The claimSnowman function is intended to allow each eligible address to claim once. The contract maintains s_hasClaimedSnowman to track claimed addresses, but this mapping is only written to—it is never read during the claim flow. As a result, there is no check that prevents an address from claiming again after it has already claimed.
After a successful claim, the user's Snow balance becomes zero because all tokens are transferred to the contract. The only protection against a second claim is the balance check at line 91. If the user later re-acquires the exact amount of Snow from the Merkle snapshot (via earnSnow, purchase, or transfer), they can pass all checks and claim again: balance > 0, valid signature for (receiver, amount), and valid Merkle proof for the same leaf.
The mapping exists and is exposed via getClaimStatus, but claimSnowman never reverts when s_hasClaimedSnowman[receiver] is already true.
Likelihood (medium):
Snow can be re-acquired through earnSnow() (1 Snow per week per user), transfers, or other sources.
A user who claimed once can obtain the same amount again and reuse the same Merkle proof with a new signature.
No special privileges or complex setup are required; the attacker is the legitimate claimant.
Impact (medium):
User receives more Snowman NFTs than intended (double or multiple claims).
Unbounded minting of Snowman NFTs beyond the airdrop allocation.
Dilution of NFT value and unfair distribution relative to other claimants.
Severity (medium):
Alice has 100 Snow and is in the Merkle tree with leaf (alice, 100).
Alice claims successfully: transfers 100 Snow to the contract, receives 100 Snowman NFTs. s_hasClaimedSnowman[alice] = true is set.
Alice later earns or receives 100 Snow again (e.g. via earnSnow, OTC, or DEX).
Alice signs a new message SnowmanClaim(alice, 100) and calls claimSnowman again with the same Merkle proof.
All checks pass: balance > 0, valid signature, valid Merkle proof. There is no require(!s_hasClaimedSnowman[receiver]).
Alice receives another 100 Snowman NFTs and transfers another 100 Snow to the contract.
This can repeat each time Alice re-acquires 100 Snow.
Add a check at the start of claimSnowman to revert if the receiver has already claimed.
Add the new 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.