Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

External Call Before Internal State Update in buyPass (CEI Violation)

The buyPass() function in FestivalPass.sol performs an external call to the BeatToken contract's mint() function before completing all internal state updates.

Description

  • first of all im sorry for any inconvenience in reading the code or in anything as this is my first submission.

  • The buyPass() function currently violates the Checks-Effects-Interactions (CEI) pattern by invoking an external call to BeatToken.mint before all internal state transitions are securely finalized. Although passSupply is incremented prior to the external call, this alone does not fully safeguard the function. The external mint call may trigger untrusted behavior (e.g., via future modifications to BeatToken or ERC1155 receiver hooks), which could enable reentrant entry into buyPass.

    the contract does not implement any reentrancy protection mechanisms such as a nonReentrant modifier or proper isolation of internal logic. This opens the possibility for an attacker to exploit the contract through reentrancy before internal logic is complete, potentially bypassing supply constraints or claiming BEAT token rewards multiple times.

// line 95-96 in FestivalPass.sol in the function buyPass - 77line-
// updating after mint!
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];

Risk : High

Likelihood:

  • A malicious contract implementing the ERC1155Receiver interface can:

    • In the onERC1155Received() callback, call buyPass() again recursively.

    • Since passSupply is incremented only after the external _mint() call, the nested call sees outdated state and incorrectly allows further minting.

    Consequences:

    • Attacker can mint unlimited festival passes

    • Attacker can claim unlimited BEAT token bonuses tied to VIP or BACKSTAGE passes.

    • Results in financial loss and broken pass distribution logic.

Impact:

  • Attacker having more then one Passes breaking the protocol's functonality

  • Contracts's security severly damaged and financial loss

Proof of Concept:

This PoC demonstrates a reentrancy vulnerability in the buyPass function of the FestivalPass contract. It does so by deploying a malicious contract (AttackerContract) that exploits the fact that FestivalPass calls an external contract (BeatToken.mint) before completing its internal state updates.

In this specific test:

  • The attacker initiates a buyPass call with enough ETH for a single VIP pass.

  • Upon receiving the ERC1155 token, the attacker’s onERC1155Received callback re-enters buyPass again, bypassing supply limits.

  • This results in the attacker receiving 2 VIP passes and 10 BEAT tokens, despite paying only once and when only 1 pass was expected.

The test confirms the vulnerability by checking that:

  • The attacker receives BEAT tokens for both mints.

  • The festival contract receives payment only once.

This clearly proves that the function is vulnerable to reentrancy and allows unauthorized repeated state manipulation and token minting.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;
import "forge-std/Test.sol";
import "../src/FestivalPass.sol";
import "../src/BeatToken.sol";
import "../src/AttackerContract.sol";
contract FestivalPassReentrancyTest is Test {
FestivalPass public festival;
BeatToken public beat;
AttackerContract public attacker;
address public organizer = address(0xA0);
address public user = address(0xB0);
uint256 public constant VIP_PASS = 2;
uint256 public constant PASS_PRICE = 0.1 ether;
uint256 public constant MAX_SUPPLY = 10;
function setUp() public {
vm.startPrank(organizer);
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
festival.configurePass(VIP_PASS, PASS_PRICE, MAX_SUPPLY);
vm.stopPrank();
attacker = new AttackerContract(address(festival));
vm.deal(address(attacker), 1 ether);
}
function testReentrancyExploit() public {
uint256 initialSupply = festival.passSupply(VIP_PASS);
uint256 initialBalance = attacker.balance;
vm.prank(address(attacker));
attacker.attack{value: PASS_PRICE}(VIP_PASS);
uint256 finalSupply = festival.passSupply(VIP_PASS);
uint256 attackerBalance = festival.balanceOf(address(attacker), VIP_PASS);
uint256 beatBalance = beat.balanceOf(address(attacker));
assertEq(attackerBalance, 2, "Attacker should have received 2 passes");
assertEq(finalSupply, initialSupply + 2, "Supply should be increased twice");
assertEq(beatBalance, 10e18, "Attacker should receive bonus twice");
}
}

Recommended Mitigation:

This is the code which will not have reentrancy issue.

A good Recommendation from me would be that use ReentrancyGuard on functions that are:

// additional security from Reentrancy Attacks
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
  1. Transfer ETH or tokens (e.g., payable or mint/burn).

  2. Interact with untrusted contracts (external calls).

  3. Modify critical state variables that could be exploited if called recursively.

  4. It accepts ETH

  5. It updates state

  6. It calls an external contract.



// CHANGE THE CODE WITH THE + ONE.
// just write the state change before mint
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ ++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
Updates

Lead Judging Commences

inallhonesty Lead Judge 26 days ago
Submission Judgement Published
Validated
Assigned finding tags:

buyPass reentrancy to surpass the passMaxSupply

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.