Snowman Merkle Airdrop

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

SnowmanAirdrop::claimSnowman never checks s_hasClaimedSnowman allowing repeated airdrop claims

SnowmanAirdrop::claimSnowman never checks s_hasClaimedSnowman allowing repeated airdrop claims

Description

  • Each eligible address is meant to claim the airdrop once. The contract declares s_hasClaimedSnowman and sets it to true on every claim, so the intended design is to block any address that has already claimed. claimSnowman never reads s_hasClaimedSnowman before minting. The only thing stopping a repeat claim is the recipient's Snow balance dropping to zero (staked into the contract at line 92), but that state is reversible: the recipient re-acquires Snow via buySnow/earnSnow and claims again, minting more Snowman NFTs each time.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
@> // missing: if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
if (receiver == address(0)) revert SA__ZeroAddress();
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
...
@> s_hasClaimedSnowman[receiver] = true; // written, but never checked
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood:

  • Every time a recipient re-acquires the committed Snow amount (buySnow and earnSnow are permissionless) and calls claimSnowman again, the claim succeeds, because the already-claimed flag is never enforced. The recipient signs with their own key, so the signature check and Merkle proof pass on every repeat.

Impact:

  • A single eligible recipient mints unbounded Snowman NFTs by looping buy-and-claim, far beyond their one-time entitlement. The airdrop distribution is broken and the NFT supply is inflated arbitrarily.

Proof of Concept

The PoC deploys the three contracts with a single-leaf Merkle tree, so the root equals the leaf for (receiver, AMOUNT) and the proof is an empty array. It then has the same recipient claim the airdrop twice. The deal cheatcode stands in for the recipient re-acquiring Snow through the permissionless buySnow/earnSnow functions between claims. If the already-claimed flag were enforced, the second claim would revert; instead it succeeds and mints a second batch of NFTs.

function test_receiverCanClaimAirdropTwice() public {
bytes32[] memory emptyProof = new bytes32[](0); // single-leaf tree
deal(address(snow), receiver, AMOUNT);
vm.prank(receiver);
snow.approve(address(airdrop), type(uint256).max);
bytes32 digest = airdrop.getMessageHash(receiver);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(receiverPk, digest);
airdrop.claimSnowman(receiver, emptyProof, v, r, s);
assertEq(snowman.balanceOf(receiver), AMOUNT); // 4 NFTs
deal(address(snow), receiver, AMOUNT); // re-acquire Snow (== buySnow)
airdrop.claimSnowman(receiver, emptyProof, v, r, s); // second claim succeeds
assertEq(snowman.balanceOf(receiver), 2 * AMOUNT); // 8 NFTs: claimed twice
}

Result: the recipient's Snowman balance goes from 4 after the first claim to 8 after the second (Apos 1a reclamacao, NFTs: 4 / Apos 2a reclamacao, NFTs: 8). Because deal only restores the token balance that buySnow would restore in production, this is a fully realistic sequence: nothing here is exclusive to a test environment.

Recommended Mitigation

The root cause is that s_hasClaimedSnowman is written but never read as a guard, so enforcement accidentally falls to the recipient's Snow balance reaching zero, which the recipient can reverse. The fix is to enforce the flag at the very top of claimSnowman, before any state changes: an address that has already claimed reverts immediately, regardless of its current balance. Add a dedicated error and the guard:

+ error SA__AlreadyClaimed();
error SA__InvalidProof();
error SA__InvalidSignature();
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
+ // enforce the one-claim-per-address invariant that the mapping was meant to guarantee
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
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!