SnowmanAirdrop::claimSnowman() is intended to allow each whitelisted address to claim Snowman NFTs exactly once. The mapping s_hasClaimedSnowman exists for this purpose and is correctly set to true after a successful claim.
However, the mapping is never read at the beginning of the function. The only guard against repeat claims is the balanceOf(receiver) == 0 check, which can be bypassed: after a first claim transfers the receiver's Snow balance to the contract, the receiver can re-acquire Snow tokens equal to their original Merkle-recorded amount and call claimSnowman() again with the identical proof.
Likelihood:
After a first claim, a receiver only needs to re-acquire Snow tokens in the exact amount recorded in the Merkle tree (e.g., via buySnow() or earnSnow()) to repeat the claim
The Merkle proof is fixed and reusable; no new proof is required for the second claim
Impact:
A whitelisted user can cycle through claim → re-acquire → claim indefinitely, minting Snowman NFTs far beyond their intended allocation
Each iteration costs only the price of re-acquiring the Snow tokens; the receiver profits in NFTs on every cycle
The following Foundry test demonstrates the double-claim attack. After a first
successful claim, the receiver purchases Snow tokens equal to their original Merkle-
recorded balance and calls claimSnowman() again with the identical proof. Because
s_hasClaimedSnowman is never read, the second claim succeeds and the receiver
receives an additional batch of Snowman NFTs beyond their intended allocation.
The fix adds an early revert at the top of claimSnowman() that reads the already-
existing s_hasClaimedSnowman mapping — which is correctly written on first claim but
currently never read. This single guard closes the double-claim vector without any
architectural change, since the tracking data structure is already 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.