Santa's List

AI First Flight #3
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: high
Valid

`buyPresent` Burns `presentReceiver`'s Tokens Instead of Caller's

Description

  • buyPresent is designed to allow anyone holding SantaTokens to spend PURCHASED_PRESENT_COST (2e18) of their own tokens in exchange for receiving an NFT. The function accepts a presentReceiver address intended to receive the gift NFT.

  • The implementation passes presentReceiver — not msg.sender — to i_santaToken.burn, burning the recipient's tokens rather than the caller's. Simultaneously, the NFT is minted to msg.sender via _mintAndIncrement, not to presentReceiver. The result is that the caller pays nothing and receives the NFT, while the designated recipient loses their tokens and gets nothing:

function buyPresent(address presentReceiver) external {
// @> burns RECEIVER's tokens, not msg.sender's — wrong target
i_santaToken.burn(presentReceiver);
// @> NFT is minted to msg.sender (the caller), not presentReceiver
_mintAndIncrement();
}
// _mintAndIncrement always mints to msg.sender
function _mintAndIncrement() private {
_safeMint(msg.sender, s_tokenCounter++);
}

Because SantaToken.burn is callable only by SantasList (enforced by the i_santasList check), but does not verify any allowance from the target address, buyPresent effectively gives SantasList the ability to burn any address's tokens on behalf of any caller — with no consent from the token holder.

Risk

Likelihood:

  • Any user aware of the bug can call buyPresent(victim) at any time after Christmas — no setup, no tokens, and no permissions are required from the attacker.

  • The SantaToken balance of every EXTRA_NICE user who has called collectPresent is permanently at risk as soon as CHRISTMAS_2023_BLOCK_TIME is reached.

Impact:

  • Attackers can drain SantaToken balances from any address without the holder's consent.

  • Attackers receive free NFTs without spending any SantaTokens of their own, breaking the economic model of buyPresent.

  • Victims receive neither the NFT nor their tokens back — the loss is permanent.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import {Test} from "forge-std/Test.sol";
import {SantasList} from "../../src/SantasList.sol";
import {SantaToken} from "../../src/SantaToken.sol";
contract CRIT02_BuyPresentWrongBurn is Test {
SantasList santasList;
SantaToken santaToken;
address santa = makeAddr("santa");
address victim = makeAddr("victim");
address attacker = makeAddr("attacker");
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
// Give victim a legitimate EXTRA_NICE status
vm.startPrank(santa);
santasList.checkList(victim, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(victim, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
// Victim legitimately collects: 1 NFT + 1e18 SantaTokens
vm.prank(victim);
santasList.collectPresent();
}
function test_AttackerBurnsVictimTokensForFreeNFT() public {
assertEq(santaToken.balanceOf(victim), 1e18, "victim starts with tokens");
assertEq(santasList.balanceOf(attacker), 0, "attacker starts with no NFTs");
assertEq(santaToken.balanceOf(attacker), 0, "attacker has no tokens");
// Attacker calls buyPresent with victim as the "receiver"
// No SantaTokens needed by the attacker
vm.prank(attacker);
santasList.buyPresent(victim);
// Victim's 1e18 tokens are burned — they paid for someone else's NFT
assertEq(santaToken.balanceOf(victim), 0, "victim's tokens drained");
// Attacker received the NFT despite paying nothing
assertEq(santasList.balanceOf(attacker), 1, "attacker got free NFT");
// Victim received nothing
assertEq(santasList.balanceOf(victim), 1, "victim still only has original NFT");
}
}

Explanation: The test places 1e18 SantaTokens in the victim's wallet (via legitimate collectPresent). The attacker — holding zero tokens — calls buyPresent(victim). The contract burns the victim's tokens (i_santaToken.burn(victim)) and mints the NFT to the attacker (_safeMint(msg.sender, ...)). The victim loses 1e18 tokens and gains nothing; the attacker gains an NFT without spending anything.

Recommended Mitigation

function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver);
+ i_santaToken.burn(msg.sender);
_mintAndIncrement();
}

Explanation: Burning from msg.sender ensures the caller — the one initiating the purchase — pays the token cost. This matches the protocol's documented behavior ("anyone with SantaTokens can buy a present"). Optionally, if the intent is for the NFT to go to presentReceiver rather than the caller, _mintAndIncrement should also be updated to accept a recipient address:

