Snowman Merkle Airdrop

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

Signature Replay Attack Enables Unlimited NFT Minting Through Reusable EIP-712 Signatures

Author Revealed upon completion

Root + Impact

Description

The team intended to allow only one claim per eligible wallet (or one claim per signature).
But Alice is claiming multiple times by reusing the signature and just re-funding her SNOW.

Even though she's paying ETH for SNOW, she’s bypassing the core rules of the airdrop:

  • A user should not be able to mint multiple NFTs just by refilling balance and reusing a signature.


Normal Behavior: Users should generate unique signatures for each claim transaction, staking their Snow tokens exactly once per signature to receive corresponding Snowman NFTs.

Issue: The contract lacks nonce-based replay protection in its EIP-712 signature scheme, allowing attackers to reuse valid signatures indefinitely. The vulnerability stems from calculating the signed amount at call-time rather than signature-time, combined with missing nonce validation, enabling unlimited NFT minting with a single valid signature.

// Root cause in the codebase with @> marks to highlight the relevant section
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external nonReentrant {
// @> No nonce validation - signatures can be replayed infinitely
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
// @> Amount calculated at call-time, not signature-time
uint256 amount = i_snow.balanceOf(receiver);
// @> s_hasClaimedSnowman set but never checked for prevention
s_hasClaimedSnowman[receiver] = true;
}
function getMessageHash(address receiver) public view returns (bytes32) {
// @> Amount recalculated each call - enables replay when balance matches
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}
// @> Missing nonce and deadline fields for replay protection
bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");

Risk

Likelihood:

  • Snow tokens can be easily reacquired through weekly farming or direct purchase, recreating identical balance conditions for signature reuse

  • Attack requires only transaction replay with the same parameters - no advanced cryptographic knowledge needed

  • Single valid signature can be exploited indefinitely across multiple claim cycles

Impact:

  • Infinite NFT minting using a single signature, completely bypassing the intended 1:1 token-to-NFT staking ratio

  • Severe economic damage to protocol as NFTs are minted without corresponding permanent token burns

  • Unfair advantage over legitimate users who generate new signatures for each claim






Proof of Concept

// Step-by-step replay attack:
// 1. Alice owns 100 Snow tokens, generates signature S1 for SnowmanClaim(Alice, 100)
// 2. Alice calls claimSnowman() with S1 → Stakes 100 tokens, receives 1 NFT
// 3. Alice farms/buys 100 more Snow tokens (balance = 100 again)
// 4. Alice reuses same signature S1 → getMessageHash() recalculates amount=100
// 5. Signature validates successfully, Alice gets another NFT for same signature
// 6. Repeat steps 3-5 unlimited times with zero additional signatures required
// Critical flaw: amount = i_snow.balanceOf(receiver) makes signatures reusable
// whenever balance matches the original signed amount

Recommended Mitigation

```diff
+ mapping(address => uint256) private s_nonces;
+ error SA__ExpiredSignature();
+ error SA__InvalidNonce();
+ error SA__InsufficientBalance();
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 amount,uint256 nonce,uint256 deadline)");
- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 amount, uint256 nonce, uint256 deadline, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external nonReentrant {
if (receiver == address(0)) revert SA__ZeroAddress();
+ if (block.timestamp > deadline) revert SA__ExpiredSignature();
+ if (nonce != s_nonces[receiver]) revert SA__InvalidNonce();
+ if (i_snow.balanceOf(receiver) < amount) revert SA__InsufficientBalance();
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ bytes32 digest = getMessageHash(receiver, amount, nonce, deadline);
+ if (!_isValidSignature(receiver, digest, v, r, s)) {
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver);
+ s_nonces[receiver]++; // Increment before state changes to prevent replay
// ... rest of function remains the same
}
- function getMessageHash(address receiver) public view returns (bytes32) {
- uint256 amount = i_snow.balanceOf(receiver);
- return _hashTypedDataV4(
- keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
- );
- }
+ function getMessageHash(address receiver, uint256 amount, uint256 nonce, uint256 deadline) public view returns (bytes32) {
+ return _hashTypedDataV4(
+ keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, amount, nonce, deadline))
+ );
+ }
```
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 15 hours 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.