Each eligible address should be able to claim its Snowman allocation exactly once; the mapping s_hasClaimedSnowman and its getter exist for that purpose.
claimSnowman writes s_hasClaimedSnowman[receiver] = true but never reads it. The only barrier against a second claim is the zero-balance check, and the balance is topped up freely via earnSnow(). Because the leaf and the signed digest are both derived from the live balanceOf, restoring the original amount makes the old signature and Merkle proof valid again.
Likelihood:
Any eligible claimant tops their SNOW back up with earnSnow() (free, once a week) or buySnow() and calls claimSnowman again with the same signature and proof.
The signature carries no nonce or deadline, so it is reusable indefinitely.
Impact:
Unlimited minting of Snowman NFTs by any eligible address, at zero cost via earnSnow.
The airdrop's one-allocation-per-user guarantee is broken and all honest participants are diluted.
The test claims once (1 NFT), then tops the SNOW balance back to the original amount and calls claimSnowman again reusing the SAME signature and Merkle proof, asserting the NFT balance keeps growing. Both variants pass: 3 NFTs paying via buySnow, and 4 NFTs for free via earnSnow.
Verified with Foundry (test_elMismoUsuarioReclamaElAirdropTresVeces, test_yEncimaSaleGratisConEarnSnow), forge test passing:
Check the s_hasClaimedSnowman flag that already exists, at the start of claimSnowman, so a second claim reverts:
Additionally, fix the allocation in the Merkle leaf and pass amount as a parameter instead of reading balanceOf, and add a nonce/deadline to the signed message.
# 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.