s_hasClaimedSnowman is written but never read, so one eligible address can claim its allocation an unlimited number of timesA Merkle airdrop distributes a fixed allocation once per listed address. SnowmanAirdrop is built to enforce that: it declares mapping(address => bool) private s_hasClaimedSnowman, describes it in a comment as the mapping "to verify if an address has claimed Snowman", sets it during claimSnowman, and exposes it through the public getter getClaimStatus.
The flag is never read anywhere in the claim path. claimSnowman contains no check against s_hasClaimedSnowman before minting, so the write at the end of the function records history that nothing consults. The only other reference in the contract is the external getter. The signature is also replayable: the digest covers only (receiver, amount) with no nonce and no deadline, so a single signature authorises every future claim at that amount.
Grepping the contract for the mapping returns exactly three occurrences: the declaration, this write, and the getter. There is no fourth.
The reason this is not self-limiting is that Snow can be reacquired for free. claimSnowman transfers the claimant's balance into the airdrop contract, which drops the balance to zero and would otherwise stop a second claim at the SA__ZeroAmount check. But Snow::earnSnow mints one base unit to any caller for free, and every recipient in the project's own script/flakes/input.json is listed with an amount of exactly 1. So a claimant returns to precisely the balance their leaf commits to, and the same proof and the same signature verify again.
Likelihood:
The claimant needs no attacker, no special privilege, and no cooperation from anyone. They reuse the signature and proof they already produced for their legitimate first claim.
earnSnow restores the required balance for free, and the allocation amounts in the project's own input file are exactly 1, the value earnSnow mints. The reacquisition step therefore lands on the required balance exactly, with no calculation and no cost beyond gas.
Every listed recipient can do this, and the profit is strictly positive, so a rational recipient does it.
Impact:
The airdrop mints unbounded NFTs against a fixed allocation. The invariant that a listed address receives its committed amount and no more does not hold.
The Snowman supply is inflated without limit over time, diluting every honestly claimed NFT.
getClaimStatus reports true for an address that is still claiming, so any off-chain accounting, dashboard or downstream integration that trusts this getter is reporting a state the contract does not enforce.
Deployment and Merkle proofs are the project's own, taken from script/Helper.s.sol and test/TestSnowmanAirdrop.t.sol. Alice signs once. The signature and proof are captured before the first claim and reused unmodified for all four subsequent claims.
Result:
Five NFTs against a one-NFT allocation, with getClaimStatus returning true throughout. The loop count of four is arbitrary; the bound is how many times the claimant is willing to reacquire Snow, and earnSnow is free.
Scope note. The reacquisition is rate limited in practice, because Snow::earnSnow is gated on a timer. That limits the rate of the extra mints, not the total: the loop has no terminating condition and the allocation is exceeded permanently after the second claim. The finding is the missing guard, not the throughput.
Read the flag that the contract already maintains, before doing any work. A dedicated error makes the failure legible.
and set the flag before the external calls rather than between them, so the guard holds under the checks-effects-interactions ordering rather than relying on the nonReentrant modifier alone:
Separately, the signature should carry a per-receiver nonce so that one authorisation cannot be replayed even if the claim guard is later relaxed or the contract is reused for a second airdrop round.
This is a separate defect from the unrestricted Snowman::mintSnowman mint and from the leaf being rebuilt from the live balance, and each has an independent fix:
The unrestricted mint lives in Snowman.sol and is fixed by adding access control there. Fixing it does not add a claim guard to SnowmanAirdrop, so repeat claiming through the legitimate path would still work.
The live-balance leaf causes claims to fail; this causes claims to succeed too often. They are opposite failures of the same function and are fixed by different changes: one by taking the amount as a parameter, this one by reading the flag.
# 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.