The intended behavior of claimSnowman is that any address whitelisted in the Merkle tree (with the matching (address, amount) leaf) can produce a valid Merkle proof, sign the EIP-712 digest, and receive their NFT. The Merkle root is immutable at deployment, so once a user is whitelisted their claim should be guaranteed for the lifetime of the contract.
The specific issue is that the on-chain leaf is computed from i_snow.balanceOf(receiver) at claim time, NOT from a fixed amount parameter supplied by the caller. The Merkle tree off-chain is built with a fixed (address, amount) pair captured at snapshot time, but the contract re-derives amount from live balance state. Any third party who transfers even 1 wei of Snow tokens into a whitelisted victim's wallet — an action that requires no permission, no signature, and no interaction with the victim — pushes the victim's balance above the Merkle-tree amount, makes the on-chain leaf diverge from the off-chain leaf, and causes MerkleProof.verify to revert with SA__InvalidProof. The victim cannot claim until they shed the excess tokens, and if the farming window has closed and they cannot earnSnow() again, the griefing can be permanent.
Likelihood:
Reason 1: Any Snow token holder (including the attacker) can grief any whitelisted victim by calling snow.transfer(victim, 1). No approval, no signature, no victim interaction is required. Transfers are a base ERC20 capability that cannot be blocked.
Reason 2: The griefing is triggered by ordinary ERC20 transfers that look completely benign on-chain and in explorers — there is no on-chain signal that the transfer is an attack, which makes the cause of the SA__InvalidProof revert non-obvious to the victim.
Impact:
Impact 1: A whitelisted victim is permanently unable to claim their NFT until they restore their balance to exactly the Merkle-tree amount. If the farming window has expired and the victim has no other way to dispose of the excess tokens (e.g., the attacker refuses to take them back, or the victim is a contract that cannot easily transfer arbitrary tokens), the claim is blocked indefinitely.
Impact 2: The vulnerability extends to the getMessageHash path: the EIP-712 digest also reads balanceOf(receiver), so a victim who signed before the griefing attack will see their pre-existing signature rejected — they must re-sign, re-approve, and re-submit, with no guarantee the attacker will not grief them again before the new claim lands.
The fix replaces live balanceOf reads with a caller-supplied amount parameter that is bound to both the EIP-712 signature and the Merkle leaf. The Merkle proof still authoritatively verifies that (receiver, amount) is in the tree, so the caller cannot inflate amount. Any third-party token transfer into receiver no longer affects the claim outcome.
# 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.