SnowmanAirdrop is never checked, so claimSnowman can be replayed indefinitely with the byte-identical Merkle proof and signatureEach address in the Merkle tree is entitled to claim exactly once. SnowmanAirdrop maintains s_hasClaimedSnowman for precisely this purpose and even exposes it through the getClaimStatus getter, so a second claim by the same address is meant to revert.
The mapping is written at the end of claimSnowman but is never read anywhere in the claim path. Nothing enforces the one-claim-per-address rule, so the same address can claim as many times as it can restore its Snow balance to the amount the Merkle tree committed to — resubmitting the same proof and the same signature unchanged.
The only thing that accidentally blocks an immediate second claim is that safeTransferFrom drained the balance, so the SA__ZeroAmount check trips. That is not a guard — Snow is a plain ERC20 with no transfer restrictions, so anyone can push the balance back to the committed amount and the claim becomes valid again.
The replay works with the original signature because the signed struct carries no nonce and no deadline, and the digest is derived from the same live balance:
Likelihood:
Every whitelisted address can do this, using only the proof and signature it already needs for its legitimate first claim. No special tooling, no race, no privileged position.
Restoring the balance is free and requires no cooperation from anyone: a throwaway address farms 1 wei through Snow::earnSnow and transfers it over. Snow has no transfer restrictions, so any holder can also simply gift the wei.
The replay uses the exact same (merkleProof, v, r, s) bytes each round, so the attacker performs no cryptographic work after the first claim.
Impact:
The airdrop's allocation invariant is broken: a whitelist entry granting one NFT grants as many as the holder cares to farm for, and a single address can absorb the entire distribution.
A signature the user believes is spent is never invalidated. Because claimSnowman is permissionless and users must grant the airdrop an ERC20 allowance, a third party can replay an old signature to pull the victim's re-acquired Snow into the airdrop without fresh consent. This is bounded — the victim does receive NFTs in exchange, so it is a forced-but-compensated stake rather than theft — but the victim's only defence is manually revoking an allowance the protocol never tells them to revoke.
Honest bound on absolute numbers, stated so the severity is not overread: free Snow issuance is throttled to 1 wei per week protocol-wide (s_earnTimer in Snow.sol:30 is a single global slot) and the paid path costs 5 ETH per wei, so circulating Snow is tiny and the raw NFT count an attacker accumulates is small. The defect is the broken invariant, not a headline count.
Alice is whitelisted for one NFT and ends up with six, at zero cost, using byte-identical proof and signature every round. Save as test/PoCH02.t.sol and run forge test --match-test test_H02 -vv.
Result:
Read the flag before doing any work, and make the signature single-use by adding a nonce and a deadline to the signed struct.
Snowman::mintSnowman is itself unguarded (reported separately), so an attacker who only wants NFTs does not need this bug. The two are independent root causes in different contracts with different fixes: gating the mint would leave the airdrop's own one-claim-per-address invariant broken, and fixing this one would leave the mint open.
# 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.