Snowman Merkle Airdrop

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

claimSnowman never checks s_hasClaimedSnowman: the same signature and Merkle proof can be replayed to mint unlimited Snowman NFTs

Description

SnowmanAirdrop.claimSnowman() (src/SnowmanAirdrop.sol L69–L99) never reads s_hasClaimedSnowman, so the "one claim per address" invariant that this variable's name advertises is not enforced anywhere in the contract.

The state variable exists and is maintained:
// src/SnowmanAirdrop.sol L47 mapping(address => bool) private s_hasClaimedSnowman; // mapping to verify if an address has claimed Snowman

// L94, inside claimSnowman s_hasClaimedSnowman[receiver] = true;

// L137-L139 function getClaimStatus(address claimant) public view returns (bool) { return s_hasClaimedSnowman[claimant]; }

But the only three places where it appears are its declaration, its write and its getter. Searching s_hasClaimedSnowman in the source returns exactly those three hits — there is no require(!s_hasClaimedSnowman[receiver]), no custom error guarded by it, and no early return before the mint at L98. The claim path therefore accepts the same redemption any number of times.

Two further details turn this from a bookkeeping gap into an immediately exploitable bug:

  1. The signed message does not contain a fixed entitlement. getMessageHash(receiver) (L112–L122) computes the amount from the live token balance at signing time:
    uint256 amount = i_snow.balanceOf(receiver); // L117 return _hashTypedDataV4(keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount}))));

    claimSnowman then rebuilds the leaf with the same live read:
    uint256 amount = i_snow.balanceOf(receiver); // L84 bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); // L86 if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof(); // L87

    So a signature is only as durable as the balance that produced it. The moment the receiver's Snow balance returns to the amount committed in its own leaf, the byte-identical (v, r, s) and the unchanged merkleProof verify again — the digest is reproducible and the leaf hash is reproducible. There is no nonce, no deadline, no claimed-amount accounting and no read of s_hasClaimedSnowman to stop a second use.

  2. Anyone can trigger it. claimSnowman does not require msg.sender == receiver; _isValidSignature (L102–L109) only checks that the recovered signer equals receiver. In my tests every replay is submitted by satoshi, a third party who is not the beneficiary and who simply pays the gas. Exploiting this finding does not require the victim to cooperate.

In this deployment the tree assigns exactly 1 per whitelisted address (script/flakes/input.json, root 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a), and Snow.earnSnow() mints exactly 1 free Snow (src/Snow.sol L96), so restoring the matching balance is trivial and repeatable — the loop can be run indefinitely.

Risk

The Merkle root encodes a fixed allocation per address, yet the number of Snowman NFTs minted through the claim path has no upper bound:

  • Airdrop supply is uncapped. Each replay mints one more NFT (i_snowman.mintSnowman(receiver, amount), L98) and pulls one more Snow into the contract (L92). getTokenCounter() no longer corresponds to the distribution table the sponsor committed, so total issuance becomes whatever a claimer's balance happened to be times how often it was replayed.

  • Individual allocations are exceeded. A user registered for 1 receives 4, 5, or any number they can fund — direct dilution of every other holder's share and of the collection's scarcity.

  • On-chain state lies to integrations. getClaimStatus() returns true while nothing blocks another claim, so a front-end, an index or a downstream contract that gates on it will report "already claimed" for an address that can still claim again.

  • No cooperation from the victim is needed. Because msg.sender may differ from receiver, any third party can pay gas and re-trigger a beneficiary's mint; the attack is available to whoever watches the mempool, not only to beneficiaries.

The affected value is the airdrop allocation itself (Snow + Snowman NFTs), and the trigger is a single repeated external call with unchanged calldata — high impact, high likelihood.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 32 minutes ago
Submission Judgement Published
Validated
Assigned finding tags:

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

# 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; } ```

Support

FAQs

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

Give us feedback!