The SnowmanAirdrop::claimSnowman() function includes a mapping s_hasClaimedSnowman that is intended to track whether an address has already claimed their Snowman NFTs. On line 94, the function sets s_hasClaimedSnowman[receiver] = true after a successful claim. However, the function never checks this mapping before processing the claim.
The expected behavior is that the function should reject claims from addresses that have already claimed, preventing double-claiming. The standard pattern for this is to add a check at the beginning of the function:
Instead, the current code only sets the flag without ever reading it:
While the current implementation has an indirect protection (after claiming, the user's Snow balance becomes 0, causing the balanceOf check on line 76 to revert on subsequent calls), this is an accidental safeguard rather than an intentional design pattern. The s_hasClaimedSnowman mapping and getClaimStatus() getter function (line 137) clearly indicate that double-claim prevention was intended but incompletely implemented.
This becomes exploitable if combined with other bugs or if the protocol is modified. For example, if a user receives additional Snow tokens after claiming (through earnSnow() or buySnow()), they could potentially claim again since only the balance check would gate access, and the balance would no longer be zero.
Likelihood: Medium
The indirect balance-based protection currently prevents straightforward double-claims
However, users can acquire new Snow tokens after claiming, which would bypass the balance check
The Merkle proof verification adds another layer of protection, but if the user's new balance happens to match a Merkle leaf, the claim would succeed again
Impact: Medium
A user who acquires new Snow tokens after claiming could potentially claim additional Snowman NFTs
The getClaimStatus() function returns stale/incorrect data since s_hasClaimedSnowman is set but never enforced, which could mislead front-end applications and integrators relying on this flag
The protocol's intended security invariant (each address claims exactly once) is not properly enforced
The core issue is defense-in-depth: the contract stores claim status data but fails to use it, creating a false sense of security. External contracts or front-ends calling getClaimStatus() would incorrectly assume that the claim status is being enforced on-chain.
Add a check for s_hasClaimedSnowman at the beginning of the claimSnowman() function:
Also add the corresponding error:
# 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.