Each Merkle leaf represents a fixed recipient address and a fixed claim amount determined when the Merkle tree is generated.
The claim signature should therefore authorize that fixed amount. Instead, SnowmanAirdrop derives the amount from the recipient's current Snow balance at execution time.
getMessageHash() signs the mutable balance:
claimSnowman() then reads the balance again and uses it to create the Merkle leaf:
Snow is a transferable ERC20 token. An attacker can therefore send even one minimal Snow unit to an eligible recipient after the recipient signs their claim but before the transaction is executed.
This changes the recipient's live balance and causes two failures:
The existing signature becomes invalid because getMessageHash() now includes the modified balance.
A new signature for the modified balance still fails because the Merkle tree contains the recipient's original fixed allocation.
An attacker can monitor the public mempool and repeatedly send Snow to the recipient before each claim attempt, preventing the entitled recipient from claiming.
The attacker must obtain a minimal amount of Snow.
Snow is freely transferable through the inherited ERC20 transfer() function.
The attacker must identify an eligible recipient and transfer Snow before their claim executes.
To maintain the denial after the recipient removes the unwanted balance, the attacker must monitor and repeat the attack.
Because the attack requires preparation and transaction timing, the likelihood is Medium.
An eligible recipient's valid signature is invalidated.
Re-signing does not solve the problem because the new amount no longer matches the fixed Merkle leaf.
The recipient cannot claim their entitled Snowman NFT while the poisoned balance remains.
Repeated front-running can indefinitely censor a targeted recipient's claim.
The attacker does not need access to the recipient's private key or approval.
The impact to the affected recipient is High.
Create test/ClaimBalanceDoS.t.sol:
Run:
The test demonstrates that an unsolicited Snow transfer invalidates the original signature. Even after Alice signs the new balance, the claim fails because the modified amount does not match the fixed Merkle leaf.
Pass the fixed claim amount as an explicit parameter instead of deriving it from the recipient's mutable live balance.
The same explicit amount must be included in:
The EIP-712 signature
The Merkle leaf
The token transfer
The NFT mint
This prevents unsolicited token transfers from modifying the signed or Merkle-verified claim amount.
# 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.