Beatland Festival

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

configurePass always resets passSupply to 0, so repricing a tier after sales reopens a full maxSupply on top of the passes already minted, oversells the tier and corrupts passSupply

Root + Impact

Description

  • 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.

// src/FestivalPass.sol L23, L54-L66 and L74; @> on L23, L65, L74
@> mapping(uint256 => uint256) public passSupply; // Current minted
​
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
}
// ... buyPass (L69):
@> require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");

Risk

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.

Proof of Concept

  • 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

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
​
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
​
contract PoC_BF03 is Test {
FestivalPass festival;
BeatToken beat;
address organizer = makeAddr("organizer");
​
uint256 constant GENERAL = 1;
uint256 constant VIP = 2;
​
function setUp() public {
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
}
​
function _buy(uint256 passId, uint256 i, uint256 price) internal returns (address buyer) {
buyer = address(uint160(0x10000 + passId * 1000 + i));
vm.deal(buyer, price);
vm.prank(buyer);
festival.buyPass{value: price}(passId);
}
​
function _countHolders(uint256 passId, uint256 n) internal view returns (uint256 total) {
for (uint256 i = 0; i < n; i++) {
total += festival.balanceOf(address(uint160(0x10000 + passId * 1000 + i)), passId);
}
}
​
/// Baseline + break: VIP cap 2, sells out, then a routine reprice with the SAME cap
/// lets another 2 VIP passes be sold (4 passes, 20 BEAT welcome bonus vs cap 2).
function test_RepricingResetsSupplyAndOversellsTier() public {
uint256 cap = 2;
uint256 p1 = 0.1 ether;
vm.prank(organizer);
festival.configurePass(VIP, p1, cap);
​
// Baseline: cap is enforced
_buy(VIP, 0, p1);
_buy(VIP, 1, p1);
assertEq(festival.passSupply(VIP), 2);
address extra = address(0xBEEF);
vm.deal(extra, p1);
vm.prank(extra);
vm.expectRevert("Max supply reached");
festival.buyPass{value: p1}(VIP);
​
// Honest repricing, intending to keep the cap at 2
uint256 p2 = 0.15 ether;
vm.prank(organizer);
festival.configurePass(VIP, p2, cap);
​
// Accounting is wiped although 2 passes still exist
assertEq(festival.passSupply(VIP), 0, "passSupply reset to 0");
assertEq(_countHolders(VIP, 2), 2, "2 VIP passes still exist");
​
// Two more sales succeed past the declared cap
_buy(VIP, 2, p2);
_buy(VIP, 3, p2);
​
uint256 minted = _countHolders(VIP, 4);
console.log("passMaxSupply(VIP):", festival.passMaxSupply(VIP));
console.log("passSupply(VIP) reported:", festival.passSupply(VIP));
console.log("VIP passes actually minted:", minted);
console.log("BEAT welcome bonus minted:", beat.totalSupply());
​
assertEq(minted, 4, "4 VIP passes exist");
assertGt(minted, festival.passMaxSupply(VIP), "tier oversold beyond cap");
assertEq(festival.passSupply(VIP), 2, "passSupply misreports minted count");
assertEq(beat.totalSupply(), 4 * 5e18, "extra welcome bonus BEAT minted");
assertEq(festival.getMultiplier(address(uint160(0x10000 + VIP * 1000 + 3))), 2, "extra 2x holder");
assertEq(address(festival).balance, 2 * p1 + 2 * p2);
}
​
/// Repeated reconfigurations compound: three configure calls with cap 2 -> 6 passes.
function test_RepeatedReconfigurationCompounds() public {
uint256 cap = 2;
uint256 price = 0.05 ether;
uint256 idx;
for (uint256 round = 0; round < 3; round++) {
price += 0.01 ether;
vm.prank(organizer);
festival.configurePass(GENERAL, price, cap);
_buy(GENERAL, idx++, price);
_buy(GENERAL, idx++, price);
}
uint256 minted = _countHolders(GENERAL, idx);
console.log("GENERAL cap:", festival.passMaxSupply(GENERAL));
console.log("GENERAL minted after 3 configs:", minted);
assertEq(minted, 3 * cap, "3x the declared cap sold");
assertEq(festival.passSupply(GENERAL), cap);
}
​
/// Attempting to freeze the tier at the number already sold reopens a full batch instead.
function test_FreezeAttemptReopensSales() public {
vm.prank(organizer);
festival.configurePass(GENERAL, 0.05 ether, 10);
_buy(GENERAL, 0, 0.05 ether);
_buy(GENERAL, 1, 0.05 ether);
_buy(GENERAL, 2, 0.05 ether);
​
// Organizer tries to close the tier at 3 sold
vm.prank(organizer);
festival.configurePass(GENERAL, 0.05 ether, 3);
​
_buy(GENERAL, 3, 0.05 ether);
_buy(GENERAL, 4, 0.05 ether);
_buy(GENERAL, 5, 0.05 ether);
assertEq(_countHolders(GENERAL, 6), 6, "6 sold after 'freeze' at 3");
​
// maxSupply = 0 is rejected, so a cap cannot express 'closed'
vm.prank(organizer);
vm.expectRevert("Max supply must be greater than 0");
festival.configurePass(GENERAL, 0.05 ether, 0);
}
}

Observed output (excerpt):

[PASS] test_FreezeAttemptReopensSales() (gas: 340116)
[PASS] test_RepeatedReconfigurationCompounds() (gas: 348772)
GENERAL cap: 2
GENERAL minted after 3 configs: 6
[PASS] test_RepricingResetsSupplyAndOversellsTier() (gas: 429440)
passMaxSupply(VIP): 2
passSupply(VIP) reported: 2
VIP passes actually minted: 4
BEAT welcome bonus minted: 20000000000000000000
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

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.

require(price > 0, "Price must be greater than 0");
require(maxSupply > 0, "Max supply must be greater than 0");
+ require(maxSupply >= passSupply[passId], "Max supply below passes already sold");
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
- passSupply[passId] = 0; // Reset current supply
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!