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 20 days 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!