Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

No per-buyer purchase limit (DoS via bulk buy)

Root + Impact

Missing per-address cap and purchased-mapping check in buy()

Description

The buy() function contains no check to determine whether a given address has already purchased a pass, nor does it enforce any per-wallet quantity cap. This means any external account can call buy() an unlimited number of times within a single transaction or across multiple transactions, accumulating as many passes as the remaining supply allows.

Because Ethereum transactions are atomic and gas limits on modern networks are high enough to support hundreds of consecutive calls, a malicious actor can write a simple attack contract that loops through buy() until passSupply is exhausted. Once the supply is drained, all subsequent calls from legitimate users will revert with a "supply exhausted" error, effectively locking out the entire user base from acquiring passes.

This is a classic application-level Denial of Service (DoS) pattern. It does not require any exploit of the EVM itself — only the absence of a business-logic guard. The attacker does not even need to be sophisticated; a basic script or a simple smart contract loop is sufficient. Since the attacker acquires real tokens in the process, there is also a secondary financial motivation: they can resell passes on the secondary market at a premium after manufacturing artificial scarcity.

function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// Check payment and supply
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
// Mint BEAT tokens to buyer
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

This vulnerability is rated High likelihood. The attack requires only a standard EOA or a minimal attack contract and a sufficient ETH balance to purchase all passes. There is no time-lock, no CAPTCHA-equivalent, no rate limiting on-chain, and no whitelist preventing this. The attack can be executed in a single block. Given the financial incentive (cornering the pass supply for resale), motivated actors are likely to attempt this within moments of the contract going live.

Impact:

This vulnerability is rated High impact. A successful attack prevents the entire intended user base from participating in the pass sale, rendering the launch effectively a failure. The protocol suffers reputational damage and potential legal exposure depending on the jurisdiction. While the attacker does spend ETH to acquire the passes, the harm to all other prospective buyers is severe and immediate. If the contract has a refund or cancellation mechanism, the attacker may even be able to unwind their position, making the attack low-cost with high disruption potential.

Proof of Concept

The attack contract BulkBuyer calls buy() in a tight loop up to totalSupply times in a single transaction. Since there is no per-address mapping check, every iteration succeeds. After the transaction confirms, the attacker's wallet holds all available passes and no other buyer can acquire one. The loop runs entirely within one block, making front-running countermeasures ineffective.

// Attack contract (Solidity):
contract BulkBuyer {
IPassContract target;
constructor(address _target) { target = IPassContract(_target); }
function drainSupply(uint256 total) external payable {
uint256 price = target.passPrice();
for (uint256 i = 0; i < total; i++) {
target.buy{value: price}();
}
}
}
// Off-chain (ethers.js):
const attacker = new BulkBuyer(passContractAddress);
await attacker.drainSupply(totalSupply, { value: price * totalSupply });

Recommended Mitigation

The fix introduces a purchaseCount mapping that tracks how many passes each address has purchased. The require guard enforces MAX_PER_WALLET — set to 1 for a one-per-wallet model, but adjustable for other use cases. This makes it impossible for any single address to drain the supply regardless of how many times they call buy(). For additional protection, consider adding a commit-reveal scheme or merkle-proof allowlist to prevent Sybil attacks where one person controls many wallets.

// Add a per-address purchase tracking mapping:
mapping(address => uint256) public purchaseCount;
uint256 public constant MAX_PER_WALLET = 1;
function buy() external payable {
require(passSupply < maxSupply, "Supply exhausted");
require(msg.value == passPrice, "Incorrect ETH");
require(
purchaseCount[msg.sender] < MAX_PER_WALLET,
"Already purchased"
);
purchaseCount[msg.sender] += 1;
passSupply += 1;
_safeMint(msg.sender, passSupply);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!