Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

# L-5: `configurePass` resets `passSupply` to zero, allowing sales beyond `maxSupply`

Root + Impact

configurePass unconditionally sets passSupply[passId] = 0 every time it is called. passSupply is meant to track how many passes of that tier have been minted, but resetting it to zero while previously-minted passes still exist decouples the counter from reality. After a reconfiguration, the passSupply < passMaxSupply guard in buyPass allows another full maxSupply worth of passes to be sold, so the number of pass NFTs in circulation can exceed the advertised maximum.

Description

// src/FestivalPass.sol:54-66
function configurePass(uint256 passId, uint256 price, uint256 maxSupply) external onlyOrganizer {
require(passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS, "Invalid pass ID");
require(price > 0, "Price must be greater than 0");
require(maxSupply > 0, "Max supply must be greater than 0");
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
@> passSupply[passId] = 0; // Reset current supply — also erases already-minted count
}

The reset is not tied to any burn; existing ERC1155 balances are untouched. buyPass at :74 therefore compares a zeroed counter against maxSupply.

Risk

Likelihood: Low. Only the trusted organizer can call configurePass, and the README states the organizer is trusted. It triggers only when a tier is reconfigured after it has already sold passes (e.g., a price change mid-festival).

Impact: More passes than maxSupply can exist for a tier, breaking the scarcity invariant. Because configurePass also lets the organizer set maxSupply to any value, the practical delta is small — but the accounting desync (passSupply under-reporting actual supply) is real and would corrupt any downstream logic that trusts the counter.

Proof of Concept

The test configures GENERAL with maxSupply = 1, sells one pass, then reconfigures the same tier (reset to zero), then a second user buys. Two pass NFTs now exist against maxSupply == 1, and passSupply reads 1, proving the counter no longer reflects circulating supply.

// test/Exploits.t.sol
function test_ConfigurePassResetExceedsMaxSupply() public {
vm.prank(organizer);
fp.configurePass(1, GENERAL_PRICE, 1); // maxSupply = 1
​
address u1 = makeAddr("u1");
address u2 = makeAddr("u2");
vm.deal(u1, 1 ether); vm.deal(u2, 1 ether);
vm.prank(u1);
fp.buyPass{value: GENERAL_PRICE}(1);
​
vm.prank(organizer);
fp.configurePass(1, GENERAL_PRICE, 1); // resets passSupply to 0
assertEq(fp.passSupply(1), 0);
​
vm.prank(u2);
fp.buyPass{value: GENERAL_PRICE}(1);
​
// bug proof: two live passes against maxSupply == 1
assertEq(fp.passMaxSupply(1), 1);
assertEq(fp.balanceOf(u1, 1) + fp.balanceOf(u2, 1), 2);
}

Recommended Mitigation

Do not reset existing supply when reconfiguring. If a fresh sale round is genuinely intended, require that no passes have been minted yet (passSupply[passId] == 0) or introduce a separate epoch/round counter. As a minimum, only reset when the tier is unsold.

function configurePass(uint256 passId, uint256 price, uint256 maxSupply) external onlyOrganizer {
require(passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS, "Invalid pass ID");
require(price > 0, "Price must be greater than 0");
require(maxSupply > 0, "Max supply must be greater than 0");
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
- passSupply[passId] = 0; // Reset current supply
+ // Never erase already-minted supply; a new round needs its own accounting.
+ require(passSupply[passId] == 0, "Tier already has sales");
}

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] [H-1] Reseting the current pass supply to 0 in the FestivalPass::configurePass function allows users to bypass the max supply cap of a pass

# \[H-1] Reseting the current pass supply to `0` in the `FestivalPass::configurePass` function allows users to bypass the max supply cap of a pass ## Description: ```solidity function configurePass(uint256 passId, uint256 price, uint256 maxSupply) external onlyOrganizer { require(passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS, "Invalid pass ID"); require(price > 0, "Price must be greater than 0"); require(maxSupply > 0, "Max supply must be greater than 0"); passPrice[passId] = price; passMaxSupply[passId] = maxSupply; @> passSupply[passId] = 0; // Reset current supply } ``` If you reset `passSupply[passId]` to `0` in the `FestivalPass::configurePass` function after passes have been sold, the next buyer will be able to mint as if no passes have been sold. This allows the total minted passes to exceed `passMaxSupply`, which is a serious vulnerability (a supply cap bypass) ## Impact: * Supply caps become meaningless: The users can mint unlimited passes beyond the intended maximum supply * Pass scarcity and value are destroyed, affecting the economic model ## Proof of Concept: ```solidity function test_SupplyCapBypassVulnerability() public { // Step 1: Configure a pass with max supply of 2 vm.prank(organizer); festivalPass.configurePass(1, GENERAL_PRICE, 2); // Step 2: Buy 2 passes (reaching max supply) vm.prank(user1); festivalPass.buyPass{value: GENERAL_PRICE}(1); vm.prank(user2); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Verify max supply reached assertEq(festivalPass.passSupply(1), 2); assertEq(festivalPass.passMaxSupply(1), 2); // Step 3: Try to buy another pass - should fail address user3 = makeAddr("user3"); vm.deal(user3, 10 ether); vm.prank(user3); vm.expectRevert("Max supply reached"); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Step 4: VULNERABILITY - Organizer reconfigures the pass // This resets passSupply[1] to 0, bypassing the supply cap! vm.prank(organizer); festivalPass.configurePass(1, GENERAL_PRICE, 2); // Step 5: Now we can buy more passes even though max supply was already reached vm.prank(user3); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Step 6: We can even buy more passes beyond the original max supply vm.deal(user4, 10 ether); vm.prank(user4); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Step 7: Verify the vulnerability - total supply exceeds max supply assertEq(festivalPass.passSupply(1), 2); // Current supply counter assertEq(festivalPass.passMaxSupply(1), 2); // Max supply limit // But we actually have 4 passes minted in total! assertEq(festivalPass.balanceOf(user1, 1), 1); assertEq(festivalPass.balanceOf(user2, 1), 1); assertEq(festivalPass.balanceOf(user3, 1), 1); assertEq(festivalPass.balanceOf(user4, 1), 1); // Total minted: 4 passes, but max supply is only 2! uint256 totalMinted = festivalPass.balanceOf(user1, 1) + festivalPass.balanceOf(user2, 1) + festivalPass.balanceOf(user3, 1) + festivalPass.balanceOf(user4, 1); assertGt(totalMinted, festivalPass.passMaxSupply(1), "VULNERABILITY: Total minted exceeds max supply!"); } ``` ## Recommended Mitigation: The `passSupply` reset should be removed ```diff function configurePass(uint256 passId, uint256 price, uint256 maxSupply) external onlyOrganizer { require(passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS, "Invalid pass ID"); require(price > 0, "Price must be greater than 0"); require(maxSupply > 0, "Max supply must be greater than 0"); passPrice[passId] = price; passMaxSupply[passId] = maxSupply; - passSupply[passId] = 0; } ```

Support

FAQs

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

Give us feedback!