Snowman Merkle Airdrop

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

Merkle leaf computed from dynamic balanceOf instead of snapshot amount — any Snow transfer permanently DoSes legitimate claimers

Merkle leaf computed from dynamic balanceOf instead of snapshot amount

Impact

High Impact

Likelihood

Medium Likelihood

Root + Impact

Description

  • A Merkle airdrop is designed so that the root is frozen at deployment time and only users present in the tree with their exact allocated amounts can claim.

  • claimSnowman reads amount = i_snow.balanceOf(receiver) at claim time and constructs leaf = keccak256(keccak256(abi.encode(receiver, amount))) using that live value. If anyone transfers even 1 wei of Snow to a legitimate receiver after the snapshot, the receiver's balance changes, the recomputed leaf no longer matches any leaf in the tree, and MerkleProof.verify reverts — permanently locking the claimer out.

// src/SnowmanAirdrop.sol
@> uint256 amount = i_snow.balanceOf(receiver); // dynamic — not from calldata
bytes32 leaf = keccak256(
bytes.concat(keccak256(abi.encode(receiver, amount)))
);
@> if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();

Risk

Likelihood:

  • A single 1-wei Snow transfer to any legitimate receiver (intentional griefing or accidental) permanently prevents their claim.

  • Any secondary market activity, dust attacks, or protocol integrations moving Snow balances trigger this.

Impact:

  • Legitimate Merkle recipients permanently lose their Snowman NFT entitlement.

  • Merkle proof verification becomes meaningless — it only confirms (receiver, currentBalance) is in the tree, not the frozen snapshot amount.

Proof of Concept

1) Merkle tree built with leaf: keccak256(keccak256(abi.encode(alice, 5e18)))
2) Alice holds exactly 5e18 Snow — claim would succeed
3) Bob sends 1 wei Snow to Alice (dust transfer / griefing)
4) Alice balance is now 5e18 + 1
5) Contract computes leaf from 5e18+1 -> no match in tree -> SA__InvalidProof revert
6) Alice can never claim unless she transfers exactly 1 wei back out

Recommended Mitigation

- 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();
- uint256 amount = i_snow.balanceOf(receiver);
+ if (i_snow.balanceOf(receiver) < amount) revert SA__InsufficientBalance();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));

The amount passed by the caller must match the value stored in the Merkle tree at snapshot time.

Updates

Lead Judging Commences

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