Snowman Merkle Airdrop

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

SnowmanAirdrop::claimSnowman never reads s_hasClaimedSnowman, so an eligible address can claim its allocation an unlimited number of times

The claim flag s_hasClaimedSnowman is written but never read, so one eligible address can claim its allocation an unlimited number of times

Description

  • A Merkle airdrop distributes a fixed allocation once per listed address. SnowmanAirdrop is built to enforce that: it declares mapping(address => bool) private s_hasClaimedSnowman, describes it in a comment as the mapping "to verify if an address has claimed Snowman", sets it during claimSnowman, and exposes it through the public getter getClaimStatus.

  • The flag is never read anywhere in the claim path. claimSnowman contains no check against s_hasClaimedSnowman before minting, so the write at the end of the function records history that nothing consults. The only other reference in the contract is the external getter. The signature is also replayable: the digest covers only (receiver, amount) with no nonce and no deadline, so a single signature authorises every future claim at that amount.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
// @> no check of s_hasClaimedSnowman anywhere in this function
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver);
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 by nothing
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Grepping the contract for the mapping returns exactly three occurrences: the declaration, this write, and the getter. There is no fourth.

// SnowmanAirdrop.sol
47: mapping(address => bool) private s_hasClaimedSnowman; // declaration
94: s_hasClaimedSnowman[receiver] = true; // the only write
138: return s_hasClaimedSnowman[claimant]; // the only read, in a getter

The reason this is not self-limiting is that Snow can be reacquired for free. claimSnowman transfers the claimant's balance into the airdrop contract, which drops the balance to zero and would otherwise stop a second claim at the SA__ZeroAmount check. But Snow::earnSnow mints one base unit to any caller for free, and every recipient in the project's own script/flakes/input.json is listed with an amount of exactly 1. So a claimant returns to precisely the balance their leaf commits to, and the same proof and the same signature verify again.

Risk

Likelihood:

  • The claimant needs no attacker, no special privilege, and no cooperation from anyone. They reuse the signature and proof they already produced for their legitimate first claim.

  • earnSnow restores the required balance for free, and the allocation amounts in the project's own input file are exactly 1, the value earnSnow mints. The reacquisition step therefore lands on the required balance exactly, with no calculation and no cost beyond gas.

  • Every listed recipient can do this, and the profit is strictly positive, so a rational recipient does it.

Impact:

  • The airdrop mints unbounded NFTs against a fixed allocation. The invariant that a listed address receives its committed amount and no more does not hold.

  • The Snowman supply is inflated without limit over time, diluting every honestly claimed NFT.

  • getClaimStatus reports true for an address that is still claiming, so any off-chain accounting, dashboard or downstream integration that trusts this getter is reporting a state the contract does not enforce.

Proof of Concept

Deployment and Merkle proofs are the project's own, taken from script/Helper.s.sol and test/TestSnowmanAirdrop.t.sol. Alice signs once. The signature and proof are captured before the first claim and reused unmodified for all four subsequent claims.

function test_C3_same_signature_and_proof_claim_repeatedly() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(alice);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1, "first claim");
assertTrue(airdrop.getClaimStatus(alice), "contract records her as having claimed");
// Re-acquire exactly one wei of Snow. earnSnow is free and repeatable.
for (uint256 i = 0; i < 4; i++) {
vm.warp(block.timestamp + 1 weeks);
vm.prank(alice);
snow.earnSnow();
// Identical signature, identical proof, already flagged as claimed.
vm.prank(alice);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}
assertEq(nft.balanceOf(alice), 5, "five NFTs from a one-NFT allocation");
console2.log("NFTs minted to a single eligible address:", nft.balanceOf(alice));
console2.log("claim status flag was set on claim 1 :", airdrop.getClaimStatus(alice));
}

Result:

[PASS] test_C3_same_signature_and_proof_claim_repeatedly()
NFTs minted to a single eligible address: 5
claim status flag was set on claim 1 : true

Five NFTs against a one-NFT allocation, with getClaimStatus returning true throughout. The loop count of four is arbitrary; the bound is how many times the claimant is willing to reacquire Snow, and earnSnow is free.

Scope note. The reacquisition is rate limited in practice, because Snow::earnSnow is gated on a timer. That limits the rate of the extra mints, not the total: the loop has no terminating condition and the allocation is exceeded permanently after the second claim. The finding is the missing guard, not the throughput.

Recommended Mitigation

Read the flag that the contract already maintains, before doing any work. A dedicated error makes the failure legible.

+ 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();
+ }
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}

and set the flag before the external calls rather than between them, so the guard holds under the checks-effects-interactions ordering rather than relying on the nonReentrant modifier alone:

+ s_hasClaimedSnowman[receiver] = true;
+
i_snow.safeTransferFrom(receiver, address(this), amount);
- s_hasClaimedSnowman[receiver] = true;
-
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);

Separately, the signature should carry a per-receiver nonce so that one authorisation cannot be replayed even if the claim guard is later relaxed or the contract is reused for a second airdrop round.

Distinctness

This is a separate defect from the unrestricted Snowman::mintSnowman mint and from the leaf being rebuilt from the live balance, and each has an independent fix:

  • The unrestricted mint lives in Snowman.sol and is fixed by adding access control there. Fixing it does not add a claim guard to SnowmanAirdrop, so repeat claiming through the legitimate path would still work.

  • The live-balance leaf causes claims to fail; this causes claims to succeed too often. They are opposite failures of the same function and are fixed by different changes: one by taking the amount as a parameter, this one by reading the flag.

Updates

Lead Judging Commences

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