Snowman Merkle Airdrop

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

Add nonce or claim ID in ECDSA hash to prevent replay attacks in claimSnowman()

Author Revealed upon completion

Root + Impact- The ECDSA signature used for claimSnowman() is static — it only includes the user address and amount. This allows re-use of a previously signed message, leading to replay attacks if other checks (like nonce or timestamp) aren't added.

Description

  • A user signs an EIP-712 message and submits it on-chain to claim their Snowman NFT.

  • Since the hash doesn't include a nonce or unique identifier, a malicious actor can reuse the signed message on another chain, deployment, or after cloning wallets. The logic assumes a single claim will stop replays, but it doesn't account for external factors or signature leaks.

@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount)");
...
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);

Risk

Likelihood:

  • Anyone who captures a valid signature can reuse it later (e.g., from logs, frontend memory, phishing).

  • Replayable across testnets, forks, and other contracts using same logic.

Impact:

  • NFT claimed multiple times.

  • Undeserved mints or DoS to legitimate claims.

  • Vulnerability persists even if hasClaimed is updated, as signature itself remains valid without expiration or nonce.

Proof of Concept- This PoC shows how a previously valid signature can be reused by anyone who obtains it. Since it lacks a nonce or expiry mechanism, the claim can be duplicated indefinitely unless further mitigations are implemented.

// Step 1: Attacker captures this valid signed data for another user
// receiver = 0xAlice, amount = 1, signature = (v, r, s)
// Step 2: Wait until Alice uses the signature
snowmanAirdrop.claimSnowman(receiver, amount, v, r, s);
// Step 3: Replay the same signature again in another tx
snowmanAirdrop.claimSnowman(receiver, amount, v, r, s);

Recommended Mitigation- Introduce a per-user nonce to the signature scheme and ensure it's included in the hashed message and incremented on successful claims. This breaks replayability by making every signed message unique.

- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address 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) {
return _hashTypedDataV4(
- keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, amount))
+ keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, amount, s_nonces[receiver]))
);
}
function claimSnowman(...) external {
...
+ s_nonces[receiver]++;
}
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.