Beatland Festival

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

Bonus BEAT tokens can be repeatedly claimed by transferring and re-buying passes.

Root + Impact

Description

Users purchase passes (GENERAL_PASS, VIP_PASS, BACKSTAGE_PASS) via buyPass, receiving ERC1155 tokens and BeatToken bonuses (5e18 for VIP, 15e18 for BACKSTAGE). This Passes are transferable by default, allowing users to buy a pass, claim the bonus, transfer the pass, and repeat, accumulating bonuses.

// Root cause in the codebase with @> marks to highlight the relevant section
// FestivalPass.sol
function buyPass(uint256 collectionId) external payable {
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
_mint(msg.sender, collectionId, 1, ""); // @> Transferable ERC1155 token
++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus); // @> Bonus without transfer restriction
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

  • Any user with ETH can exploit via safeTransferFrom. No restrictions on bonus claims.

  • Straightforward attack path (buy, claim, transfer, repeat).

Impact:

  • Direct fund impact via uncapped BeatToken minting, inflating supply and devaluing rewards.

  • Severe disruption of tokenomics and secondary market control.

Proof of Concept

// In FestivalPassTest.sol
function test_BonusAbuseViaTransfer() public {
// User1 buys a BACKSTAGE pass
vm.prank(user1);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
assertEq(beatToken.balanceOf(user1), 15e18); // Initial bonus
assertEq(festivalPass.balanceOf(user1, 3), 1);
// User1 transfers pass to user2
vm.prank(user1);
festivalPass.safeTransferFrom(user1, user2, 3, 1, "");
assertEq(festivalPass.balanceOf(user1, 3), 0);
assertEq(festivalPass.balanceOf(user2, 3), 1);
// User2 transfers back to user1
vm.prank(user2);
festivalPass.safeTransferFrom(user2, user1, 3, 1, "");
assertEq(festivalPass.balanceOf(user1, 3), 1);
// User1 buys another pass
vm.prank(user1);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
assertEq(beatToken.balanceOf(user1), 30e18); // Double bonus
assertEq(festivalPass.balanceOf(user1, 3), 2);
}

Recommended Mitigation

// FestivalPass.sol
+ import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155.sol";
contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass {
+ function _beforeTokenTransfer(
+ address operator,
+ address from,
+ address to,
+ uint256[] memory ids,
+ uint256[] memory amounts,
+ bytes memory data
+ ) internal virtual override {
+ super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
+ for (uint256 i = 0; i < ids.length; i++) {
+ if (ids[i] <= BACKSTAGE_PASS && from != address(0) && to != address(0)) {
+ revert("Passes are non-transferable");
+ }
+ }
+ }
}
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.