Snowman Merkle Airdrop

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

`claimSnowman()` reads live Snow balance for Merkle leaf causing permanent proof failure when balance changes

Root + Impact

Description

  • The Merkle tree encodes static (address, amount) pairs captured at snapshot time, and claimSnowman() is meant to verify a recipient's eligibility against those fixed values.

  • claimSnowman() constructs the Merkle leaf using i_snow.balanceOf(receiver) — the receiver's current live Snow balance at claim time rather than the amount recorded in the tree. If a user acquires even one additional Snow token (via buySnow() or earnSnow()) after the snapshot, their proof permanently fails. Additionally, getMessageHash() reads balanceOf independently, so in relay scenarios the signed digest (balance at signing) and the Merkle leaf (balance at execution) can diverge, making the relay feature effectively unusable.

@> uint256 amount = i_snow.balanceOf(receiver); // line 84 — live balance, not snapshot amount
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}

Risk

Likelihood:

  • Any user who acquires additional Snow tokens after the snapshot — a normal and expected user behavior on the protocol — enters the permanently-failed state with no recovery path.

  • Relay designs (where satoshi submits claims on behalf of users) extend the vulnerable window from signing to block inclusion, which can span minutes to hours in practice.

Impact:

  • Legitimate recipients whose Snow balance differs from the exact snapshot amount are permanently unable to claim, losing their airdrop allocation despite being whitelisted.

  • Relayers cannot reliably batch transactions because the hash changes with every balance change, making the relay feature effectively unusable.

Proof of Concept

Place this test in test/ and run forge test --match-test testProofFailsAfterBalanceChange. The test demonstrates that a Merkle proof verified against a user's live token balance will fail if the balance changes between whitelist snapshot and claim time, permanently locking valid claimants out.

contract LiveBalancePoC is Test {
function testProofFailsAfterBalanceChange() public {
// Alice is whitelisted with amount=1 in the Merkle tree
// Alice earns one more Snow token after the snapshot
vm.prank(alice);
snow.earnSnow(); // alice.balance is now 2
// Alice tries to claim with her valid proof (generated for amount=1)
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, merkleProof, v, r, s);
// Fails: leaf is built from balance=2, but proof covers balance=1
}
}

Recommended Mitigation

Add an explicit amount parameter to claimSnowman() and verify the Merkle proof against the caller-supplied amount rather than the live on-chain balance, decoupling proof validity from token balance fluctuations. The receiver must hold at least amount Snow tokens at claim time; any remaining balance stays in their wallet and is not staked.

function claimSnowman(
address receiver,
+ uint256 amount, // claimant supplies the amount from the tree
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), i_snow.balanceOf(receiver));
+ i_snow.safeTransferFrom(receiver, address(this), amount);
i_snowman.mintSnowman(receiver, amount);
}
Updates

Lead Judging Commences

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