Snowman Merkle Airdrop

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

Airdrop claim binds to the recipient's live Snow balance, so anyone can dust them to invalidate the proof and block the claim

Description

SnowmanAirdrop.claimSnowman derives the claim amount from the recipient's live Snow balance and builds the merkle leaf from it:

uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();

The merkle tree commits to a specific (receiver, amount) snapshot, but the claim recomputes amount from whatever the recipient holds at claim time. Since Snow is a freely-transferable ERC20, anyone can send a pending recipient a tiny amount of Snow (even 1 wei), changing balanceOf(receiver) so the recomputed leaf no longer matches the committed one, and the claim reverts SA__InvalidProof.

The recipient can only restore eligibility by moving their balance back to the exact committed amount — which the attacker can undo by re-dusting. Front-running the claim with a 1-wei transfer blocks it indefinitely.

Risk

Likelihood: High

  • Anyone can transfer Snow to anyone; a single wei is enough and it can be repeated or front-run.

Impact: Medium

  • Recipients can be griefed out of their airdrop; any claim is DoS-able by an unrelated third party at negligible cost.

Proof of Concept

A griefer dusts alice with 1 Snow, and her otherwise-valid claim now reverts:

function test_M4_dustTransferGriefsClaim() public {
address griefer = makeAddr("griefer");
vm.deal(griefer, snow.s_buyFee());
vm.prank(griefer); snow.buySnow{value: snow.s_buyFee()}(1);
vm.prank(griefer); snow.transfer(alice, 1); // alice 1 -> 2
assertEq(snow.balanceOf(alice), 2);
vm.prank(alice); snow.approve(address(airdrop), type(uint256).max);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, airdrop.getMessageHash(alice));
vm.prank(satoshi);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s); // bricked by the dust
}

Recommended Mitigation

Commit the airdrop amount in the merkle leaf and use that committed value, instead of the recipient's live balance. Take amount as a claim parameter, verify keccak(receiver, amount) against the root, and require the recipient to stake exactly that committed amount:

- 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 {
...
- uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();
i_snow.safeTransferFrom(receiver, address(this), amount);
Updates

Lead Judging Commences

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