The intended behavior is that each eligible user should be able to claim their Snowman NFTs only once through the SnowmanAirdrop contract. The contract even stores each receiver’s claim status in s_hasClaimedSnowman, which shows that one-time claiming is intended.
The issue is that claimSnowman() sets s_hasClaimedSnowman[receiver] = true after a successful claim, but never checks whether receiver has already claimed before processing a new claim. As a result, a whitelisted user can claim once, acquire the same Snow balance again, and reuse the same Merkle proof/signature to claim additional NFTs.
This shows the user is marked as claimed after a successful claim.
Also highlight the start of claimSnowman() to show the missing check:
There should be a check inside this function like:
but no such check exists.
Likelihood:
High. Any eligible receiver can repeat the claim flow as long as they regain the required Snow balance and approve the airdrop contract again.
Impact:
High. A user can mint more Snowman NFTs than allocated, inflating the NFT supply and bypassing the intended one-claim-per-user airdrop rule.
The test proves that s_hasClaimedSnowman is only updated, but never enforced.
First, Alice performs a normal valid claim using her Snow balance, Merkle proof, and signature. The claim succeeds, Alice receives one Snowman NFT, and getClaimStatus(alice) returns true.
Then Alice waits one week and earns one Snow token again. She approves the airdrop contract again and reuses the exact same Merkle proof and signature from the first claim.
The second claim also succeeds, and Alice receives another Snowman NFT even though her claim status was already true.
This demonstrates that the airdrop does not enforce one claim per user. The claimed mapping exists, but because claimSnowman() never checks it, eligible users can repeatedly claim as long as they regain the required Snow balance.
Add an explicit check at the start of claimSnowman() to prevent users who have already claimed from claiming again.
The claimed status should also be updated before external calls:
The root issue is that the contract records a successful claim but never uses that record to block future claims.
By checking s_hasClaimedSnowman[receiver] before processing the claim, the contract guarantees that each eligible receiver can only claim once. Once a user has successfully claimed, any later attempt by the same receiver will revert immediately.
Setting s_hasClaimedSnowman[receiver] = true before token transfers and NFT minting also follows the checks-effects-interactions pattern. This reduces risk from external calls and ensures the claim state is updated before interacting with other contracts.
# 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.