SnowmanAirdrop::claimSnowman fails to check whether s_hasClaimedSnowman[receiver] is already true, allowing eligible users with valid proofs to execute multiple claims and drain the NFT supply.In a standard Merkle airdrop architecture, the contract must enforce a single claim per eligible address. Once an eligible user claims their allocation, any subsequent claim attempts by that same address must immediately revert.
In src/SnowmanAirdrop.sol, the contract maintains a state mapping mapping(address => bool) private s_hasClaimedSnowman to track claim statuses and updates s_hasClaimedSnowman[receiver] = true at the very end of the claimSnowman function. However, the function never validates at the start whether s_hasClaimedSnowman[receiver] is already true.
Because there is no check verifying if s_hasClaimedSnowman[receiver] is true, an eligible recipient can acquire additional Snow tokens and invoke claimSnowman again using the exact same Merkle proof and signature. The transaction will execute successfully multiple times, allowing the user to mint NFTs well beyond their authorized allocation.
Likelihood: High
Reason 1: Any eligible address that has already completed a claim can call claimSnowman again as long as they hold enough Snow tokens to satisfy the transferFrom call.
Reason 2: Merkle proofs and ECDSA signatures are static and remain valid indefinitely because the contract does not invalidate them or check the claim state mapping prior to execution.
Impact: High
Impact 1: Destruction of the airdrop's fairness and accounting, allowing malicious or wealthy eligible users to drain the Snowman NFT supply.
Impact 2: Total violation of the core protocol invariant that restricts each eligible participant to a single airdrop claim.
PoC Explanation: The following Proof of Concept written in Foundry proves that an eligible user can execute duplicate claims using the same Merkle proof and signature:
Setup: The test deploys Snow, Snowman, and SnowmanAirdrop. A Merkle root is set up for user with an allocation. user obtains Snow tokens and grants approval to SnowmanAirdrop.
First Claim: user signs the EIP-712 message digest and invokes claimSnowman. The initial claim succeeds, minting 10 Snowman NFTs and setting s_hasClaimedSnowman[user] = true.
Replay Execution: user acquires 10 more Snow tokens and invokes claimSnowman a second time using the same proof and signature.
Assertion: The second claim completes without reverting, increasing user's balance to 20 Snowman NFTs and proving the absence of claim status enforcement.
Mitigation Explanation: To fix this vulnerability, SnowmanAirdrop::claimSnowman must enforce the Checks-Effects-Interactions (CEI) pattern. Check if s_hasClaimedSnowman[receiver] is true at the very beginning of the function and revert with a custom SnowmanAirdrop__AlreadyClaimed error. Furthermore, update s_hasClaimedSnowman[receiver] = true; before performing external calls to safeTransferFrom or mintSnowman.
# 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.