Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: low
Invalid

[M-02] `BeatToken.burnFrom` bypasses ERC20 allowance check, letting FestivalPass burn any user's BEAT without approval

Description

burnFrom in BeatToken calls _burn(from, amount) directly without first calling _spendAllowance(from, msg.sender, amount). The ERC20 standard requires burnFrom to check that the caller has sufficient allowance from the token holder. Because this check is missing, the FestivalPass contract can burn any user's BEAT tokens without their explicit approval.

Vulnerability Details

// src/BeatToken.sol, lines 24-27
function burnFrom(address from, uint256 amount) external {
require(msg.sender == festivalContract, "Only_Festival_Burn");
_burn(from, amount); // @> no _spendAllowance(from, msg.sender, amount) call
}

The require(msg.sender == festivalContract) gate restricts calling to the FestivalPass contract. Today, redeemMemorabilia only passes msg.sender as the from parameter:

// src/FestivalPass.sol, line 197
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);

So the current call path burns only the caller's own tokens. But the BeatToken contract itself has no defense. Any future code path in FestivalPass that calls burnFrom(someOtherAddress, amount) would silently burn that victim's tokens with zero approval. The standard burnFrom in OpenZeppelin's ERC20Burnable calls _spendAllowance before _burn for exactly this reason.

This is a latent vulnerability. The mechanism is broken in BeatToken itself. The only reason it doesn't cause immediate harm is that FestivalPass happens to always pass msg.sender. If the protocol adds a new function to FestivalPass, or if a new version of FestivalPass is deployed and set via setFestivalContract, the missing allowance check becomes exploitable without any change to BeatToken.

Risk

Likelihood:

  • Requires a new code path in FestivalPass (or a new FestivalPass contract) that calls burnFrom with an address other than msg.sender. Low probability in the current single-contract setup, but standard development practice is to add functions over time.

Impact:

  • Any user's BEAT balance can be burned without their consent. BEAT tokens are earned through attendance and used to redeem memorabilia NFTs. Burning a user's BEAT destroys their ability to claim memorabilia they earned through legitimate participation.

Proof of Concept

The test demonstrates that FestivalPass can burn any user's BEAT without that user ever granting an allowance to BeatToken or FestivalPass.

function testExploit_BurnFromNoAllowance() public {
// Setup: user earns BEAT through normal flow
vm.prank(organizer);
festivalPass.configurePass(3, 1 ether, 10);
vm.prank(organizer);
festivalPass.createPerformance(block.timestamp + 1, 1 days, 100e18);
vm.warp(block.timestamp + 2);
vm.deal(user, 1 ether);
vm.prank(user);
festivalPass.buyPass{value: 1 ether}(3);
vm.prank(user);
festivalPass.attendPerformance(0);
uint256 userBeat = beatToken.balanceOf(user);
assertTrue(userBeat > 0, "User should have BEAT");
// Verify: user has NOT approved FestivalPass or BeatToken
uint256 allowance = beatToken.allowance(user, address(festivalPass));
assertEq(allowance, 0, "User gave zero allowance");
// Despite zero allowance, burnFrom succeeds when called by festivalContract
vm.prank(address(festivalPass));
beatToken.burnFrom(user, userBeat);
assertEq(beatToken.balanceOf(user), 0, "EXPLOIT: Tokens burned without allowance");
}

Output:

User BEAT balance before: 315000000000000000000
User allowance to FestivalPass: 0
User BEAT balance after burnFrom: 0
EXPLOIT PROVEN: burnFrom succeeded with zero allowance

Recommendations

Add the standard allowance check before burning:

function burnFrom(address from, uint256 amount) external {
require(msg.sender == festivalContract, "Only_Festival_Burn");
+ _spendAllowance(from, msg.sender, amount);
_burn(from, amount);
}

Then update redeemMemorabilia to have users approve BeatToken spending before redemption, or use OpenZeppelin's ERC20Burnable which includes this check by default.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 8 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!