Severity: High · Likelihood: High
Scope:src/SnowmanAirdrop.sol
Contest: CodeHawks First Flight — Snowman Merkle AirdropSubmitted as:
s_hasClaimedSnowmanis written but never read, letting a whitelisted address replay the identical proof and signature for unlimited NFTs
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.
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.