Beatland Festival

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

[H-02] Pass Re-use Across Addresses In `FestivalPass::attendPerformance` function lead to — Unlimited Attendance Rewards

Pass Re-use Across Addresses — Unlimited Attendance Rewards

Description

  • Normal behaviour: Each festival pass (ERC-1155 IDs 1-3) should allow its holder to earn BEAT tokens once per performance.

  • Issue: Attendance is tracked per caller address via hasAttended[performanceId][msg.sender]. After attending, a user can transfer their pass to another address; the new holder passes the hasPass() check and the per-address guard is still false, so they can attend again. The cycle can be repeated indefinitely during the same performance window.

// FestivalPass.sol – attendPerformance()
require(hasPass(msg.sender), "Must own a pass");
@> require(!hasAttended[performanceId][msg.sender], "Already attended");
hasAttended[performanceId][msg.sender] = true; // tracks by address only

Risk

Likelihood:

  • Passes are freely transferable and trades often occur; moving a pass between wallets is common.

  • Performances last hours; plenty of time for an automated attacker to rotate a pass among dozens of wallets.

Impact:

  • Unlimited inflation of BEAT tokens; a single VIP pass can mint hundreds of times its intended reward.

  • Honest users’ relative share diminishes; project tokenomics and reputation suffer.

Proof of Concept

The Forge test test_PoC_PassReuse_MultipleAttendance() demonstrates the flow:

  1. Alice buys a VIP pass and, during the concert, claims her attendance reward (2× multiplier).

  2. She transfers that very pass to Bob while the performance is still ongoing.

  3. Bob calls attendPerformance() for the same performanceId and receives another full reward because the contract only checked hasAttended[perfId][Bob] (which was false).

  4. Both wallets now hold the expected BEAT balances, confirming the pass was reused.

/* ---------------------------------------------------------------------- */
/* PoC-3: Pass Token Re-use across multiple accounts */
/* ---------------------------------------------------------------------- */
/**
* @dev Shows how a single pass can be transferred and reused to earn
* rewards multiple times from the same performance.
*/
function test_PoC_PassReuse_MultipleAttendance() public {
// Alice purchases a VIP pass (receives 5 BEAT welcome bonus)
vm.prank(alice);
festivalPass.buyPass{value: 0.1 ether}(VIP_PASS);
// Organizer schedules a performance in 1h with base reward 100 BEAT
uint256 startTime = block.timestamp + 1 hours;
vm.prank(organizer);
uint256 perfId = festivalPass.createPerformance(startTime, 2 hours, 100e18);
// Warp into the performance window
vm.warp(startTime + 10 minutes);
// Alice attends and receives reward (2x multiplier for VIP)
vm.prank(alice);
festivalPass.attendPerformance(perfId);
// Alice now transfers her VIP pass to Bob
vm.startPrank(alice);
festivalPass.safeTransferFrom(alice, bob, VIP_PASS, 1, "");
vm.stopPrank();
// Bob attends the SAME performance, receiving rewards again.
vm.prank(bob);
festivalPass.attendPerformance(perfId);
// ------------------------------------------------------------------
// Assertions: both balances increased independently.
// ------------------------------------------------------------------
uint256 aliceExpected = 5e18 /* bonus */ + 200e18 /* 2x reward */;
uint256 bobExpected = 200e18; // no welcome bonus, but 2x reward
assertEq(beatToken.balanceOf(alice), aliceExpected, "Alice reward mismatch");
assertEq(beatToken.balanceOf(bob), bobExpected, "Bob improperly blocked - vulnerability exploited");
}

Recommended Mitigation

Two viable defences:

A. Track usage per token

- mapping(uint256 => mapping(address => bool)) public hasAttended;
+ mapping(uint256 => mapping(uint256 => bool)) public passUsed; // pid => tokenId
// Inside attendPerformance
- require(!hasAttended[pid][msg.sender], "Already attended");
- hasAttended[pid][msg.sender] = true;
+ uint256 tokenId = PASS_ID_USED_FOR_ENTRY;
+ require(!passUsed[pid][tokenId], "Pass already used");
+ passUsed[pid][tokenId] = true;

This assigns the NFT itself as the key, so even after transfers the pass cannot be recycled.

B. Temporarily freeze transfers

Override _beforeTokenTransfer to reject moves of IDs 1-3 while any performance is active (block timestamp between startTime and endTime). This solution is simpler but imposes UX restrictions on secondary-market trading during events.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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.