Normal behavior: Each whitelisted address is supposed to claim exactly once — claimSnowman sets s_hasClaimedSnowman[receiver] = true after a successful claim, and getClaimStatus() exposes this flag.
Issue: The flag is written but never read. claimSnowman contains no if (s_hasClaimedSnowman[receiver]) revert check, and the EIP-712 signature has no nonce or deadline. A recipient who re-accrues the tree amount (weekly earnSnow or a purchase) can claim again with the exact same signature and Merkle proof, minting NFTs repeatedly. Worse: the signature is public on-chain after first use, so once a victim has a standing approval, any third party can replay it to force-stake the victim's re-earned Snow.
Grep confirms: line 94 is the only use in claimSnowman — the mapping is never checked before a claim proceeds.
Likelihood:
Every eligible recipient hits this naturally: the weekly earnSnow faucet gives them the exact tree amount again, and their old signed message from the first claim is permanently valid (no nonce).
After one approve(airdrop, type(uint256).max), the standing allowance lets anyone replay the victim's public signature in the same way — no victim action required.
Impact:
The one-claim-per-address invariant (proven intended by the mapping + getter) is broken: eligible users mint NFTs without limit, inflating supply beyond the airdrop design.
Third parties can force-stake a victim's newly acquired Snow without their consent, consuming their allowance.
Result: PASS on commit 6efcb0f — second claim succeeds while getClaimStatus is already true. Repeatable every time the tree amount is re-accrued.
Additionally, add a deadline or nonce to the signed message (e.g., include block.number or a per-receiver nonce in the EIP-712 struct) so a used signature can never be replayed even with the flag check in place.
# 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.