Snowman Merkle Airdrop

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

Critical: Replayable Signature Vulnerability in claimSnowman() - NFT Theft via Signature Interception

Root + Impact

Description

  • Normal behavior:
    Cryptographic signatures used for claiming NFTs should be single-use only. This prevents attackers from reusing valid signatures to repeatedly claim rewards or steal user allocations.

  • Issue:
    The contract validates signatures without any nonce mechanism. As a result, a valid claim signature can be reused indefinitely, either by a malicious actor or a third party who intercepts it. This makes the protocol vulnerable to replay attacks, where stolen or leaked signatures can be exploited to mint NFTs multiple times.

// >>> Root cause: Signature replay possible due to missing nonce binding @>
function _isValidSignature(address receiver, bytes32 digest, ...)
internal
pure
returns (bool)
{
(address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
return actualSigner == receiver; // @> No nonce protection
}

Risk

Likelihood:

  • High — Off-chain signatures are easily intercepted (e.g., via phishing or mempool sniffing).

  • Reproducibility: Replay is always possible unless explicitly prevented.

  • Ease of exploitation: Minimal effort required after obtaining a valid signature.

Impact:

  • NFT theft — Attackers can claim NFTs intended for other users.

  • Allocation loss — Users lose access to their rightful rewards.

  • Trust erosion — The system appears broken or rigged to users.

  • Financial damage — Valuable NFTs may be stolen and sold.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/SnowmanAirdrop.sol";
contract ReplayPoC is Test {
SnowmanAirdrop airdrop;
address alice = address(0xAAA);
address attacker = address(0xBAD);
bytes32[] proof;
uint256 expectedAmount = 1;
function testSignatureReplay() public {
// Alice signs a valid claim
(uint8 v, bytes32 r, bytes32 s) = getSignature(alice);
// Attacker reuses the signature
vm.prank(attacker);
airdrop.claimSnowman(alice, proof, v, r, s);
// Attacker gets Alice's NFTs
assertEq(nft.balanceOf(attacker), expectedAmount);
assertEq(nft.balanceOf(alice), 0); // Alice is cheated
}
}

Explanation:

  • A valid signature is created off-chain for alice.

  • Attacker intercepts the signed data or reuses it from prior mempool transactions.

  • They call claimSnowman() on Alice’s behalf and steal the allocation.

  • There is no nonce check or protection to detect this replay.


Recommended Mitigation

Use a nonce-based system to invalidate reused signatures. Every signed claim must include a unique nonce value per user to ensure one-time use.

+ mapping(address => uint256) public nonces;
function claimSnowman(
address receiver,
uint256 snapshotAmount,
bytes32[] calldata merkleProof,
uint8 v, bytes32 r, bytes32 s
) external {
+ bytes32 digest = keccak256(abi.encodePacked(
+ MESSAGE_TYPEHASH,
+ receiver,
+ snapshotAmount,
+ nonces[receiver]++
+ ));
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, digest, v, r, s)) {
revert SA__InvalidSignature();
}
// Continue claim logic...
}

Explanation:

  • Fix: Add nonces[receiver] as a required part of the signature.

  • Security: Guarantees that each signature is valid for only one claim.

  • Replay prevention: Previously used signatures become invalid once nonce increments.

  • Standard practice: Follows industry-standard replay protection for signed messages.

Severity Note:

This is a critical vulnerability because it enables theft of NFT allocations with no on-chain prevention. Any user with a leaked or reused signature is at immediate risk. Adding a nonce is a proven, low-cost solution with significant security improvement.

Verification confirms proper functionality:

function testSignatureWithNoncePreventsReplay() public {
vm.prank(alice);
airdrop.claimSnowman(alice, 100, proof, v, r, s); // Success
vm.expectRevert("Invalid signature"); // Nonce has incremented
vm.prank(attacker);
airdrop.claimSnowman(alice, 100, proof, v, r, s); // Replay fails
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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