- The specification states *"an address is only allowed to collect 1 NFT per address, there is a
check in the codebase to prevent someone from minting duplicate NFTs."*
- That check reads `balanceOf(msg.sender)` — OpenZeppelin v5.0.0's live `_balances[owner]`, rewritten
by `_update` on every transfer. `SantasList` inherits the full unmodified ERC-721 transfer surface,
so the gate asks *do you hold a present now*, never *did you ever collect*. Moving the token to a
second address resets it.
```solidity
// src/SantasList.sol:151-153
@> if (balanceOf(msg.sender) > 0) { // live balance, not a record
revert SantasList__AlreadyCollected();
}
// src/SantasList.sol:161-162 (EXTRA_NICE branch — mints currency on every pass)
_mintAndIncrement();
@> i_santaToken.mint(msg.sender); // 1e18 SANTA on every collection
// src/SantasList.sol:180-182
function _mintAndIncrement() private {
@> _safeMint(msg.sender, s_tokenCounter++); // OZ commits the mint, THEN calls the receiver hook
}
```
### Risk
**Likelihood:**
- Any user Santa grades holds the only precondition. Re-collection needs two ordinary transactions,
no attacker contract and no capital.
- `_safeMint` runs `_mint` before `_checkOnERC721Received` (`ERC721.sol:312-314`), so the mint is
committed when the hook fires, and there is no reentrancy guard anywhere in `src/` — a contract
recipient re-enters `collectPresent` from inside the callback after moving the token out.
**Impact:**
**(a) Repeat collection.** One `EXTRA_NICE` grading yields 5 presents and 5e18 SANTA in the PoC,
and 50 presents with 50e18 SANTA in a single block. The protocol believes it issued `1e18`.
**(b) Atomic inflation.** 8 presents and 8e18 SANTA from one grading in **one transaction**; 50e18
at depth 50. The depth limit is the attacker's own counter.
**(c) Denial.** Because a third party can raise a balance, an attacker pushes an unsolicited
present onto a graded user and their `collectPresent` reverts `SantasList__AlreadyCollected`; the
`1e18` they were owed is never minted. Recoverable for an EOA — asserted — and terminal only for a
recipient that cannot initiate an ERC-721 transfer.
Impact (b) requires Santa to have graded a contract address; nothing in `checkList`/`checkTwice`
reads `code.length`, so the protocol cannot distinguish one. Impacts (a) and (c) carry no such
assumption.
### Proof of Concept
```solidity
function test_F02_collectTwiceFromOneGrading() public {
_grade(alice, SantasList.Status.EXTRA_NICE); // Santa grades her ONCE
vm.startPrank(alice);
list.collectPresent();
list.transferFrom(alice, address(0xDEAD), 0); *// balanceOf(alice) -> 0*
list.collectPresent(); *// the gate passes again*
vm.stopPrank();
assertEq(token.balanceOf(alice), 2e18); // 2e18 SANTA from a 1e18 entitlement
}
```
### Recommended Mitigation
```diff
+ mapping(address => bool) private s_collected;
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) revert SantasList__NotChristmasYet();
- if (balanceOf(msg.sender) > 0) revert SantasList__AlreadyCollected();
+ if (s_collected[msg.sender]) revert SantasList__AlreadyCollected();
if (s_theListCheckedOnce[msg.sender] == Status.NICE && s_theListCheckedTwice[msg.sender] == Status.NICE) {
+ s_collected[msg.sender] = true;
_mintAndIncrement();
return;
} else if (...) {
+ s_collected[msg.sender] = true;
_mintAndIncrement();
i_santaToken.mint(msg.sender);
```
Writing the flag **before** `_mintAndIncrement()` is load-bearing: it is what closes impact (b), by
rejecting the re-entrant call at the flag rather than at a balance the attacker controls.
## 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(); } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.