A Merkle airdrop commits (recipient, amount) pairs into an immutable root at deployment. The claim function is supposed to take the amount as a parameter and let the proof authenticate it, so a recipient's entitlement is fixed at snapshot time and cannot drift.
SnowmanAirdrop::claimSnowman instead re-reads the recipient's current Snow balance and builds the leaf from that. The tree is a frozen offline snapshot while the balance is live and mutable, so the claim only verifies for as long as the balance equals the snapshot exactly. Any transfer in or out — including the free weekly farming the protocol tells users to do — silently destroys the claim.
The same live read is repeated inside the digest, so the pre-signed message dies alongside the proof:
Every leaf in script/flakes/input.json commits amount = "1", and i_merkleRoot is immutable (src/SnowmanAirdrop.sol:43), so the claim window is a single exact balance value per recipient.
The specification contradicts itself here, which is what makes this a defect rather than a design choice: the README promises NFTs "equal to their Snow balance", while the tree commits a fixed amount generated once, offline. Both statements cannot hold at the same time.
Likelihood:
Every recipient who farms their free weekly Snow before claiming loses their claim. Snow::earnSnow is the protocol's own advertised distribution mechanism, so the balance-changing action is the one users are actively directed to perform.
Every recipient who receives Snow from anyone, or spends any of it, moves out of the single valid balance value. The token has no transfer restrictions (src/Snow.sol:18), so third parties can push a balance change onto a recipient without their involvement.
The failure is silent at signing time. getMessageHash returns a hash for any nonzero balance, so a user signs successfully and only discovers the problem when the claim reverts.
Impact:
Eligible recipients are denied their airdrop until they manually restore the exact snapshot balance — an operation the protocol never documents and provides no interface for.
A third party can grief any pending claim by transferring 1 wei of Snow to the claimant, invalidating their proof and their pre-signed message.
Once the 12-week farming window closes, the lockout becomes unrecoverable through the protocol for anyone whose balance dropped below the snapshot: earnSnow and buySnow both revert S__SnowFarmingOver (src/Snow.sol:53-58) at any price, and getMessageHash reverts SA__ZeroAmount. Recovery then depends entirely on an over-the-counter trade for a token whose total free supply is roughly 12 wei.
Stated explicitly so the severity is not overread — I verified each of these rather than assuming the worst case:
The third-party dust grief is not permanent. The victim can transfer the excess to a burn address, re-sign and claim. test_M01b_DustGriefIsRecoverable below demonstrates the recovery. It is a delay, not a brick.
Sustained griefing is costly for the attacker: each round permanently burns 1 wei of their own Snow, because the victim sheds it to a dead address where it cannot be recycled — against a supply throttled to 1 wei per week protocol-wide, or 5 ETH per wei on the paid path.
Front-running the recovery is defeated by batching heal-and-claim into a single transaction through a helper contract.
This is filed at Medium and led by the no-attacker self-lockout, which requires no ammunition at all and therefore has none of these limitations.
Save as test/PoCM01.t.sol and run forge test --match-contract PoCM01 -vv.
Result:
TestSnowmanAirdrop::testClaimSnowman passes only because script/Helper.s.sol:35-61 gives every user exactly 1 wei via earnSnow(), matching the committed "1" byte for byte. The fixture masks the divergence — no test in the suite ever exercises a claimant whose balance changed after the snapshot.
Take the amount from calldata and let the Merkle proof authenticate it, which is the entire purpose of committing it to the tree. Bind that same value into the signed struct instead of re-reading the balance.
# 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.