Santa's List

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

collectPresent() uses balanceOf as the already-collected guard, allowing any whitelisted address to collect unlimited presents by transferring their NFT away between calls

Root + Impact

Any address on the nice list can collect an unlimited number of NFTs by repeatedly calling collectPresent() and transferring the minted NFT away before each subsequent call. The already-collected protection is entirely bypassable by any whitelisted user — no exploit or attacker contract required.

The attack requires only a legitimate NICE or EXTRA_NICE status and the ability to transfer an ERC721 token — standard wallet functionality. It is available to every whitelisted user and requires no special knowledge or capital beyond gas.

Description

  • collectPresent() uses balanceOf(msg.sender) > 0 to determine whether a user has already collected their present:

function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
// @> mutable balance check used as already-collected guard
// @> transfers the NFT away and this check passes again
if (balanceOf(msg.sender) > 0) {
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 is a mutable value that the user fully controls via ERC721 transfers. Once the minted NFT is transferred to another wallet, balanceOf(msg.sender) drops back to 0, and the same address can call collectPresent() again. The check provides no persistent protection.

  • The contract never writes a permanent "collected" flag to storage before or after minting. The status mappings s_theListCheckedOnce and s_theListCheckedTwice are never cleared after collection, so the eligibility checks continue to pass on every subsequent call. There is no state in the contract that permanently records that an address has already collected.

Risk

Likelihood:

  • Available to every legitimately whitelisted address — no exploit contract required

  • Requires only a standard NFT transfer between calls, executable from any wallet

  • Repeatable indefinitely as long as the caller transfers the NFT away before each call

Impact:

  • Every whitelisted address can mint an unlimited number of NFTs

  • NFT scarcity is completely destroyed — the collection loses all value

  • EXTRA_NICE addresses also receive unlimited SantaToken mints per collect cycle, draining the token supply

Proof of Concept

The following test demonstrates a whitelisted address collecting 5 NFTs by transferring each one away before the next call:

function test_F3_CanCollectArbitraryNumberOfTimes() public {
// Santa legitimately marks attacker as NICE
vm.startPrank(santa);
santasList.checkList(attacker, SantasList.Status.NICE);
santasList.checkTwice(attacker, SantasList.Status.NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
uint256 rounds = 5;
for (uint256 i = 0; i < rounds; i++) {
// Collect present
vm.prank(attacker);
santasList.collectPresent();
// Transfer NFT away — balanceOf drops back to 0
vm.prank(attacker);
santasList.transferFrom(attacker, dump, i);
}
// 5 NFTs minted for a single whitelisted address
assertEq(santasList.balanceOf(dump), 5);
assertEq(santasList.balanceOf(attacker), 0);
}

Recommended Mitigation

Replace the mutable balanceOf check with a dedicated storage mapping that is permanently set to true before any external call is made. This follows the Checks-Effects-Interactions pattern and cannot be reset by transferring tokens.

+ mapping(address => bool) private s_alreadyCollected;
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
- if (balanceOf(msg.sender) > 0) {
- revert SantasList__AlreadyCollected();
- }
+ if (s_alreadyCollected[msg.sender]) {
+ revert SantasList__AlreadyCollected();
+ }
+ s_alreadyCollected[msg.sender] = true;
if (s_theListCheckedOnce[msg.sender] == Status.NICE ...
Updates

Lead Judging Commences

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