A whitelisted user claims their Snowman NFTs by presenting a Merkle proof and an EIP-712 signature for their allocation. However, both the Merkle leaf and the signed digest are recomputed from Snow::balanceOf(receiver) at execution time instead of from an amount committed in the tree, so any balance movement after the snapshot — including the protocol's own earnSnow/buySnow, or 1 wei of dust sent by anyone — makes the claim revert until the user manually rebalances.
The tree commits to (receiver, amount) pairs fixed at snapshot time. Because the contract re-derives amount from the live balance, the leaf only matches when the current balance equals the snapshot balance exactly. A user who follows the intended flow — earning 1 free Snow per week — silently loses claimability the moment their balance moves, and there is no on-chain alternate settlement path: the only recovery is for each affected user to figure out they must transfer the exact excess Snow away themselves. The same mechanism is a griefing vector: anyone can send 1 wei of Snow to a victim to force their pending claim to revert, at a cost of 1 wei of a freely earnable token.
Likelihood:
Any whitelisted user who earns or buys Snow between the snapshot and their claim is desynced — this is the protocol's own encouraged usage.
Any third party can desync any victim at will by transferring them 1 wei of Snow (mempool-front-running a pending claim works too).
Impact:
Claim (the airdrop's core function) reverts for every desynced user until each user individually performs a manual token transfer to restore the exact snapshot balance — non-obvious and undocumented.
Dust-cost griefing: an attacker can repeatedly block victims' claims for 1 wei per disruption.
Run: add the test to test/TestSnowmanAirdrop.t.sol (setup provides dan/clara/eli, their keys, and DAN_PROOF/CL_PROOF), then forge test --match-test test_PoC_LiveBalanceDesync -vv
Output (actual run):
Commit the amount in the leaf and accept it as a parameter instead of sampling the live balance (this also enables partial claims).
(Apply the same amount parameter inside getMessageHash so the signed struct matches the committed allocation.)
# 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.