Santa's List

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

NFT reuse via transfer allows repeated minting of presents (bypass of one-claim restriction)


Description

The collectPresent() function incorrectly enforces eligibility by checking whether an address currently holds the NFT, rather than whether it has already claimed it at least once.

As a result, a user classified as Nice or EXTRA_NICE can repeatedly bypass the intended “one-time claim” restriction by transferring the NFT to another address they control and calling collectPresent() again to mint additional NFTs.


Vulnerability Details

Affected code

https://github.com/CodeHawks-Contests/ai-santas-list/blob/main/src/SantasList.sol#L151

The function relies on a check similar to:

  • “does the address currently hold the NFT?”

instead of:

  • “has this address ever claimed the NFT?”


Issue Breakdown

The system assumes NFT ownership is a reliable indicator of claim status. This assumption is incorrect because:

  1. A user can mint an NFT as a Nice or EXTRA_NICE address.

  2. They can then transfer the NFT to another address they control.

  3. The original address becomes eligible again because it no longer holds the NFT.

  4. The user calls collectPresent() again from the original address.

  5. A new NFT is minted.

This process can be repeated indefinitely across multiple owned addresses.


Impact

An attacker can:

  • Mint unlimited NFTs for free (for eligible tiers)

  • Circumvent intended one-claim-per-user logic

  • Drain protocol NFT supply mechanics (if supply-limited or reward-based)

  • Break fairness assumptions of the “Nice / EXTRA_NICE reward system”

Result:

➡️ Unlimited repeated NFT minting by a single user using multiple controlled addresses


Proof of Concept

  1. User A is marked as EXTRA_NICE

  2. User A calls collectPresent() → receives NFT

  3. User A transfers NFT to User B (controlled by same person)

  4. User A no longer holds NFT → passes eligibility check again

  5. User A calls collectPresent() again → mints another NFT

  6. Repeat indefinitely with additional wallets


Root Cause

  • Incorrect invariant: ownership == claim status

  • Missing persistent storage tracking claimed addresses

  • Lack of a boolean mapping such as hasClaimed[address]


Recommended Fix

Introduce a persistent tracking mechanism:

mapping(address => bool) public hasClaimed;

Update collectPresent():

function collectPresent() external {
require(!hasClaimed[msg.sender], "Already claimed");
hasClaimed[msg.sender] = true;
_mintPresent();
}

Alternatively, if the system is NFT-bound, enforce non-transferable claim status or tie eligibility to off-chain identity instead of token ownership.


Conclusion

The current implementation incorrectly uses NFT ownership as a proxy for claim eligibility. This allows users to reset their eligibility by transferring NFTs between their own wallets, enabling infinite minting of rewards. This breaks the core economic and fairness assumptions of the protocol.

Updates

Lead Judging Commences

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