Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Live balanceOf(receiver) in claimSnowman lets anyone grief/invalidate claims via dusting

Live balanceOf(receiver) in claimSnowman lets anyone grief/invalidate claims via dusting

Summary

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.

Vulnerability Details

Location: src/SnowmanAirdrop.sol — claimSnowman() lines 75–88, getMessageHash() lines 117–127.

Attack:

  1. Wait for or frontrun a victim's pre-signed gasless claim message (the signature binds (receiver, leafAmount)).

  2. Force-send 1 unit of Snow to the victim.

  3. The victim's claim now fails — the pre-signed signature no longer matches the live-balance digest (SA__InvalidSignature).

  4. 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.

Impact

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.

Proof of Concept

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.

Recommendation

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.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] DoS to a user trying to claim a Snowman

# 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 ... } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!