Snowman Merkle Airdrop

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

Missing already-claimed check allows repeated Snowman claims

Root + Impact

Description

  • SnowmanAirdrop stores whether an address has claimed in s_hasClaimedSnowman, but claimSnowman() never reads this mapping. After a successful claim, the receiver’s Snow balance is transferred to the airdrop contract, so their balance becomes 0. However, if the receiver later obtains the same amount of Snow again, they can reuse the same Merkle proof and the same ECDSA signature to claim another Snowman NFT.

  • This allows repeated claims and unlimited NFT minting.

  • The mapping is written but never checked:

i_snow.safeTransferFrom(receiver, address(this), amount);
@> s_hasClaimedSnowman[receiver] = true; // written, but never read
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);

Risk

Likelihood:

  • After the first claim, the receiver’s balance is 0, so an immediate second claim reverts. But this is not a real claimed-check. If the receiver receives Snow again, the balance is non-zero and the claim path can be entered again.

    Because getMessageHash() and the Merkle leaf both use the receiver’s current Snow balance, obtaining the exact same amount again reproduces the exact same digest and leaf. The old signature and Merkle proof remain valid.

Impact:

  • A claimer can mint multiple Snowman NFTs using the same proof and signature.

  • The NFT supply is no longer limited by the Merkle allocation.

  • If Snow can be re-obtained cheaply, e.g. via earnSnow() or buySnow(), the attacker can repeat the claim many times.

  • The protocol’s airdrop distribution is completely broken.

Proof of Concept

Assume Alice is in the Merkle tree with allocation 1 and has already claimed once.

  1. Alice claims once. Her Snow balance becomes 0. She owns 1 Snowman.

  2. Alice obtains 1 Snow again, e.g. by buying exactly 1 Snow or receiving 1 Snow from another account.

  3. Alice reuses the same AL_PROOF, alV, alR, alS.

  4. claimSnowman() succeeds again because:

    • i_snow.balanceOf(alice) == 1

    • getMessageHash(alice) returns the same digest as before

    • the Merkle leaf is keccak256(abi.encode(alice, 1)), matching the original proof

    • s_hasClaimedSnowman[alice] is never checked

  5. Alice receives a second Snowman NFT.

function testRepeatClaim() public {
// First claim
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assertEq(nft.balanceOf(alice), 1);
// Replenish Alice's Snow balance to the original amount
deal(address(snow), alice, 1);
// Reuse the exact same proof and signature
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// Second NFT minted
assertEq(nft.balanceOf(alice), 2);
}

Recommended Mitigation

Add a claimed check at the start of claimSnowman():

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 (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
// ...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!