Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

`claimSnowman` rebuilds the Merkle leaf from the live Snow balance instead of the committed amount, so eligible users lose their claim by farming as the protocol instructs

The Merkle leaf is derived from mutable live state instead of the amount the tree committed to, so any change to a claimant's Snow balance invalidates both their proof and their signature

Description

  • A Merkle airdrop commits (recipient, amount) pairs into an immutable root at deployment. The claim function is supposed to take the amount as a parameter and let the proof authenticate it, so a recipient's entitlement is fixed at snapshot time and cannot drift.

  • SnowmanAirdrop::claimSnowman instead re-reads the recipient's current Snow balance and builds the leaf from that. The tree is a frozen offline snapshot while the balance is live and mutable, so the claim only verifies for as long as the balance equals the snapshot exactly. Any transfer in or out — including the free weekly farming the protocol tells users to do — silently destroys the claim.

// src/SnowmanAirdrop.sol:84-90
@> uint256 amount = i_snow.balanceOf(receiver); // @> claim datum read from LIVE, mutable state
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
@> if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { // @> checked against an IMMUTABLE root
revert SA__InvalidProof();
}

The same live read is repeated inside the digest, so the pre-signed message dies alongside the proof:

// src/SnowmanAirdrop.sol:112-122
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) { revert SA__ZeroAmount(); }
@> uint256 amount = i_snow.balanceOf(receiver); // @> digest moves with the balance too
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Every leaf in script/flakes/input.json commits amount = "1", and i_merkleRoot is immutable (src/SnowmanAirdrop.sol:43), so the claim window is a single exact balance value per recipient.

The specification contradicts itself here, which is what makes this a defect rather than a design choice: the README promises NFTs "equal to their Snow balance", while the tree commits a fixed amount generated once, offline. Both statements cannot hold at the same time.

Risk

Likelihood:

  • Every recipient who farms their free weekly Snow before claiming loses their claim. Snow::earnSnow is the protocol's own advertised distribution mechanism, so the balance-changing action is the one users are actively directed to perform.

  • Every recipient who receives Snow from anyone, or spends any of it, moves out of the single valid balance value. The token has no transfer restrictions (src/Snow.sol:18), so third parties can push a balance change onto a recipient without their involvement.

  • The failure is silent at signing time. getMessageHash returns a hash for any nonzero balance, so a user signs successfully and only discovers the problem when the claim reverts.

Impact:

  • Eligible recipients are denied their airdrop until they manually restore the exact snapshot balance — an operation the protocol never documents and provides no interface for.

  • A third party can grief any pending claim by transferring 1 wei of Snow to the claimant, invalidating their proof and their pre-signed message.

  • Once the 12-week farming window closes, the lockout becomes unrecoverable through the protocol for anyone whose balance dropped below the snapshot: earnSnow and buySnow both revert S__SnowFarmingOver (src/Snow.sol:53-58) at any price, and getMessageHash reverts SA__ZeroAmount. Recovery then depends entirely on an over-the-counter trade for a token whose total free supply is roughly 12 wei.

Honest limitations

Stated explicitly so the severity is not overread — I verified each of these rather than assuming the worst case:

  • The third-party dust grief is not permanent. The victim can transfer the excess to a burn address, re-sign and claim. test_M01b_DustGriefIsRecoverable below demonstrates the recovery. It is a delay, not a brick.

  • Sustained griefing is costly for the attacker: each round permanently burns 1 wei of their own Snow, because the victim sheds it to a dead address where it cannot be recycled — against a supply throttled to 1 wei per week protocol-wide, or 5 ETH per wei on the paid path.

  • Front-running the recovery is defeated by batching heal-and-claim into a single transaction through a helper contract.

This is filed at Medium and led by the no-attacker self-lockout, which requires no ammunition at all and therefore has none of these limitations.

Proof of Concept

Save as test/PoCM01.t.sol and run forge test --match-contract PoCM01 -vv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.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";
import {Helper} from "../script/Helper.s.sol";
contract PoCM01 is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
// alice's proof, lifted verbatim from the project's own TestSnowmanAirdrop.t.sol
bytes32[] AL_PROOF = [
bytes32(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52),
bytes32(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af),
bytes32(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1)
];
address alice;
uint256 alKey;
address bob;
address attacker = makeAddr("attacker");
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
bob = makeAddr("bob");
}
/// PRIMARY: no attacker, no cost - the user simply does what the README tells them to.
function test_M01_SelfLockoutByUsingTheProtocolAsAdvertised() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
// alice farms her free weekly Snow, exactly as the protocol advertises
vm.warp(block.timestamp + 1 weeks);
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 2, "balance 2, the tree committed 1");
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}
/// Third-party dust variant - reported honestly as a DELAY, not a brick.
function test_M01b_DustGriefIsRecoverable() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
// attacker obtains 1 wei the free way and dusts alice
vm.warp(block.timestamp + 1 weeks);
vm.prank(attacker);
snow.earnSnow();
vm.prank(attacker);
snow.transfer(alice, 1);
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
// ...but she heals it herself by shedding the dust. NOT permanent.
vm.prank(alice);
snow.transfer(address(0xdead), 1);
(v, r, s) = vm.sign(alKey, airdrop.getMessageHash(alice));
vm.prank(alice);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1, "recovered - this is a delay, not a brick");
}
/// The one case that IS unrecoverable through the protocol.
function test_M01c_AfterFarmingWindowThereIsNoProtocolPathBack() public {
// alice moves her Snow before claiming (sells, rotates wallet, pays someone)
vm.prank(alice);
snow.transfer(bob, 1);
assertEq(snow.balanceOf(alice), 0);
vm.warp(block.timestamp + 12 weeks); // farming window closes
vm.prank(alice);
vm.expectRevert(Snow.S__SnowFarmingOver.selector);
snow.earnSnow();
vm.deal(alice, 100 ether);
vm.prank(alice);
vm.expectRevert(Snow.S__SnowFarmingOver.selector);
snow.buySnow{value: 5 ether}(1); // no price restores it
vm.expectRevert(SnowmanAirdrop.SA__ZeroAmount.selector);
airdrop.getMessageHash(alice);
}
}

