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.
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.
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()
# 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; } ```
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.