Snowman Merkle Airdrop

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

claimSnowman Uses Dynamic Snow Balance Instead of Fixed Merkle Amount, Breaking Verification

The claimSnowman function is designed to allow users to claim a Snowman NFT by proving they are on the pre‑approved list and by staking the required amount of Snow tokens. The contract verifies the claim against a Merkle root that was built from a list of (address, amount) pairs.

The issue is that when constructing the leaf for verification, the function reads the user’s current Snow token balance and uses that as the amount, rather than using the fixed amount that was originally hashed into the leaf. This means the leaf computed on‑chain will only match the leaf from the Merkle tree if the user’s balance happens to equal the exact intended amount at the moment of claiming.

uint256 amount = i_snow.balanceOf(receiver);

Risk Likelihood: High – The condition for a successful claim is that the user’s balance exactly matches the value recorded in the Merkle tree. In almost all cases, users will have either more or less than that exact amount (especially because Snow tokens can be earned or bought over time). The only way to succeed is to artificially adjust one’s balance to match, which is both improbable and outside the intended flow.

Impact:

Legitimate recipients cannot claim their NFT unless they happen to hold exactly the predetermined amount.

A user with a large balance could potentially claim a leaf intended for another user if the leaf’s amount happens to match their current balance – though this requires specific conditions, it shows the verification is not tied to the actual intended allocation.

Proof of Concept

The protocol builds a Merkle tree with leaf (address A, 100) (meaning A should stake 100 Snow tokens to receive the NFT).
A holds 150 Snow tokens (e.g., from buying or earning extra).
A calls claimSnowman with the correct proof generated from the tree.
The contract computes leaf = keccak256(abi.encodePacked(keccak256(abi.encode(A, 150)))).
The Merkle proof, which was constructed from (A, 100), will not match this leaf, causing the transaction to revert.
A cannot claim until they reduce their balance to exactly 100, which is not always feasible or expected.

Recommended Mitigation

function claimSnowman(uint256 amount, bytes32[] calldata proof) external nonReentrant {
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
require(MerkleProof.verify(proof, i_merkleRoot, leaf), "Invalid proof");
require(i_snow.transferFrom(msg.sender, address(this), amount), "Transfer failed");
i_snowman.mint(msg.sender);
// ... rest of logic
}
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!