Snowman Merkle Airdrop

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

Airdrop unclaimable: `SnowmanAirdrop::claimSnowman` builds the Merkle leaf from the live Snow balance instead of the snapshotted amount

Description

  • The airdrop is distributed with a Merkle tree whose leaves are keccak256(keccak256(abi.encode(recipient, amount))), where amount is each recipient's snapshotted Snow balance captured off-chain when the tree is generated (see script/GenerateInput.s.sol, which reads fixed balances helper.aliceSB(), helper.bobSB(), etc., and bakes them into input.json). The Merkle root passed to the constructor is committed to those exact (address, snapshotAmount) pairs.

  • In SnowmanAirdrop::claimSnowman, the contract ignores the snapshotted amount and instead recomputes amount from the recipient's current on-chain balance, then builds the leaf from that live value. Any recipient whose balance has changed since the snapshot produces a leaf that is not in the tree, so MerkleProof.verify fails and the claim reverts. Because Snow is explicitly designed to keep changing (earned weekly via earnSnow, bought via buySnow, and freely transferable as an ERC20), balances drift from the snapshot during normal usage, making the airdrop effectively unclaimable for eligible users.

// src/SnowmanAirdrop.sol (claimSnowman)
uint256 amount = i_snow.balanceOf(receiver); // @> live balance, not the snapshot amount
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); // @> leaf built from live balance
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { // @> reverts whenever live balance != snapshot amount
revert SA__InvalidProof();
}

Risk

Likelihood: High

  • The Merkle root commits to fixed snapshot amounts, while the claim path always reads the live balance; the two diverge as soon as an eligible user's balance changes by even 1 wei.

  • The protocol is built around balances that change over time (weekly earnSnow, buySnow, ordinary ERC20 transfers), so divergence is the expected steady state, not an edge case.

Impact: High

  • Eligible recipients whose balance differs from the snapshot can never claim their Snowman NFT; the airdrop's core function (distributing NFTs to the whitelist) is broken.

  • Legitimate rewards are permanently locked and undeliverable for affected users, defeating the entire purpose of the contract.

Proof of Concept

The tree is generated for alice with snapshot amount = 1e18. Alice then earns her weekly Snow (an intended action), which moves her live balance to 1e18 + 1. The claim rebuilds the leaf from the live balance, so the proof no longer verifies and the claim reverts. Self-contained Foundry test:

function test_ClaimRevertsAfterBalanceChanges() public {
// 1. Snapshot: alice is whitelisted for exactly 1e18 Snow; tree/root built from that pair.
uint256 snapshotAmount = 1e18;
deal(address(snow), alice, snapshotAmount);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(alice, snapshotAmount))));
bytes32 root = leaf; // single-leaf tree; proof is empty
bytes32[] memory proof = new bytes32[](0);
SnowmanAirdrop airdrop = new SnowmanAirdrop(root, address(snow), address(snowman));
// alice approves the airdrop and signs the claim message
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, airdrop.getMessageHash(alice));
// 2. Alice earns her weekly Snow (intended behavior) -> live balance becomes 1e18 + 1
vm.prank(alice);
snow.earnSnow();
// 3. Claim rebuilds the leaf from the LIVE balance, which is not in the tree -> revert
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, proof, v, r, s);
}

Alice is a legitimate whitelisted recipient yet can never claim. The same occurs for any recipient who buys, earns, receives, or sends any Snow after the snapshot.

Recommended Mitigation

Bind the claim to the snapshotted amount that the Merkle tree actually commits to, rather than the live balance. Pass amount in as a parameter and use it consistently for the leaf, the signed message, and the staked transfer:

- 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();
}

The amount used for the leaf, getMessageHash, and the safeTransferFrom must be the same value that was committed to the Merkle tree at snapshot time.

Updates

Lead Judging Commences

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