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.
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.
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:
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.
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:
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.
# 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 ... } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.