Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: low
Valid

[H-3] Missing alreadyClaimed Check in claimSnowman

Root + Impact

  • Root: The claimSnowman function lacks an alreadyClaimed verification before external calls;

  • Impact: Attackers can claim the airdrop multiple times, exhausting the supply.

Description

  • The claimSnowman function is designed to allow eligible addresses to claim Snowman NFTs by providing a valid Merkle proof, a signature, and meeting certain balance requirements. However, the current implementation does not enforce a one-claim-per-address policy. The function proceeds with external calls to transfer Snow tokens and mint NFTs before updating the s_hasClaimedSnowman mapping, which serves as the claim status tracker. This ordering creates a vulnerability where an attacker can resubmit the claim transaction multiple times for the same receiver address, as long as the initial conditions (e.g., valid proof and signature) are met. The lack of a pre-check against s_hasClaimedSnowman[receiver] means the function does not revert on subsequent calls, allowing the attacker to accumulate multiple NFTs or drain the contract’s token reserves.

// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • The likelihood of exploitation is high. The vulnerability can be triggered by any user with a valid Merkle proof and signature, which are intended to be widely distributed to eligible participants.

  • An attacker could easily generate multiple transactions using the same receiver address, especially if they control it, making this a feasible attack vector during the airdrop period

Impact:

  • The impact is severe, classified as high. Repeated claims could exhaust the Snowman NFT supply or the Snow token balance required for the airdrop, potentially rendering the contract unusable for legitimate users.

  • This could result in significant economic damage to the project, reputational harm, and loss of trust among the community. The lack of a cap on claims amplifies the potential scale of the issue.

Proof of Concept

  • Demonstration: The vulnerability can be demonstrated by executing the claimSnowman function multiple times with the same receiver address.

  • Setup: Deploy the SnowmanAirdrop contract with a valid ROOT, Snow, and Snowman instance. Fund a test address (e.g., alice) with 1 Snow token and approve the airdrop contract to transfer it. Generate a valid Merkle proof (AL_PROOF) and signature for alice.
    Steps:
    Call claimSnowman(alice, AL_PROOF, v, r, s) as alice, verifying the first claim succeeds and mints 1 NFT.
    Immediately call claimSnowman(alice, AL_PROOF, v, r, s) again without modifying the state.
    Observe that the second call also succeeds, minting an additional NFT to alice, as s_hasClaimedSnowman[alice] is not checked beforehand.
    Expected Outcome: nft.balanceOf(alice) should exceed 1, confirming multiple claims are possible. This behavior validates the vulnerability, as the contract allows unlimited claims per address.

Recommended Mitigation

  • To address this critical vulnerability, the claimSnowman function must be updated to include a pre-check against the s_hasClaimedSnowman mapping and introduce a new SA__AlreadyClaimed error. The state update should also be prioritized before external calls to align with the Checks-Effects-Interactions (CEI) pattern. The revised implementation is as follows:

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver <= address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) <= 0) {
revert SA__ZeroAmount();
}
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }
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();
}
+ s_hasClaimedSnowman[receiver] = true; // Update state first
i_snow.safeTransferFrom(receiver, address(this), amount); // Then interact
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
  • Explanation: The addition of the if (s_hasClaimedSnowman[receiver]) check ensures that only the first claim per address succeeds, reverting with SA__AlreadyClaimed on subsequent attempts.

Updates

Lead Judging Commences

yeahchibyke Lead Judge 21 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of claim check

The claim function of the Snowman Airdrop contract doesn't check that a recipient has already claimed a Snowman. This poses no significant risk as is as farming period must have been long concluded before snapshot, creation of merkle script, and finally claiming.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.