Snowman Merkle Airdrop

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

Critical Vulnerabilities in claimSnowman(): Signature Replay, Balance Manipulation

Root + Impact
Signature Replay

  • Root: Missing nonce/chainID in signed message

  • Impact: Attacker reuse signatures to drain funds

Balance Manipulation

  • Root: Merkle leaf uses live balance vs snapshot

  • Impact: Users invalidate proofs after generation

Double-Claim

  • Root: s_hasClaimedSnowman set after transfers

  • Impact: Reentrancy allows multiple NFT mints

Description

  • The function combines multiple insecure patterns:

    1. Signatures can be replayed across chains as they lack nonce/chainID.

    2. Merkle proofs become invalidatable as they depend on real-time balances.

    3. State updates happen after external calls (violating CEI), enabling reentrancy.

    4. No decimal scaling mints NFTs proportional to raw token units (e.g., 1e18 NFTs per 1 token).

// Root cause in the codebase with @> marks to highlight the relevant section
function claimSnowman(
address receiver,
uint256 predeterminedAmount, // Use snapshot amount
bytes32[] calldata merkleProof,
uint256 nonce, // Anti-replay
uint8 v, bytes32 r, bytes32 s
) external nonReentrant {
// 1. CEI Pattern - State updates first
require(!s_hasClaimedSnowman[receiver], "Already claimed");
s_hasClaimedSnowman[receiver] = true;
// 2. Secure Signature
bytes32 message = keccak256(abi.encode(
receiver,
predeterminedAmount,
nonce,
block.chainid
));
require(_isValidSignature(message, v, r, s), "Invalid sig");
// 3. Fixed Merkle Proof
bytes32 leaf = keccak256(abi.encode(receiver, predeterminedAmount));
require(MerkleProof.verify(merkleProof, i_merkleRoot, leaf), "Invalid proof");
// 4. Safe transfers (caller pays gas)
i_snow.safeTransferFrom(msg.sender, address(this), predeterminedAmount);
// 5. Scaled minting
uint256 nftAmount = predeterminedAmount / SCALING_FACTOR;
i_snowman.mintSnowman(receiver, nftAmount);
}

Risk

Likelihood:

  • High (Signature reuse is trivial)

  • Medium (Requires block timing)

Impact:

  • : Critical (Full contract drain)

  • High (Unauthorized claims)

Proof of Concept

// Signature Replay Attack
function testSignatureReplay() public {
(uint8 v, bytes32 r, bytes32 s) = getValidSignature();
// Reuse same sig on different chain
vm.chainId(123);
claimSnowman(alice, proof, v, r, s); // Successfully replays
}
// Balance Manipulation Attack
function testBalanceManipulation() public {
// 1. Generate proof with balance=100
uint256 balance = 100;
bytes32[] memory proof = getProof(alice, balance);
// 2. Transfer 99 tokens out before claiming
snow.transfer(bob, 99);
// 3. Still claims with original proof
claimSnowman(alice, proof, v, r, s); // Exploits live balance check
}

Recommended Mitigation

- remove this code
+ add this code
bytes32 message = keccak256(abi.encode(
receiver,
amount,
nonce,
block.chainid
));
// Use predetermined amounts in tree
bytes32 leaf = keccak256(abi.encode(receiver, fixedAmount));
function claimSnowman(...) {
s_hasClaimed[receiver] = true; // State first
_safeTransferFrom(...); // Interactions last
}
uint256 nftAmount = amount / 1e18; // Scale to NFT units
Updates

Lead Judging Commences

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

Invalid merkle-proof as a result of snow balance change before claim action

Claims use snow balance of receiver to compute the merkle leaf, making proofs invalid if the user’s balance changes (e.g., via transfers). Attackers can manipulate balances or frontrun claims to match eligible amounts, disrupting the airdrop.

Support

FAQs

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