claimSnowman sets a claimed flag but never reads it, so claims repeat and a used signature stays valid, breaking the allocation cap and locking Snow without consentThe airdrop keeps a per-receiver claimed flag, commented as a "mapping to verify if an address has claimed Snowman" (src/SnowmanAirdrop.sol:47). claimSnowman sets it after a successful claim (:94), and getClaimStatus exposes it (:137-139). But claimSnowman never reads it, so a receiver who has already claimed can claim again.
Nothing else makes a claim single-use. The signed message holds only the receiver and their current Snow balance, with no nonce or deadline, and the Merkle leaf is (receiver, current balance) (:84-90, :112-122). So the same (v, r, s) verifies again whenever the receiver's balance is back at the signed amount. claimSnowman has no caller check, so anyone can submit it, and the signature is public in the calldata of the first claim.
This gives two effects on the code as written:
Repeat claims past the allocation. A listed account with a leaf of 1 earns a free unit each week and claims it every week with the same signature. It ends with 12 NFTs against an allocation of 1, paying nothing.
Replay of a used signature by a third party. After a holder claims, any later Snow they hold that brings their balance back to the signed amount can be pulled by anyone resubmitting the old signature, with no fresh consent. The lock has no exit. This needs the holder's allowance to the airdrop to still cover the amount, which a max approval leaves (OpenZeppelin's ERC20 doesn't reduce a max allowance; checked at the repo's pinned commit 239795b), and so does approving "1 S" in a wallet that reads Snow's 18 decimals, since that approves 1e18 base units. If the holder's balance is below the signed amount, the third party can send the difference.
On intent: the README never says a receiver claims only once. But the contract declares the flag's purpose in its own comment, records it, and exposes getClaimStatus, so a claimed receiver being able to claim again is the guard missing, not a design choice. The third-party replay doesn't depend on that reading. CodeHawks' validity guide lists "design opinions with no clear loss of funds indication" among findings that may be invalid; the replay isn't one, because it takes a holder's Snow without consent.
Likelihood: Medium. Repeat claims need only a listed account that wants more NFTs, and Snow can be earned free. The replay needs a standing allowance from the holder, and a holder who wants to keep later Snow. An exact approval stops it (test_F003_control_exactApprovalStopsReplay below).
Impact: Medium. Funds are indirectly at risk: a holder's Snow is locked without consent, and the allowlist's allocations stop capping anything.
Repeat claims: in the PoC, one signature over a leaf of 1 claims 12 times across the farming window, for 12 NFTs and no ETH or WETH paid (test_F003_oracle_repeatClaimBeyondLeafAmount).
Replay: after Alice claims her leaf of 2, she buys one more unit to keep. A third party sends her one unit and resubmits her used signature. Her Snow goes from 1 to 0, the airdrop's locked Snow from 2 to 4, and her NFTs from 2 to 4, although getClaimStatus(alice) was already true. The third party ends with nothing (test_F003_oracle_thirdPartyReplaysUsedSignature).
Bounds: the receiver still gets NFTs at the protocol's 1:1 rate, and the replayer gains nothing, so this is griefing, not theft. It hurts a holder who wanted to keep or resell Snow.
Save the file below as test/F003RepeatClaims.t.sol in the contest repository and run, from the repository root (no fork needed):
It imports only the repository's own src/ files, forge-std and OpenZeppelin from lib/. Its setUp deploys the real Snow, Snowman and SnowmanAirdrop the way the repo's deploy scripts do: MockWETH, a fee of 5, and the committed Merkle root. The committed allowlist's names and proofs are copied into the file from script/flakes/output.json. Nothing is mocked or stubbed. Actors:
Attacker: mallory, a third party who replays Alice's used signature. In the repeat-claim test, the listed account itself is the one claiming past its allocation.
Victim: alice, the committed list's first account, whose later Snow gets locked.
Protocol: SnowmanAirdrop, with Snow and Snowman.
The replay test uses a one-leaf tree with a leaf of 2 (root = leaf, empty proof), built with the scripts' leaf encoding, because every leaf on the committed list is 1 and the replay needs a balance above the leaf to set up. The repeat-claim test uses the committed list as it is. Signatures are over the contract's own getMessageHash output, the form this contract accepts.
Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:
The replay test logs Alice's Snow, the locked Snow and her NFTs as 1 / 2 / 2 before the replay and 0 / 4 / 4 after it. The repeat-claim test logs 12 claims with one signature, 12 NFTs, and a leaf amount of 1. The control shows that with an exact approval the replay reverts with ERC20InsufficientAllowance: the allowance stops it, not any claimed check.
Check the flag the contract already keeps, at the top of claimSnowman:
Notes:
Tested: this check was tested on a patched copy of SnowmanAirdrop. The replay reverts with SA__AlreadyClaimed, and the holder keeps their Snow. The weekly repeat claim reverts, and NFTs stay at the leaf amount. Normal claims still work, self-submitted or relayed.
Signatures that were never used are a separate gap: the signed message has no deadline or nonce, so a signature handed out and never submitted stays valid indefinitely, which this check doesn't cover. A per-receiver nonce consumed on claim would also stop reuse of a used signature, but not repeat claims signed fresh each time. The claimed flag is the contract's own guard for both.
Taking amount as a parameter (verifying the leaf and signature for a fixed amount, instead of the live balance) should ship together with this check. Without it, removing the exact-balance requirement would let a used signature replay whenever the receiver holds at least the signed amount, with no top-up needed.
Redeployment: nothing is upgradeable, so this needs a new airdrop. Its root should leave out anyone who already claimed, since a new contract starts with every flag cleared.
# 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.