Each eligible address is meant to claim the airdrop once. The contract declares s_hasClaimedSnowman and sets it to true on every claim, so the intended design is to block any address that has already claimed. claimSnowman never reads s_hasClaimedSnowman before minting. The only thing stopping a repeat claim is the recipient's Snow balance dropping to zero (staked into the contract at line 92), but that state is reversible: the recipient re-acquires Snow via buySnow/earnSnow and claims again, minting more Snowman NFTs each time.
Likelihood:
Every time a recipient re-acquires the committed Snow amount (buySnow and earnSnow are permissionless) and calls claimSnowman again, the claim succeeds, because the already-claimed flag is never enforced. The recipient signs with their own key, so the signature check and Merkle proof pass on every repeat.
Impact:
A single eligible recipient mints unbounded Snowman NFTs by looping buy-and-claim, far beyond their one-time entitlement. The airdrop distribution is broken and the NFT supply is inflated arbitrarily.
The PoC deploys the three contracts with a single-leaf Merkle tree, so the root equals the leaf for (receiver, AMOUNT) and the proof is an empty array. It then has the same recipient claim the airdrop twice. The deal cheatcode stands in for the recipient re-acquiring Snow through the permissionless buySnow/earnSnow functions between claims. If the already-claimed flag were enforced, the second claim would revert; instead it succeeds and mints a second batch of NFTs.
Result: the recipient's Snowman balance goes from 4 after the first claim to 8 after the second (Apos 1a reclamacao, NFTs: 4 / Apos 2a reclamacao, NFTs: 8). Because deal only restores the token balance that buySnow would restore in production, this is a fully realistic sequence: nothing here is exclusive to a test environment.
The root cause is that s_hasClaimedSnowman is written but never read as a guard, so enforcement accidentally falls to the recipient's Snow balance reaching zero, which the recipient can reverse. The fix is to enforce the flag at the very top of claimSnowman, before any state changes: an address that has already claimed reverts immediately, regardless of its current balance. Add a dedicated error and the guard:
# 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.