Beatland Festival

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

Replay exploit: transferable FestivalPass can farm rewards multiple times per performance

Root + Impact

Description

attendPerformance(uint256 performanceId) is meant to grant rewards once per pass-holder for each performance.
The safeguard is a mapping keyed only by the caller’s address:

require(
!hasAttended[performanceId][msg.sender],
"You already attended"
);
hasAttended[performanceId][msg.sender] = true;

Because a FestivalPass is an ERC-1155 token and therefore freely transferable:

  1. Alice (wallet A) calls attendPerformance() → mapping set for A.

  2. Alice transfers the very same pass to wallet B.

  3. Wallet B calls attendPerformance() → mapping for B is empty → rewards minted again.

  4. The cycle can be repeated with unlimited wallets (or even within the same block via flash-loans).

Consequences

  • Unlimited BEAT (or perk) inflation: each physical pass can print rewards N times.

  • Bypasses time-based attendance limit: transferring the pass sidesteps the intended cooldown entirely.

  • Economic & reputational damage: honest attendees are diluted, leaderboards and reward programmes become unfair.

@> mapping(uint256 => mapping(address => bool)) public hasAttended; // keyed by address only
...
@> require(!hasAttended[performanceId][msg.sender], "You already attended");

Risk

Likelihood:

  • Anyone can script pass transfers between their own wallets; no special privileges needed.

Impact:

  • Economic loss through unbounded BEAT issuance.

  • Reputational harm from reward-farming and ticket-scalping scenarios.

Proof of Concept

The PoC shows the same physical pass earning rewards twice by moving between two wallets, proving that the current address-centric check is insufficient.

function test_ReplayExploit() public {
uint256 startTime = block.timestamp + 1 hours;
uint256 duration = 2 hours;
uint256 reward = 100e18;
vm.prank(organizer);
vm.expectEmit(true, true, true, true);
emit PerformanceCreated(0, startTime, startTime + duration);
uint256 perfId = festivalPass.createPerformance(startTime, duration, reward);
vm.warp(2 hours);
// Wallet A attends once
vm.prank(user1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
vm.prank(user1);
festivalPass.attendPerformance(perfId);
// Alice transfers the pass to wallet B
vm.prank(user1);
festivalPass.safeTransferFrom(user1, user2, 1, 1, "");
// Wallet B attends again with the same token
vm.prank(user2);
festivalPass.attendPerformance(perfId);
// Expect twice the reward
assertEq(
beatToken.balanceOf(user1) + beatToken.balanceOf(user2),
reward * 2
);
}

Recommended Mitigation

Index attendance by passId (or burn/lock the pass) so the token itself can be used only once per performance, regardless of how many times it changes hands.

- mapping(uint256 => mapping(address => bool)) public hasAttended;
+ mapping(uint256 => mapping(uint256 => bool)) public hasAttended; // performanceId → passId
function attendPerformance(uint256 performanceId, uint256 passId) external {
require(balanceOf(msg.sender, passId) > 0, "Not pass holder");
- require(!hasAttended[performanceId][msg.sender], "Already attended");
- hasAttended[performanceId][msg.sender] = true;
+ require(!hasAttended[performanceId][passId], "Pass already used");
+ hasAttended[performanceId][passId] = true;
...
}
Updates

Lead Judging Commences

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