claimSnowman rebuilds the Merkle leaf from the live balanceOf instead of the snapshot amount. Impact: any balance change after the snapshot permanently voids an eligible user's claimThe Merkle root commits to (address, amount) pairs captured at snapshot time, and every entry in script/flakes/input.json holds exactly 1 Snow. A claim should verify against that recorded amount.
Instead the amount is read live at call time and hashed into the leaf. The leaf only matches while the holder's balance is frozen at the snapshot value, so one wei in either direction makes the proof fail. The protocol simultaneously invites users to keep farming and buying Snow, so the two mechanisms work against each other.
The README also promises recipients "receive Snowman NFTS equal to their Snow balance", which a frozen snapshot cannot deliver. The two statements cannot both hold.
Likelihood:
No attacker is required. A holder who does what the protocol tells them to do, farming their free Snow the following week or buying more, destroys their own claim.
Snow is a plain ERC20, so anyone can push one wei into any eligible address unsolicited, and a watcher can repeat it before every claim attempt.
Impact:
Eligible users who farmed across the 12 week window lose the airdrop entirely, and there is no partial or snapshot-amount claim to fall back on.
The loss becomes unrecoverable once farming closes: with earnSnow and buySnow both reverting, a holder whose balance fell below the snapshot has no way to restore it.
The affected party is any eligible recipient, alice here, one of the five in script/flakes/input.json. An attacker is optional: the first path needs none at all, the second is any address holding a wei of Snow. The protocol side is SnowmanAirdrop, which rejects its own recipients. Deployed via their own Helper.s.sol.
Output:
Three supporting tests, all passing, close the usual objections. Buying Snow, the other advertised way to obtain it, breaks a claim exactly the same way. Once FARMING_DURATION expires a holder who has dropped below the snapshot cannot recover at all, since earnSnow and buySnow both revert and getMessageHash refuses a zero balance. And the dust grief is not something the victim can simply undo: claimSnowman requires the receiver's own signature, so she cannot combine the cleanup and the claim in one transaction through a helper contract, and a watcher refills the dust in the gap between her two transactions. I ran that loop three times and she never got a claim through.
Fuzzing confirms it is not limited to one wei: every deviation from the snapshot amount rejects the claim across 2000 randomised runs.
Take the snapshot amount as a parameter, verify it against the proof, and only require that the holder still owns at least that much:
getMessageHash needs the same treatment, taking amount as an argument rather than reading the live balance, so the signed digest stays stable too.
# 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.