Both the signature digest and the Merkle leaf bind to the live i_snow.balanceOf(receiver): getMessageHash(receiver) digests (receiver, balanceOf(receiver)), and claimSnowman sets amount = balanceOf(receiver) with leaf = keccak256(keccak256(abi.encode(receiver, amount))). balanceOf(receiver) is attacker-writable by anyone — Snow.transfer has no blocklist and Snow.earnSnow() mints for free — so anyone can invalidate a victim's pre-signed gasless claim with a 1-unit dust transfer.
Location: src/SnowmanAirdrop.sol — claimSnowman() lines 75–88, getMessageHash() lines 117–127.
Attack:
Wait for or frontrun a victim's pre-signed gasless claim message (the signature binds (receiver, leafAmount)).
Force-send 1 unit of Snow to the victim.
The victim's claim now fails — the pre-signed signature no longer matches the live-balance digest (SA__InvalidSignature).
A fresh signature cannot rescue the victim either — the Merkle leaf in the immutable tree is fixed at the old amount (SA__InvalidProof).
The victim's only recovery is to send the dust back away (restoring the leaf amount, which revives the original pre-signed signature — there is no nonce). But a griefer can repeat the dusting indefinitely, and every pre-signed / relayer-held claim message is fragile to any 1-unit balance change.
The gasless-claim flow the signature design exists for is DoS-able by anyone at ~zero cost (one earnSnow faucet call + one transfer). Frontrunnable: an attacker watching the mempool for claimSnowman submissions can invalidate them with a dust transfer in the same block. Medium because the victim self-heals by returning the balance to the leaf amount, so lockout is not permanent without a persistent griefer; but the attack is free, permissionless, and breaks the protocol's intended signature flow.
PoC file: PoC2_CanonicalMisses.t.sol (named in this submission; the form offers no file-attachment control). The test test_PoC_DustingInvalidatesClaim:
(a) alice pre-signs at balance 1; attacker dusts her to 2; claimSnowman with the pre-signed message reverts SA__InvalidSignature;
(b) fresh signature at balance 2 reverts SA__InvalidProof (leaf fixed at 1);
(c) control: alice returns the dust, original signature claims successfully.
Result: PASS.
Bind claims to a fixed allocation parameter instead of the live balance (e.g. claimSnowman(address receiver, uint256 amount, ...) with the leaf check on the passed amount), or snapshot the claimable balance at a claim-registration step. At minimum, document that any inbound Snow transfer invalidates pending claims.
# 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.