Santa's List

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

buyPresent mints the purchased NFT to msg.sender instead of presentReceiver, so a present bought for a friend never reaches the friend and the buyer must forward it manually

Root + Impact

Description

  • Three sources say the present should go to the named receiver: the NatSpec of buyPresent ("Buy a present for someone else"), its parameter name presentReceiver, and the README, which says SantaToken "can be used to buy the NFT for their NAUGHTY or UNKNOWN friends".

  • buyPresent reuses _mintAndIncrement, which hardcodes _safeMint(msg.sender, ...). presentReceiver is used only as the burn source and never as the mint recipient, so the purchased NFT always lands on the caller. This needs a different line and a different fix from the burn-source defect: burning from msg.sender instead would still leave the friend without the present.

// src/SantasList.sol, line 169 (NatSpec of buyPresent)
* @notice Buy a present for someone else. This should only be callable by anyone with SantaTokens.
​
// 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++);
}

Risk

Likelihood:

  • The misdelivery occurs on every buyPresent call where presentReceiver is not the caller. A fuzz test over 256 random buyer and receiver pairs confirms it.

  • It shows up only when the named receiver holds at least 1e18 SantaToken. In the README's main case, a token-less NAUGHTY friend, the call reverts earlier on the receiver's burn, which is the separate burn-source defect. That is why likelihood is Medium rather than High.

Impact:

  • The named receiver never gets the present. The buyer receives it and must spend an extra transferFrom to complete the gift. Because the NFT can be forwarded, no NFT or funds are lost.

  • A NICE buyer who buys a gift before collecting their own present is blocked from collectPresent with AlreadyCollected until they forward the purchased NFT. They can undo this themselves.

Proof of Concept

  • test_SL07_GiftIsMintedToBuyerNotReceiver: carol (EXTRA_NICE) collects, funds dave (NAUGHTY) with 1e18 so the receiver-sourced burn clears, and calls buyPresent(dave). Token #1 is minted to carol, dave's NFT balance is 0 and his SantaToken is spent. A manual transferFrom then completes the gift.

  • testFuzz_SL07_ReceiverNeverGetsTheNft: for any EOA buyer and any distinct receiver holding 1e18, ownerOf(0) == buyer and the receiver's NFT balance is unchanged (256 runs).

  • test_SL07_TokenlessFriendGiftReverts: a gift to a token-less friend reverts with an arithmetic underflow before any mint. This belongs to the burn-source defect and is shown here only to bound this finding.

  • test_SL07_BuyBeforeCollectLocksBuyerOut: frank (NICE, not yet collected) buys a gift. His collectPresent reverts with AlreadyCollected until he forwards the gift, after which he collects.

