A Merkle airdrop commits to a fixed set of (address, amount) pairs at snapshot time. The root is immutable, so the amount used to rebuild a leaf at claim time must be the same amount that was hashed into the tree. In this codebase the tree is generated by script/GenerateInput.s.sol, which records each recipient's Snow balance at the moment of generation into script/flakes/input.json.
SnowmanAirdrop::claimSnowman does not take the snapshotted amount as a parameter. It reads the claimant's current balance with i_snow.balanceOf(receiver) and hashes that into the leaf. The claim therefore only verifies while the claimant's balance is still exactly what it was at snapshot time. Any change in either direction, by any cause, makes their own valid proof stop verifying.
This is not a theoretical drift. There are two ordinary ways a balance moves, and the protocol actively encourages the first one.
1. Using the protocol as documented destroys your own eligibility.
The README advertises that Snow "can either be earned for free once a week, or bought at anytime". Both paths mint to the caller and therefore increase their balance. An eligible recipient who does either thing between the snapshot and their claim has silently forfeited their airdrop. The behaviour is entirely counterintuitive: participating in the token's core mechanic is what disqualifies you, and nothing in the contract or the README warns of it.
2. Anyone can grief any claimant for one wei.
Snow is a standard ERC20 with an unrestricted transfer, and the balance it exposes is push-based. A third party needs no approval and no cooperation from the victim to increase the victim's balance. One wei of Snow, obtainable free from earnSnow, is enough to make a claimant's proof stop verifying.
Likelihood:
Leg 1 requires no attacker and no unusual behaviour. It triggers whenever a recipient uses earnSnow or buySnow, which the project documents as the two intended ways to interact with the token.
Leg 2 is permissionless, costs one wei of Snow plus gas, requires no approval from the victim, and can be repeated. An attacker watching the mempool can re-apply it in the same block as an attempted claim.
Every one of the five recipients in the project's own input.json is recorded with amount of exactly 1, the smallest representable balance, so any inbound transfer at all breaks the match.
Impact:
Affected recipients cannot claim the NFTs they are entitled to. The airdrop's core guarantee, that a listed address can redeem its allocation, does not hold.
Against an active griefer the denial is renewable at one wei a time, so an attacker can single out specific recipients and keep them out for the lifetime of the airdrop at negligible cost.
Scope note, stated plainly rather than left for a judge to find: this is a denial of the claim, not an irreversible loss. A recipient who understands the cause can restore a balance of exactly the snapshotted amount by transferring the excess to another address, and then claim. The proof of concept below deliberately demonstrates that recovery working, and then shows the grief being re-applied for one wei afterwards. The severity rests on the denial being renewable and on leg 1 being silent and self-inflicted, not on any claim of permanence.
Both tests use the project's own Helper.s.sol deployment and the Merkle proofs copied verbatim from the project's own test/TestSnowmanAirdrop.t.sol, so the fixture is the sponsor's.
Leg 1 - no attacker. Alice earns the free weekly Snow the README advertises, and loses her airdrop.
Leg 2 - a third party grieving for one wei. The test opens with the counterfactual, so the negative result cannot be an artifact of the fixture: it first proves the claim succeeds on an untouched balance, reverts that state, and only then applies the dust.
Results:
Take the snapshotted amount as a claim parameter and verify it against the tree, exactly as the tree was built. The claimed amount then no longer depends on mutable state, and neither the recipient's own activity nor a third party's transfer can invalidate a valid proof.
getMessageHash should take the same amount parameter rather than reading the balance, so that the signed message and the verified leaf describe the same allocation.
Note that the staking transfer on the following line, i_snow.safeTransferFrom(receiver, address(this), amount), will then move exactly the snapshotted amount and will revert if the recipient no longer holds that much. That is the correct behaviour: it makes the requirement explicit and checkable rather than silently invalidating a proof.
# 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.