Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: low
Valid

F1 — EIP-712 signature replay،

Root + Impact

Description

The one-time claim invariant in src/SnowmanAirdrop.sol is broken.
claimSnowman (lines 69-99) is supposed to let a whitelisted address
claim once, but the flag it maintains, s_hasClaimedSnowman[receiver],
is only written at line 94 and never read by any code path — there is
no check anywhere that prevents a second claim.

The EIP-712 payload ("SnowmanClaim(address receiver, uint256 amount)")
contains no nonce and no deadline, and getMessageHash (lines 112-122)
rebuilds the digest from the claimant's live SNOW balance at call
time. _isValidSignature (lines 102-109) only verifies that the signer
equals the receiver. The only effective gate is the balance check at
line 76 (SA__ZeroAmount).

As a result, after a successful claim burns the claimant's SNOW
balance, the user can earn 1 free SNOW per week via Snow.earnSnow()
and call claimSnowman again with the byte-identical (v, r, s) — the
digest matches because the balance is back to 1 — minting another NFT
from the same single allocation, repeatable for the whole 12-week
farming window.

The one-time claim invariant in src/SnowmanAirdrop.sol is broken.
claimSnowman (lines 69-99) is supposed to let a whitelisted address
claim once, but the flag it maintains, s_hasClaimedSnowman[receiver],
is only written at line 94 and never read by any code path — there is
no check anywhere that prevents a second claim.
The EIP-712 payload ("SnowmanClaim(address receiver, uint256 amount)")
contains no nonce and no deadline, and getMessageHash (lines 112-122)
rebuilds the digest from the claimant's live SNOW balance at call
time. _isValidSignature (lines 102-109) only verifies that the signer
equals the receiver. The only effective gate is the balance check at
line 76 (SA__ZeroAmount).
As a result, after a successful claim burns the claimant's SNOW
balance, the user can earn 1 free SNOW per week via Snow.earnSnow()
and call claimSnowman again with the byte-identical (v, r, s) — the
digest matches because the balance is back to 1 — minting another NFT
from the same single allocation, repeatable for the whole 12-week
farming window.// Root cause in the codebase with @> marks to highlight the relevant section

Risk

The one-time claim invariant in src/SnowmanAirdrop.sol is broken.
claimSnowman (lines 69-99) is supposed to let a whitelisted address
claim once, but the flag it maintains, s_hasClaimedSnowman[receiver],
is only written at line 94 and never read by any code path — there is
no check anywhere that prevents a second claim.

The EIP-712 payload ("SnowmanClaim(address receiver, uint256 amount)")
contains no nonce and no deadline, and getMessageHash (lines 112-122)
rebuilds the digest from the claimant's live SNOW balance at call
time. _isValidSignature (lines 102-109) only verifies that the signer
equals the receiver. The only effective gate is the balance check at
line 76 (SA__ZeroAmount).

As a result, after a successful claim burns the claimant's SNOW
balance, the user can earn 1 free SNOW per week via Snow.earnSnow()
and call claimSnowman again with the byte-identical (v, r, s) — the
digest matches because the balance is back to 1 — minting another NFT
from the same single allocation, repeatable for the whole 12-week
farming window.


Likelihood:

Likelihood: High

  • The attacker is a legitimately whitelisted user, so the exploitation
    starts from a completely normal, expected state: an allocation of
    1 SNOW and a valid claim signature that the user already holds.

  • No new signature is ever needed after the first one — the same
    byte-identical (v, r, s) works every week, because the digest is
    rebuilt from the live balance and the balance returns to 1 via the
    free weekly earnSnow().

  • Replay cost is one transaction per week. The only "requirement" is
    waiting out the 1-week timer, and that wait happens naturally for
    every legitimate farming user anyway.

  • No countermeasure exists to trip over: the s_hasClaimedSnowman flag
    is written but never checked, so every repeat claim behaves
    on-chain like an ordinary claim and raises no alert.

  • The abuse is economically invisible: the extra SNOW is farmed
    legitimately, so the repeat claims look clean to any off-chain
    monitor that only inspects events.

Overall: any whitelisted user can do this with essentially no extra
effort or cost. Not every user will bother, but the path is fully
open and trivially automatable.

Impact:

  • Unbounded NFT minting per single allocation: one whitelisted entry
    yields an unlimited number of Snowman NFTs over the 12-week farming
    window, simply by re-earning 1 SNOW per week.

  • Supply inflation of the collection and direct dilution of secondary
    market value for every legitimately claimed NFT.

  • Broken airdrop fairness: honest users who claim once end up with
    less of a fixed pool that the abusive user keeps expanding.

  • Combined with F2 (permissionless mintSnowman), the collection
    effectively has no supply cap at all, so scarcity is not
    enforceable even if F2 alone is patched.

  • The claimed-state mapping becomes meaningless as a source of truth,
    which also undermines any future airdrop logic that relies on
    getClaimStatus.

Proof of Concept

Test: test/PoC/PoC_DoubleClaim.t.sol
Reproduction: forge test --match-path "test/PoC/*" -vv

[PASS] test_Baseline_HonestClaim_WorksOnce()
[PASS] test_Exploit_SameSignature_ClaimsThreeTimes()
Logs:
claim #1 OK -> NFT balance: 1
immediate replay reverted via SA__ZeroAmount (balance),
claimed flag never checked: true
claim #2 (same signature) OK -> NFT balance: 2
claim #3 (same signature) OK -> NFT balance: 3
getClaimStatus(alice): true
[PASS] test_NegativeControl_WrongKey_Reverts()

