Santa's List

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

F-6: `collectPresent()` Repeatable via NFT Transfer — Unlimited Minting

Root + Impact

Description

  • The "already collected" guard at line 151 uses balanceOf(msg.sender) > 0,
    which only checks the user's current ERC721 NFT balance — not whether they
    have ever previously claimed a present.

  • After collecting an NFT, a user can transfer it to another address (via the
    inherited ERC721 transferFrom or safeTransferFrom), which resets their
    balanceOf to 0. They can then call collectPresent() again to mint
    another NFT, repeating this process indefinitely.

  • For EXTRA_NICE users, each call to collectPresent() also mints 1e18
    SantaTokens via i_santaToken.mint(msg.sender) at line 162, enabling
    unlimited ERC20 supply inflation.

// SantasList.sol:151-153
if (balanceOf(msg.sender) > 0) {
revert SantasList__AlreadyCollected();
// @> Only checks CURRENT balance — transferring NFT away resets to 0
}

Risk

Likelihood:

  • Any NICE or EXTRA_NICE user can exploit this trivially by transferring
    their NFT to a secondary address after each collection. On Arbitrum,
    gas costs are negligible, making mass exploitation cheap.

  • The exploit requires only a single legitimate NICE/EXTRA_NICE status to
    generate unlimited NFTs and tokens.

Impact:

  • Unlimited NFT supply inflation for any eligible address. A single NICE
    user can mint thousands of NFTs.

  • For EXTRA_NICE users: unlimited SantaToken minting (1e18 per iteration),
    completely destroying the ERC20 token economics and rendering the
    PURCHASED_PRESENT_COST mechanism meaningless.

Proof of Concept

  • A NICE user calls collectPresent() and receives NFT #0. They then
    transfer NFT #0 to a friend, resetting their balanceOf to 0. When they
    call collectPresent() again, the balanceOf > 0 check passes (balance
    is now 0), and a new NFT #1 is minted. This cycle can be repeated
    indefinitely for unlimited NFTs.

function test_F6_repeatCollection() public {
address niceUser = makeAddr("nice");
address dump = makeAddr("dump");
// Setup: niceUser is NICE on both lists, warp to Christmas
vm.warp(1_703_480_381);
// First collection — gets NFT #0
vm.prank(niceUser);
santasList.collectPresent();
assertEq(santasList.balanceOf(niceUser), 1);
// Transfer NFT away — balance resets to 0
vm.prank(niceUser);
santasList.transferFrom(niceUser, dump, 0);
assertEq(santasList.balanceOf(niceUser), 0);
// Collect AGAIN — succeeds because balanceOf is 0!
vm.prank(niceUser);
santasList.collectPresent();
assertEq(santasList.balanceOf(niceUser), 1); // New NFT #1 minted
}

Recommended Mitigation

  • Replace the balanceOf check with a dedicated s_hasCollected mapping
    that permanently tracks whether each address has already claimed. Once set
    to true, it cannot be reset by transferring the NFT, preventing repeated
    collection.

+ 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;
Updates

Lead Judging Commences

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