File test/PoC_SL-07.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
​
import {Test, console2, stdError} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {SantaToken} from "../src/SantaToken.sol";
​
/// SL-07: buyPresent(presentReceiver) always mints the NFT to msg.sender (L181),
/// never to presentReceiver.
contract PoC_SL07 is Test {
SantasList internal santasList;
SantaToken internal santaToken;
​
address internal santa = makeAddr("santa");
address internal carol = makeAddr("carol"); // EXTRA_NICE, holds SantaToken after collecting
address internal dave = makeAddr("dave"); // NAUGHTY friend, the intended gift recipient
address internal frank = makeAddr("frank"); // NICE, has not collected yet
​
uint256 internal constant CHRISTMAS = 1_703_480_381;
​
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
​
vm.startPrank(santa);
santasList.checkList(carol, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(carol, SantasList.Status.EXTRA_NICE);
santasList.checkList(dave, SantasList.Status.NAUGHTY);
santasList.checkTwice(dave, SantasList.Status.NAUGHTY);
santasList.checkList(frank, SantasList.Status.NICE);
santasList.checkTwice(frank, SantasList.Status.NICE);
vm.stopPrank();
}
​
/// Carol collects her EXTRA_NICE present: NFT #0 + 1e18 SantaToken.
function _carolCollects() internal {
vm.warp(CHRISTMAS + 1);
vm.prank(carol);
santasList.collectPresent();
assertEq(santasList.ownerOf(0), carol, "baseline: collectPresent mints to the collector");
assertEq(santaToken.balanceOf(carol), 1e18, "baseline: EXTRA_NICE gets 1e18 SANTA");
}
​
/// Limb 1 (core): Carol funds and buys a present "for" Dave; the NFT lands on Carol, Dave gets nothing.
/// Carol first hands Dave the SANTA so the call clears the burn (burn source is presentReceiver, L173);
/// this way Carol is the economic payer, which matches the intended "burn from buyer" economics.
function test_SL07_GiftIsMintedToBuyerNotReceiver() public {
_carolCollects();
​
vm.prank(carol);
santaToken.transfer(dave, 1e18);
​
uint256 carolNftBefore = santasList.balanceOf(carol);
uint256 daveNftBefore = santasList.balanceOf(dave);
assertEq(daveNftBefore, 0, "dave starts with no present");
​
vm.prank(carol);
santasList.buyPresent(dave); // "Buy a present for someone else"
​
console2.log("owner of purchased token #1:", santasList.ownerOf(1));
console2.log("carol NFT balance:", santasList.balanceOf(carol));
console2.log("dave NFT balance:", santasList.balanceOf(dave));
console2.log("dave SANTA balance (spent):", santaToken.balanceOf(dave));
​
// The bug: the purchased NFT goes to msg.sender, the named receiver gets nothing.
assertEq(santasList.ownerOf(1), carol, "purchased NFT minted to buyer (msg.sender)");
assertEq(santasList.balanceOf(carol), carolNftBefore + 1, "buyer gained the present");
assertEq(santasList.balanceOf(dave), 0, "presentReceiver received nothing");
assertEq(santaToken.balanceOf(dave), 0, "the token was spent anyway");
​
// Recoverable: Carol must spend an extra tx to forward it. No asset is lost.
vm.prank(carol);
santasList.transferFrom(carol, dave, 1);
assertEq(santasList.ownerOf(1), dave, "manual forward needed to complete the gift");
}
​
/// Limb 1 generalised: for any EOA buyer and any distinct receiver able to clear the burn,
/// the minted NFT is owned by the buyer and the receiver's NFT balance stays 0.
function testFuzz_SL07_ReceiverNeverGetsTheNft(address buyer, address receiver) public {
vm.assume(buyer != address(0) && receiver != address(0));
vm.assume(buyer != receiver);
vm.assume(buyer.code.length == 0);
vm.assume(buyer != address(santasList) && receiver != address(santasList));
​
deal(address(santaToken), receiver, 1e18, true);
uint256 receiverNftBefore = santasList.balanceOf(receiver);
​
vm.prank(buyer);
santasList.buyPresent(receiver);
​
assertEq(santasList.ownerOf(0), buyer, "NFT always minted to msg.sender");
assertEq(santasList.balanceOf(receiver), receiverNftBefore, "presentReceiver never receives the NFT");
}
​
/// Limb 2 (belongs to the burn-source bug, L173): a token-less NAUGHTY friend cannot be gifted at all,
/// because the call reverts on the friend's balance underflow before any mint happens.
function test_SL07_TokenlessFriendGiftReverts() public {
_carolCollects();
assertEq(santaToken.balanceOf(dave), 0);
assertEq(santaToken.balanceOf(carol), 1e18, "buyer can afford the present");
​
vm.prank(carol);
vm.expectRevert(stdError.arithmeticError);
santasList.buyPresent(dave);
​
assertEq(santasList.balanceOf(dave), 0, "no present delivered");
}
​
/// Limb 3: a NICE buyer who buys a gift before collecting is locked out of their own present
/// by the balanceOf guard (L151) until they forward the purchased NFT.
function test_SL07_BuyBeforeCollectLocksBuyerOut() public {
_carolCollects();
// Dave holds SANTA so the gift clears the (receiver-sourced) burn.
vm.prank(carol);
santaToken.transfer(dave, 1e18);
​
// Frank (NICE, not yet collected) buys a present for Dave. buyPresent has no time gate.
vm.prank(frank);
santasList.buyPresent(dave);
assertEq(santasList.ownerOf(1), frank, "gift landed on frank");
assertEq(santasList.balanceOf(dave), 0, "dave got nothing");
​
// Frank now tries to collect his own NICE present: blocked.
vm.prank(frank);
vm.expectRevert(SantasList.SantasList__AlreadyCollected.selector);
santasList.collectPresent();
​
// Self-recoverable: forward the gift, then collecting works.
vm.prank(frank);
santasList.transferFrom(frank, dave, 1);
vm.prank(frank);
santasList.collectPresent();
assertEq(santasList.ownerOf(2), frank, "frank collects after forwarding the gift");
assertEq(santasList.ownerOf(1), dave, "dave only gets the gift via the manual forward");
}
}

Run from the repository root:

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

Result:

Ran 4 tests for test/PoC_SL-07.t.sol:PoC_SL07
[PASS] testFuzz_SL07_ReceiverNeverGetsTheNft(address,address) (runs: 256, μ: 362575, ~: 362548)
[PASS] test_SL07_BuyBeforeCollectLocksBuyerOut() (gas: 252781)
[PASS] test_SL07_GiftIsMintedToBuyerNotReceiver() (gas: 206724)
Logs:
owner of purchased token #1: 0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272 (= carol, asserted)
carol NFT balance: 2
dave NFT balance: 0
dave SANTA balance (spent): 0
​
[PASS] test_SL07_TokenlessFriendGiftReverts() (gas: 159067)
Suite result: ok. 4 passed; 0 failed; 0 skipped

Recommended Mitigation

Mint to the named receiver. The burn source is a separate defect with its own fix, which is to burn from msg.sender.

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