passSupply is documented in code as "Current minted", and buyPass caps sales with passSupply < passMaxSupply. IFestivalPass describes maxSupply as the "Maximum number of passes that can be sold". configurePass is the only way to set a tier's price or cap, so any price change after the sale opens must go through it.
configurePass always runs passSupply[passId] = 0. The passes already sold still exist, but the counter forgets them. After any reconfiguration, such as a routine early-bird -> regular price change with the same cap, buyPass accepts another full maxSupply of sales, and repeated calls compound. Setting maxSupply to the number already sold, in an attempt to close the tier, reopens that many sales instead.
Likelihood:
It happens whenever the organizer changes a tier's price or cap after sales have started. That is a routine operation, and configurePass is the only function that can do it.
It needs a call by the trusted organizer, but an honest one. Neither the function nor its NatSpec says the call erases the sales count. A careful organizer can avoid the oversell by passing cap - sold as the new maxSupply, or can stop sales with an unreachable price, which keeps the likelihood Low.
Impact:
The tier is oversold beyond its declared cap. In the PoC, a VIP tier with cap 2 sells out, is repriced with the same cap, and then sells 2 more: 4 VIP passes against passMaxSupply = 2, with 20 BEAT of welcome bonus instead of 10 and an extra 2x reward earner. Three configurations of a GENERAL tier with cap 2 sell 6 passes.
passSupply misreports the minted count (it shows 2 while 4 VIP passes exist) to frontends and integrators. Setting the cap to the number already sold, intending to close the tier, sells another full batch. No ETH is lost; the contract simply collects more.
test_RepricingResetsSupplyAndOversellsTier covers the baseline cap revert, the reset to 0 after repricing, two more sales, and the misreported passSupply.
test_RepeatedReconfigurationCompounds shows 6 passes sold under a cap of 2.
test_FreezeAttemptReopensSales shows a "close at 3 sold" call allowing 3 more sales, and shows that maxSupply = 0 is rejected.
Save as test/PoC_BF-03.t.sol and run from the repository root:
FOUNDRY_FFI=false forge test --match-path test/PoC_BF-03.t.sol -vv
Observed output (excerpt):
Keep the lifetime counter and never let the cap fall below what has already been sold. Setting maxSupply equal to passSupply then closes the tier.
# \[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; } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.