Snowman Merkle Airdrop

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

SnowmanAirdrop claim tracking can be bypassed allowing repeated NFT claims

Root + Impact

Description

The SnowmanAirdrop contract does not permanently invalidate NFT claim eligibility after a successful claim. Users become eligible for Snowman NFT minting based on their currently staked Snow token balance, however the contract does not appear to store a permanent record showing that a specific staking position, Merkle leaf, or user has already consumed their reward.

Under normal behavior, a user should only be able to receive the intended amount of NFTs corresponding to their eligible stake. After the claim is completed, the eligibility should be permanently consumed to prevent the same balance from being reused again.

The issue occurs because the protocol appears to validate only the current staking balance during the claim process. As a result, an attacker can repeatedly reuse the same Snow tokens by cycling through staking and unstaking operations.

An attacker can perform the following sequence:

  1. Stake Snow tokens

  2. Claim Snowman NFTs

  3. Withdraw or unstake the Snow tokens

  4. Stake the same Snow tokens again

  5. Re-execute the claim function

  6. Receive additional NFTs again

Since the protocol does not permanently mark the claim as consumed, the same Snow balance may be reused indefinitely to mint NFTs far beyond the intended allocation.

This vulnerability breaks the one-time reward assumption of the airdrop mechanism and can lead to unlimited NFT inflation.

// Example vulnerable logic
function claim() external {
if (stakedBalance[msg.sender] > 0) {
_mintSnowman(msg.sender);
}
}

The protocol should instead invalidate claim eligibility permanently after the first successful mint.

// Recommended secure logic
function claim() external {
require(!hasClaimed[msg.sender], "Already claimed");
hasClaimed[msg.sender] = true;
_mintSnowman(msg.sender);
}

Additionally, Merkle proofs and delegated signatures should also be invalidated after use to prevent replay attacks across multiple claim transactions.


Risk

Likelihood:

  • Users can repeatedly interact with staking and unstaking functions using the same Snow tokens

  • Claim eligibility depends on current balances instead of permanently consumed state

  • Merkle proofs and signatures remain reusable unless explicitly invalidated

  • The attack requires no privileged access and can be executed through normal protocol interactions

Impact:

  • Unlimited or excessive Snowman NFT minting becomes possible

  • NFT scarcity and collection integrity are permanently damaged

  • Honest users suffer dilution of rewards and NFT value

  • Protocol tokenomics and distribution assumptions become invalid

  • Attackers can monopolize the NFT supply using a relatively small amount of Snow tokens


Proof of Concept

// Attacker starts with 100 Snow tokens
// Step 1: Stake Snow tokens
stake(100);
// Step 2: Claim NFTs
claim();
// Attacker receives NFTs
// Step 3: Unstake tokens
unstake(100);
// Step 4: Stake same tokens again
stake(100);
// Step 5: Claim again
claim();
// Additional NFTs are minted again
// The attacker can repeat this loop indefinitely

Execution Flow

  1. The attacker acquires or farms Snow tokens

  2. The attacker stakes the tokens into the protocol

  3. The protocol verifies only the active stake balance

  4. NFTs are minted successfully

  5. The attacker withdraws the tokens

  6. The same tokens are restaked

  7. The claim function succeeds again because no permanent claim state exists

  8. The process repeats to mint excessive NFTs


Recommended Mitigation

The protocol should permanently consume claim eligibility after the first successful claim.

Recommended protections include:

  • hasClaimed mappings

  • Merkle leaf consumption tracking

  • Nonce invalidation

  • Signature replay protection

  • Claim bitmaps

- function claim() external {
- if (stakedBalance[msg.sender] > 0) {
- _mintSnowman(msg.sender);
- }
- }
+ mapping(address => bool) public hasClaimed;
+ function claim() external {
+ require(!hasClaimed[msg.sender], "Already claimed");
+
+ hasClaimed[msg.sender] = true;
+
+ _mintSnowman(msg.sender);
+ }

For delegated claims using signatures, the protocol should additionally:

  1. Include nonces in signed payloads

  2. Consume nonces after use

  3. Bind signatures to chain ID and contract address

  4. Prevent replay across multiple transactions

These protections ensure each eligible user or staking position can only mint NFTs once as intended by the protocol design.

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   **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!