Result:

[PASS] test_M01_SelfLockoutByUsingTheProtocolAsAdvertised() (gas: 128410)
[PASS] test_M01b_DustGriefIsRecoverable() (gas: 351874)
[PASS] test_M01c_AfterFarmingWindowThereIsNoProtocolPathBack() (gas: 58456)
Suite result: ok. 3 passed; 0 failed; 0 skipped

Note on the project's own tests

TestSnowmanAirdrop::testClaimSnowman passes only because script/Helper.s.sol:35-61 gives every user exactly 1 wei via earnSnow(), matching the committed "1" byte for byte. The fixture masks the divergence — no test in the suite ever exercises a claimant whose balance changed after the snapshot.

Recommended Mitigation

Take the amount from calldata and let the Merkle proof authenticate it, which is the entire purpose of committing it to the tree. Bind that same value into the signed struct instead of re-reading the balance.

- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(
+ address receiver,
+ uint256 amount,
+ bytes32[] calldata merkleProof,
+ uint8 v,
+ bytes32 r,
+ bytes32 s
+ )
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
- if (i_snow.balanceOf(receiver) == 0) {
+ if (amount == 0) {
revert SA__ZeroAmount();
}
+ if (i_snow.balanceOf(receiver) < amount) {
+ revert SA__ZeroAmount();
+ }
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, getMessageHash(receiver, amount), v, r, s)) {
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver);
-
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
- function getMessageHash(address receiver) public view returns (bytes32) {
- if (i_snow.balanceOf(receiver) == 0) {
- revert SA__ZeroAmount();
- }
-
- uint256 amount = i_snow.balanceOf(receiver);
-
+ function getMessageHash(address receiver, uint256 amount) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 15 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] DoS to a user trying to claim a Snowman

# Root + Impact ## Description * Users will approve a specific amount of Snow to the SnowmanAirdrop and also sign a message with their address and that same amount, in order to be able to claim the NFT * Because the current amount of Snow owned by the user is used in the verification, an attacker could forcefully send Snow to the receiver in a front-running attack, to prevent the receiver from claiming the NFT.&#x20; ```Solidity function getMessageHash(address receiver) public view returns (bytes32) { ... // @audit HIGH An attacker could send 1 wei of Snow token to the receiver and invalidate the signature, causing the receiver to never be able to claim their Snowman uint256 amount = i_snow.balanceOf(receiver); return _hashTypedDataV4( keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount}))) ); ``` ## Risk **Likelihood**: * The attacker must purchase Snow and forcefully send it to the receiver in a front-running attack, so the likelihood is Medium **Impact**: * The impact is High as it could lock out the receiver from claiming forever ## Proof of Concept The attack consists on Bob sending an extra Snow token to Alice before Satoshi claims the NFT on behalf of Alice. To showcase the risk, the extra Snow is earned for free by Bob. ```Solidity function testDoSClaimSnowman() public { assert(snow.balanceOf(alice) == 1); // Get alice's digest while the amount is still 1 bytes32 alDigest = airdrop.getMessageHash(alice); // alice signs a message (uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest); vm.startPrank(bob); vm.warp(block.timestamp + 1 weeks); snow.earnSnow(); assert(snow.balanceOf(bob) == 2); snow.transfer(alice, 1); // Alice claim test assert(snow.balanceOf(alice) == 2); vm.startPrank(alice); snow.approve(address(airdrop), 1); // satoshi calls claims on behalf of alice using her signed message vm.startPrank(satoshi); vm.expectRevert(); airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS); } ``` ## Recommended Mitigation Include the amount to be claimed in both `getMessageHash` and `claimSnowman` instead of reading it from the Snow contract. Showing only the new code in the section below ```Python function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant { ... bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { revert SA__InvalidProof(); } // @audit LOW Seems like using the ERC20 permit here would allow for both the delegation of the claim and the transfer of the Snow tokens in one transaction i_snow.safeTransferFrom(receiver, address(this), amount); // send ... } ```

Support

FAQs

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

Give us feedback!