Snowman Merkle Airdrop

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

[H-02] SnowmanAirdrop::claimSnowman does not check s\_hasClaimedSnowman status, enabling duplicate claims

Root + Impact: SnowmanAirdrop::claimSnowman fails to check whether s_hasClaimedSnowman[receiver] is already true, allowing eligible users with valid proofs to execute multiple claims and drain the NFT supply.

Description

  • In a standard Merkle airdrop architecture, the contract must enforce a single claim per eligible address. Once an eligible user claims their allocation, any subsequent claim attempts by that same address must immediately revert.

    In src/SnowmanAirdrop.sol, the contract maintains a state mapping mapping(address => bool) private s_hasClaimedSnowman to track claim statuses and updates s_hasClaimedSnowman[receiver] = true at the very end of the claimSnowman function. However, the function never validates at the start whether s_hasClaimedSnowman[receiver] is already true.

// src/SnowmanAirdrop.sol
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
@> // Missing validation check: if (s_hasClaimedSnowman[receiver]) revert SnowmanAirdrop__AlreadyClaimed();
if (!MerkleProof.verify(merkleProof, i_merkleRoot, keccak256(abi.encodePacked(receiver)))) {
revert SnowmanAirdrop__InvalidProof();
}
...
i_snow.safeTransferFrom(receiver, address(this), amount);
i_snowman.mintSnowman(receiver, amount);
s_hasClaimedSnowman[receiver] = true;
}

Because there is no check verifying if s_hasClaimedSnowman[receiver] is true, an eligible recipient can acquire additional Snow tokens and invoke claimSnowman again using the exact same Merkle proof and signature. The transaction will execute successfully multiple times, allowing the user to mint NFTs well beyond their authorized allocation.

Risk

Likelihood: High

  • Reason 1: Any eligible address that has already completed a claim can call claimSnowman again as long as they hold enough Snow tokens to satisfy the transferFrom call.

  • Reason 2: Merkle proofs and ECDSA signatures are static and remain valid indefinitely because the contract does not invalidate them or check the claim state mapping prior to execution.

Impact: High

  • Impact 1: Destruction of the airdrop's fairness and accounting, allowing malicious or wealthy eligible users to drain the Snowman NFT supply.

  • Impact 2: Total violation of the core protocol invariant that restricts each eligible participant to a single airdrop claim.

Proof of Concept

PoC Explanation: The following Proof of Concept written in Foundry proves that an eligible user can execute duplicate claims using the same Merkle proof and signature:

  1. Setup: The test deploys Snow, Snowman, and SnowmanAirdrop. A Merkle root is set up for user with an allocation. user obtains Snow tokens and grants approval to SnowmanAirdrop.

  2. First Claim: user signs the EIP-712 message digest and invokes claimSnowman. The initial claim succeeds, minting 10 Snowman NFTs and setting s_hasClaimedSnowman[user] = true.

  3. Replay Execution: user acquires 10 more Snow tokens and invokes claimSnowman a second time using the same proof and signature.

  4. Assertion: The second claim completes without reverting, increasing user's balance to 20 Snowman NFTs and proving the absence of claim status enforcement.

function test_PoC_NoHasClaimedCheck_ReplayClaim() public {
// 1. Initial funding and approval
vm.deal(owner, 100 ether);
vm.prank(owner);
snow.buySnow{value: 10 ether}(10);
vm.prank(owner);
snow.transfer(user, 10);
vm.prank(user);
snow.approve(address(airdrop), type(uint256).max);
​
// 2. First claim execution
bytes32 digest = airdrop.getMessageHash(user);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, digest);
​
vm.prank(user);
airdrop.claimSnowman(user, proof, v, r, s);
​
assertEq(snowman.balanceOf(user), 10);
assertTrue(airdrop.getClaimStatus(user));
​
// 3. User acquires more Snow tokens and executes duplicate claim
vm.prank(owner);
snow.buySnow{value: 10 ether}(10);
vm.prank(owner);
snow.transfer(user, 10);
​
bytes32 digest2 = airdrop.getMessageHash(user);
(uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(userPrivateKey, digest2);
​
vm.prank(user);
airdrop.claimSnowman(user, proof, v2, r2, s2);
​
// 4. Verify user was able to claim a second time (20 NFTs total)
assertEq(snowman.balanceOf(user), 20);
}

Recommended Mitigation

Mitigation Explanation: To fix this vulnerability, SnowmanAirdrop::claimSnowman must enforce the Checks-Effects-Interactions (CEI) pattern. Check if s_hasClaimedSnowman[receiver] is true at the very beginning of the function and revert with a custom SnowmanAirdrop__AlreadyClaimed error. Furthermore, update s_hasClaimedSnowman[receiver] = true; before performing external calls to safeTransferFrom or mintSnowman.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SnowmanAirdrop__AlreadyClaimed();
+ }
+ s_hasClaimedSnowman[receiver] = true;
​
if (!MerkleProof.verify(merkleProof, i_merkleRoot, keccak256(abi.encodePacked(receiver)))) {
revert SnowmanAirdrop__InvalidProof();
}
​
...
i_snow.safeTransferFrom(receiver, address(this), amount);
i_snowman.mintSnowman(receiver, amount);
- s_hasClaimedSnowman[receiver] = true;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 20 days 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!