Snowman Merkle Airdrop

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

SnowmanAirdrop::claimSnowman rebuilds the Merkle leaf from the live balanceOf instead of the snapshotted amount, so earning or receiving any Snow voids a recipient's airdrop

Description

  • A Merkle airdrop commits to a fixed set of (address, amount) pairs at snapshot time. The root is immutable, so the amount used to rebuild a leaf at claim time must be the same amount that was hashed into the tree. In this codebase the tree is generated by script/GenerateInput.s.sol, which records each recipient's Snow balance at the moment of generation into script/flakes/input.json.

  • SnowmanAirdrop::claimSnowman does not take the snapshotted amount as a parameter. It reads the claimant's current balance with i_snow.balanceOf(receiver) and hashes that into the leaf. The claim therefore only verifies while the claimant's balance is still exactly what it was at snapshot time. Any change in either direction, by any cause, makes their own valid proof stop verifying.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
@> uint256 amount = i_snow.balanceOf(receiver); // live balance, not the snapshot amount
@> bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof(); // @> fires for every claimant whose balance moved
}

This is not a theoretical drift. There are two ordinary ways a balance moves, and the protocol actively encourages the first one.

1. Using the protocol as documented destroys your own eligibility.

The README advertises that Snow "can either be earned for free once a week, or bought at anytime". Both paths mint to the caller and therefore increase their balance. An eligible recipient who does either thing between the snapshot and their claim has silently forfeited their airdrop. The behaviour is entirely counterintuitive: participating in the token's core mechanic is what disqualifies you, and nothing in the contract or the README warns of it.

2. Anyone can grief any claimant for one wei.

Snow is a standard ERC20 with an unrestricted transfer, and the balance it exposes is push-based. A third party needs no approval and no cooperation from the victim to increase the victim's balance. One wei of Snow, obtainable free from earnSnow, is enough to make a claimant's proof stop verifying.

Risk

Likelihood:

  • Leg 1 requires no attacker and no unusual behaviour. It triggers whenever a recipient uses earnSnow or buySnow, which the project documents as the two intended ways to interact with the token.

  • Leg 2 is permissionless, costs one wei of Snow plus gas, requires no approval from the victim, and can be repeated. An attacker watching the mempool can re-apply it in the same block as an attempted claim.

  • Every one of the five recipients in the project's own input.json is recorded with amount of exactly 1, the smallest representable balance, so any inbound transfer at all breaks the match.

Impact:

  • Affected recipients cannot claim the NFTs they are entitled to. The airdrop's core guarantee, that a listed address can redeem its allocation, does not hold.

  • Against an active griefer the denial is renewable at one wei a time, so an attacker can single out specific recipients and keep them out for the lifetime of the airdrop at negligible cost.

Scope note, stated plainly rather than left for a judge to find: this is a denial of the claim, not an irreversible loss. A recipient who understands the cause can restore a balance of exactly the snapshotted amount by transferring the excess to another address, and then claim. The proof of concept below deliberately demonstrates that recovery working, and then shows the grief being re-applied for one wei afterwards. The severity rests on the denial being renewable and on leg 1 being silent and self-inflicted, not on any claim of permanence.

Proof of Concept

Both tests use the project's own Helper.s.sol deployment and the Merkle proofs copied verbatim from the project's own test/TestSnowmanAirdrop.t.sol, so the fixture is the sponsor's.

Leg 1 - no attacker. Alice earns the free weekly Snow the README advertises, and loses her airdrop.

function test_C2b_using_the_protocol_as_intended_destroys_your_own_claim() public {
vm.warp(block.timestamp + 1 weeks);
vm.prank(alice);
snow.earnSnow(); // the README's advertised "free Snow once a week"
assertEq(snow.balanceOf(alice), 2, "alice now holds 2");
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
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);
}

Leg 2 - a third party grieving for one wei. The test opens with the counterfactual, so the negative result cannot be an artifact of the fixture: it first proves the claim succeeds on an untouched balance, reverts that state, and only then applies the dust.

function test_C2_one_wei_of_dust_blocks_a_claim_and_the_grief_is_repeatable() public {
// COUNTERFACTUAL FIRST: without the dust, alice's claim succeeds.
// (Proven by the project's own testClaimSnowman; re-proven here in
// isolation so the negative claim below cannot be a fixture artifact.)
uint256 snap = vm.snapshotState();
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
bytes32 d0 = airdrop.getMessageHash(alice);
(uint8 v0, bytes32 r0, bytes32 s0) = vm.sign(alKey, d0);
vm.prank(alice);
airdrop.claimSnowman(alice, AL_PROOF, v0, r0, s0);
assertEq(nft.balanceOf(alice), 1, "control: claim works with an untouched balance");
vm.revertToState(snap);
// NOW the attack. Attacker needs one wei of Snow. Anyone can get some:
// earnSnow is free and permissionless.
vm.warp(block.timestamp + 1 weeks);
vm.prank(attacker);
snow.earnSnow();
assertEq(snow.balanceOf(attacker), 1, "attacker earned 1 wei of Snow for free");
// A plain ERC20 transfer. No approval from the victim is needed.
vm.prank(attacker);
snow.transfer(alice, 1);
assertEq(snow.balanceOf(alice), 2, "alice's balance is now 2, the tree says 1");
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
// Alice's own valid proof no longer matches her leaf.
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
// And she cannot repair it: burning is not exposed, and sending the dust
// away changes the balance again. The only balance that matches the leaf
// is exactly 1, which she can only reach by transferring the excess out.
// That works here, but the attacker can repeat the grief for 1 wei at any
// time, including in the same block as her claim.
vm.prank(alice);
snow.transfer(attacker, 1);
assertEq(snow.balanceOf(alice), 1, "alice restored her balance to exactly 1");
vm.prank(attacker);
snow.transfer(alice, 1); // re-grief, cost: 1 wei
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
console2.log("cost to re-apply the grief, per claimant (wei of Snow):", uint256(1));
}

Results:

[PASS] test_C2_one_wei_of_dust_blocks_a_claim_and_the_grief_is_repeatable()
cost to re-apply the grief, per claimant (wei of Snow): 1
[PASS] test_C2b_using_the_protocol_as_intended_destroys_your_own_claim()

Recommended Mitigation

Take the snapshotted amount as a claim parameter and verify it against the tree, exactly as the tree was built. The claimed amount then no longer depends on mutable state, and neither the recipient's own activity nor a third party's transfer can invalidate a valid proof.

- 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) {
- revert SA__ZeroAmount();
- }
+ if (amount == 0) {
+ 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))));

getMessageHash should take the same amount parameter rather than reading the balance, so that the signed message and the verified leaf describe the same allocation.

Note that the staking transfer on the following line, i_snow.safeTransferFrom(receiver, address(this), amount), will then move exactly the snapshotted amount and will revert if the recipient no longer holds that much. That is the correct behaviour: it makes the requirement explicit and checkable rather than silently invalidating a proof.

Updates

Lead Judging Commences

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