Snowman Merkle Airdrop

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

Replayable Signatures Allow Unlimited NFT Claims Leading to Protocol Collapse

Author Revealed upon completion

Description:

The SnowmanAirdrop::claimSnowman function lacks replay protection for ECDSA signatures and fails to verify claim status before processing. This allows attackers to:

  • Reuse valid signatures indefinitely

  • Claim NFTs multiple times per address

  • Mint unlimited NFTs without token ownership
    Combined with dynamic balance checks, this enables infinite NFT inflation attacks.

Impact:

  • Infinite NFT Supply: Attackers can mint arbitrary Snowman NFTs

  • Economic Collapse: NFT value drops to zero due to hyperinflation

  • Token Theft: Users' SNOW tokens drained via repeated claims

  • Protocol Failure: Core airdrop mechanism becomes unusable

Risk

Likelihood:

• Exploit requires basic Ethereum knowledge
• No special privileges needed
• Attack cost: <0.05 ETH

Impact:

• Permanent destruction of NFT value
• Protocol tokenomics rendered useless
• Loss of user funds

Proof of Concept

// Attack Contract
contract AirdropExploit {
function drain(
SnowmanAirdrop airdrop,
address victim,
bytes32[] calldata proof,
uint8 v, bytes32 r, bytes32 s
) external {
// Get initial victim balance
uint256 balance = airdrop.getSnow().balanceOf(victim);
// First valid claim
airdrop.claimSnowman(victim, proof, v, r, s);
// Transfer tokens back to victim
airdrop.getSnow().transfer(victim, balance);
// Replay same signature
airdrop.claimSnowman(victim, proof, v, r, s);
// Repeat infinitely
}
}

Attack Flow:

  • Victim has 100 SNOW tokens and valid Merkle proof

  • Attacker calls drain() with victim's signature

Attack loop:

Claim → Mint NFTs → Return tokens → Reclaim

Result: Unlimited NFTs minted per signature

Recommended Mitigation

// Add to contract
mapping(bytes32 => bool) private usedSignatures;
function claimSnowman(
address receiver,
uint256 fixedAmount, // Snapshot-based amount
bytes32[] calldata merkleProof,
uint256 nonce, // Unique claim identifier
uint8 v, bytes32 r, bytes32 s
) external nonReentrant {
// 1. Verify first-time claim
require(!s_hasClaimedSnowman[receiver], "Already claimed");
// 2. Use fixed amount for Merkle verification
bytes32 leaf = keccak256(abi.encode(receiver, fixedAmount));
require(MerkleProof.verify(merkleProof, i_merkleRoot, leaf), "Invalid proof");
// 3. Create nonced digest
bytes32 digest = keccak256(abi.encodePacked(
receiver,
fixedAmount,
nonce,
block.chainid
));
// 4. Prevent signature reuse
require(!usedSignatures[digest], "Signature reused");
require(_isValidSignature(receiver, digest, v, r, s), "Invalid signature");
usedSignatures[digest] = true;
// 5. Verify token balance
require(i_snow.balanceOf(receiver) >= fixedAmount, "Insufficient balance");
// ... rest of logic ...
}
  • Signature Tracking:

mapping(bytes32 => bool) private usedSignatures;
  • Nonced Messages:
    Include nonce and block.chainid in signed digest

  • Snapshot Amounts:
    Replace dynamic balances with Merkle-fixed amounts

Claim Status Verification:

require(!s_hasClaimedSnowman[receiver], "Already claimed");
Updates

Lead Judging Commences

yeahchibyke Lead Judge 1 day 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.