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:
require(!hasAttended[performanceId][msg.sender], "Already attended this performance");
hasAttended[performanceId][msg.sender] = true;
Key issues:
hasAttended mapping only considers the caller's address
No tracking of which specific NFT (token ID) was used for attendance
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 {
vm.deal(user1, 1 ether);
vm.prank(user1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
uint256 baseTime = block.timestamp + 1 hours;
vm.prank(organizer);
uint256 performanceId = festivalPass.createPerformance(baseTime, 2 hours, 100e18);
vm.warp(baseTime + 30 minutes);
vm.prank(user1);
festivalPass.attendPerformance(performanceId);
assertEq(beatToken.balanceOf(user1), 100e18);
vm.prank(user1);
festivalPass.safeTransferFrom(user1, user2, 1, 1, "");
assertEq(festivalPass.balanceOf(user1, 1), 0);
assertEq(festivalPass.balanceOf(user2, 1), 1);
vm.prank(user2);
festivalPass.attendPerformance(performanceId);
assertEq(beatToken.balanceOf(user2), 100e18);
assertEq(beatToken.balanceOf(user1) + beatToken.balanceOf(user2), 200e18);
}
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.