- function _mintAndIncrement() private {
- _safeMint(msg.sender, s_tokenCounter++);
- }
+ function _mintAndIncrement(address to) private {
+ _safeMint(to, s_tokenCounter++);
+ }
function buyPresent(address presentReceiver) external {
i_santaToken.burn(msg.sender);
- _mintAndIncrement();
+ _mintAndIncrement(presentReceiver);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-03] SantasList::buyPresent burns token from presentReceiver instead of caller and also sends present to caller instead of presentReceiver.

## Description The `buyPresent` function sends the present to the `caller` of the function but burns token from `presentReceiver` but the correct method should be the opposite of it. Due to this implementation of the function, malicious caller can mint NFT by burning the balance of other users by passing any arbitrary address for the `presentReceiver` field and tokens will be deducted from the `presentReceiver` and NFT will be minted to the malicious caller. Also, the NatSpec mentions that one has to approve `SantasList` contract to burn their tokens but it is not required and even without approving the funds can be burnt which means that the attacker can burn the balance of everyone and mint a large number of NFT for themselves. `buyPresent` function should send the present (NFT) to the `presentReceiver` and should burn the SantaToken from the caller i.e. `msg.sender`. ## Vulnerability Details The vulnerability lies inside the SantasList contract inside the `buyPresent` function starting from line 172. The buyPresent function takes in `presentReceiver` as an argument and burns the balance from `presentReceiver` instead of the caller i.e. `msg.sender`, as a result of which an attacker can specify any address for the `presentReceiver` that has approved or not approved the SantasToken (it doesn't matter whether they have approved token or not) to be spent by the SantasList contract, and as they are the caller of the function, they will get the NFT while burning the SantasToken balance of the address specified in `presentReceiver`. This vulnerability occurs due to wrong implementation of the buyPresent function instead of minting NFT to presentReceiver it is minted to caller as well as the tokens are burnt from presentReceiver instead of burning them from `msg.sender`. Also, the NatSpec mentions that one has to approve `SantasList` contract to burn their tokens but it is not required and even without approving the funds can be burnt which means that the attacker can burn the balance of everyone and mint a large number of NFT for themselves. ```cpp /* * @notice Buy a present for someone else. This should only be callable by anyone with SantaTokens. * @dev You'll first need to approve the SantasList contract to spend your SantaTokens. */ function buyPresent(address presentReceiver) external { @> i_santaToken.burn(presentReceiver); @> _mintAndIncrement(); } ``` ## PoC Add the test in the file: `test/unit/SantasListTest.t.sol` Run the test: ```cpp forge test --mt test_AttackerCanMintNft_ByBurningTokensOfOtherUsers ``` ```cpp function test_AttackerCanMintNft_ByBurningTokensOfOtherUsers() public { // address of the attacker address attacker = makeAddr("attacker"); vm.startPrank(santa); // Santa checks user once as EXTRA_NICE santasList.checkList(user, SantasList.Status.EXTRA_NICE); // Santa checks user second time santasList.checkTwice(user, SantasList.Status.EXTRA_NICE); vm.stopPrank(); // christmas time 🌳🎁 HO-HO-HO vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME()); // User collects their NFT and tokens for being EXTRA_NICE vm.prank(user); santasList.collectPresent(); assertEq(santaToken.balanceOf(user), 1e18); uint256 attackerInitNftBalance = santasList.balanceOf(attacker); // attacker get themselves the present by passing presentReceiver as user and burns user's SantaToken vm.prank(attacker); santasList.buyPresent(user); // user balance is decremented assertEq(santaToken.balanceOf(user), 0); assertEq(santasList.balanceOf(attacker), attackerInitNftBalance + 1); } ``` ## Impact - Due to the wrong implementation of function, an attacker can mint NFT by burning the SantaToken of other users by passing their address for the `presentReceiver` argument. The protocol assumes that user has to approve the SantasList in order to burn token on their behalf but it will be burnt even though they didn't approve it to `SantasList` contract, because directly `_burn` function is called directly by the `burn` function and both of them don't check for approval. - Attacker can burn the balance of everyone and mint a large number of NFT for themselves. ## Recommendations - Burn the SantaToken from the caller i.e., `msg.sender` - Mint NFT to the `presentReceiver` ```diff + function _mintAndIncrementToUser(address user) private { + _safeMint(user, s_tokenCounter++); + } function buyPresent(address presentReceiver) external { - i_santaToken.burn(presentReceiver); - _mintAndIncrement(); + i_santaToken.burn(msg.sender); + _mintAndIncrementToUser(presentReceiver); } ``` By applying this recommendation, there is no need to worry about the approvals and the vulnerability - 'tokens can be burnt even though users don't approve' will have zero impact as the tokens are now burnt from the caller. Therefore, an attacker can't burn others token.

Support

FAQs

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

Give us feedback!