Test: test/PoC/PoC_DoubleClaim.t.sol
Reproduction: forge test --match-path "test/PoC/*" -vv
[PASS] test_Baseline_HonestClaim_WorksOnce()
[PASS] test_Exploit_SameSignature_ClaimsThreeTimes()
Logs:
claim #1 OK -> NFT balance: 1
immediate replay reverted via SA__ZeroAmount (balance),
claimed flag never checked: true
claim #2 (same signature) OK -> NFT balance: 2
claim #3 (same signature) OK -> NFT balance: 3
getClaimStatus(alice): true
[PASS] test_NegativeControl_WrongKey_Reverts()
Steps:
1. alice (whitelisted) holds 1 SNOW and a valid EIP-712 (v, r, s).
2. alice claims once -> NFT balance 1, SNOW balance 0.
3. An immediate replay attempt is rejected only by SA__ZeroAmount
(balance is 0); getClaimStatus(alice) is already true and is
ignored.
4. Time moves forward 1 week; alice calls earnSnow() -> 1 SNOW.
5. alice calls claimSnowman again with the exact same (v, r, s)
-> NFT balance 2.
6. Repeat once more -> NFT balance 3. Three NFTs from one signature.
7. Negative control: a signature from any other key reverts, proving
the replay is specifically allowed by the missing one-time check.

Recommended Mitigation

1. Enforce the one-time invariant first thing in claimSnowman:
if (s_hasClaimedSnowman[receiver]) revert; — the flag already
exists, it just needs to be read.
2. Add a per-address nonce and a claim deadline to the signed struct
(and to the MESSAGE_TYPEHASH) and verify both in claimSnowman,
so a signature can never be reused after its first use or after
its expiry.
3. Never derive the claimed amount from the live balance. Store the
fixed allocation in the merkle leaf (e.g.
leaf = hash(receiver, fixedAmount)) and use that same fixed amount
for both the digest and the minted count. This also eliminates F5,
since a donation can no longer change what a user is entitled to.
4. Once allocation is fixed in the leaf, mint exactly that amount and
require the claimant's balance to be at least the allocation at
claim time.
5. Audit trail: the contract emits SnowmanClaimedSuccessfully per
claim; with the nonce-based fix this event becomes a reliable
one-time record that off-chain monitors can enforce additionally.
Updates

Lead Judging Commences

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

[L-01] Missing Claim Status Check Allows Multiple Claims in SnowmanAirdrop.sol::claimSnowman

# Root + Impact   **Root:** The [`claimSnowman`](https://github.com/CodeHawks-Contests/2025-06-snowman-merkle-airdrop/blob/b63f391444e69240f176a14a577c78cb85e4cf71/src/SnowmanAirdrop.sol#L44) function updates `s_hasClaimedSnowman[receiver] = true` but never checks if the user has already claimed before processing the claim, allowing users to claim multiple times if they acquire more Snow tokens. **Impact:** Users can bypass the intended one-time airdrop limit by claiming, acquiring more Snow tokens, and claiming again, breaking the airdrop distribution model and allowing unlimited NFT minting for eligible users. ## Description * **Normal Behavior:** Airdrop mechanisms should enforce one claim per eligible user to ensure fair distribution and prevent abuse of the reward system. * **Specific Issue:** The function sets the claim status to true after processing but never validates if `s_hasClaimedSnowman[receiver]` is already true at the beginning, allowing users to claim multiple times as long as they have Snow tokens and valid proofs. ## Risk **Likelihood**: Medium * Users need to acquire additional Snow tokens between claims, which requires time and effort * Users must maintain their merkle proof validity across multiple claims * Attack requires understanding of the missing validation check **Impact**: High * **Airdrop Abuse**: Users can claim far more NFTs than intended by the distribution mechanism * **Unfair Distribution**: Some users receive multiple rewards while others may receive none * **Economic Manipulation**: Breaks the intended scarcity and distribution model of the NFT collection ## Proof of Concept Add the following test to TestSnowMan.t.sol  ```Solidity function testMultipleClaimsAllowed() public { // Alice claims her first NFT vm.prank(alice); snow.approve(address(airdrop), 1); bytes32 aliceDigest = airdrop.getMessageHash(alice); (uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, aliceDigest); vm.prank(alice); airdrop.claimSnowman(alice, AL_PROOF, v, r, s); assert(nft.balanceOf(alice) == 1); assert(airdrop.getClaimStatus(alice) == true); // Alice acquires more Snow tokens (wait for timer and earn again) vm.warp(block.timestamp + 1 weeks); vm.prank(alice); snow.earnSnow(); // Alice can claim AGAIN with new Snow tokens! vm.prank(alice); snow.approve(address(airdrop), 1); bytes32 aliceDigest2 = airdrop.getMessageHash(alice); (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, aliceDigest2); vm.prank(alice); airdrop.claimSnowman(alice, AL_PROOF, v2, r2, s2); // Second claim succeeds! assert(nft.balanceOf(alice) == 2); // Alice now has 2 NFTs } ``` ## Recommended Mitigation **Add a claim status check at the beginning of the function** to prevent users from claiming multiple times. ```diff // Add new error + error SA__AlreadyClaimed(); function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant { + if (s_hasClaimedSnowman[receiver]) { + revert SA__AlreadyClaimed(); + } + if (receiver == address(0)) { revert SA__ZeroAddress(); } // Rest of function logic... s_hasClaimedSnowman[receiver] = true; } ```

Support

FAQs

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

Give us feedback!