Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Missing Reentrancy Guard in FestivalPass.sol Exposes External Call Vulnerability

Root + Impact

Description

  • The FestivalPass contract performs external calls to the BeatToken contract’s mint() and burnFrom() functions inside buyPass() and redeemMemorabilia(). These functions are public and currently lack reentrancy protection.

  • If beatToken is ever set to a malicious contract, an attacker might trigger recursive calls before internal state updates (such as supply limits or ETH transfers) are finalized. This potentially can result in over-minting of NFTs, bypassing limits, or draining funds.

// buyPass() contains external call to BeatToken without reentrancy protection
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");
_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); // External call
}
emit PassPurchased(msg.sender, collectionId);
}
// redeemMemorabilia() also makes external call to burnFrom()
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
require(collection.priceInBeat > 0, "Collection does not exist");
require(collection.isActive, "Collection not active");
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
@> BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat); // External call
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}

Risk

Likelihood:

  • Happens if beatToken is set to a malicious or upgradeable contract.

  • Could occur in testing, misconfiguration, or future development.

Impact:

  • Bypass of pass/memorabilia limits

  • Draining of ETH via repeated execution before supply limits are hit

  • State inconsistency or abuse of reward distribution logic

Proof of Concept

contract MaliciousToken {
FestivalPass public pass;
bool public reentered = false;
constructor(address _pass) {
pass = FestivalPass(_pass);
}
function mint(address, uint256) external {
if (!reentered) {
reentered = true;
// Reentrantly call buyPass or redeemMemorabilia
pass.buyPass(1);
}
}
function burnFrom(address, uint256) external {}
}

Explanation:

  • This contract simulates an attacker replacing the BeatToken contract with a malicious version. When mint() is called, it reentrantly calls buyPass() before the state in FestivalPass has finished updating. This allows bypassing logic like supply limits, or potentially draining ETH if left unchecked.

Recommended Mitigation

Explanation:

  • Using OpenZeppelin’s ReentrancyGuard ensures that no external contract can call back into a vulnerable function before the original execution completes. This helps protect any function that interacts with external contracts, like mint() and burnFrom(), from being exploited through reentrancy.

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass {
+ contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass, ReentrancyGuard {
- function buyPass(uint256 collectionId) external payable {
+ function buyPass(uint256 collectionId) external payable nonReentrant {
- function redeemMemorabilia(uint256 collectionId) external {
+ function redeemMemorabilia(uint256 collectionId) external nonReentrant {
- function withdraw(address target) external onlyOwner {
+ function withdraw(address target) external onlyOwner nonReentrant {
Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Too generic

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.