Santa's List

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

`collectPresent` can be replayed after transferring the NFT away

[H-01] collectPresent can be replayed after transferring the NFT away

Severity

High

Description

SantasList.collectPresent() is intended to let each eligible address collect only one present. The function enforces that rule by checking the caller's current ERC721 balance:

if (balanceOf(msg.sender) > 0) {
revert SantasList__AlreadyCollected();
}

This is not a durable claim record. An eligible user can collect once, transfer the NFT to another address, and then call collectPresent() again because their current balance is back to zero.

For EXTRA_NICE users, each replay also mints another SantaToken:

_mintAndIncrement();
i_santaToken.mint(msg.sender);

Affected code:

  • src/SantasList.sol:147-165

  • src/SantasList.sol:180-182

Risk

Any NICE user can mint more than one Christmas NFT by transferring each claimed NFT away before claiming again. Any EXTRA_NICE user can repeat the same flow to mint both unlimited NFTs and unlimited SantaTokens.

This breaks the explicit one-present-per-address invariant, inflates the NFT supply, and inflates the token that is used to buy additional presents.

Impact

High.

Eligible users can mint more NFTs than intended. EXTRA_NICE users can also mint additional SantaTokens on every replay, which expands the token supply and gives them more ability to buy further presents.

The protocol loses the core guarantee that each address can collect only once.

Likelihood

High.

The attack requires only normal protocol actions: collect, transfer the NFT away, and call collectPresent() again. No special permissions, timing edge case, or external dependency is required after the user has been marked NICE or EXTRA_NICE.

Proof of Concept

The exploit is covered by test_ExtraNiceUserCanCollectAgainAfterTransferringNftAway in test/unit/SantasListTest.t.sol.

function test_ExtraNiceUserCanCollectAgainAfterTransferringNftAway() public {
address receiver = makeAddr("receiver");
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);
vm.startPrank(user);
santasList.collectPresent();
santasList.transferFrom(user, receiver, 0);
santasList.collectPresent();
vm.stopPrank();
assertEq(santasList.balanceOf(user), 1);
assertEq(santasList.balanceOf(receiver), 1);
assertEq(santaToken.balanceOf(user), 2e18);
}

Run:

forge test --match-test test_ExtraNiceUserCanCollectAgainAfterTransferringNftAway -vvv

Result:

[PASS] test_ExtraNiceUserCanCollectAgainAfterTransferringNftAway()

The same EXTRA_NICE address successfully collects twice. The final assertions show two NFTs exist from one eligible address and the user has 2e18 SantaToken, even though the protocol promises one collection per address.

Mitigation

Track whether an address has collected independently from its current NFT balance:

mapping(address => bool) private s_hasCollected;
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
if (s_hasCollected[msg.sender]) {
revert SantasList__AlreadyCollected();
}
s_hasCollected[msg.sender] = true;
...
}

Add a regression test that transfers the NFT away and verifies the second collectPresent() call still reverts.

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!