## Description
In `SnowmanAirdrop.sol`, the function `claimSnowman()` tracks whether a recipient has claimed their airdrop by
setting `s_hasClaimedSnowman[receiver] = true;` on line 94.
However, the function completely omits verifying whether `s_hasClaimedSnowman[receiver]` is already `true` prior
to executing the claim.
The intended behavior of an airdrop distribution contract is that each eligible recipient may only claim their
allocated tokens once. Because the contract fails to validate the claim status, any recipient who re-acquires
`Snow` tokens can reuse their existing Merkle proof and ECDSA signature to claim additional `Snowman` NFTs
indefinitely.
```solidity
// Root cause in the codebase with @> marks to highlight the relevant section:
// File: src/SnowmanAirdrop.sol
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
@> // Missing check: if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
@> s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
Likelihood: High
Impact: High
The contract sets s_hasClaimedSnowman[receiver] = true after a successful claim, but never enforces this check
before executing a claim. Consequently, an eligible user who replenishes their Snow balance can execute
claimSnowman a second time with the identical Merkle proof and signature.
Add the following test to test/TestSnowmanAirdrop.t.sol:
function test_double_claim_allowed_due_to_missing_check() public {
// 1. Alice performs her first valid claim
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assertEq(nft.balanceOf(alice), 1);
assertTrue(airdrop.getClaimStatus(alice));
// 2. Alice acquires 1 more Snow token (or has one transferred to her)
deal(address(snow), alice, 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// 3. Alice claims a second time using the exact same signature and Merkle proof
// This call should revert if claim status were validated, but it succeeds
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// 4. Alice now possesses 2 NFTs from a single airdrop allocation
assertEq(nft.balanceOf(alice), 2);
}
Define a custom error SA__AlreadyClaimed() and enforce a check at the beginning of claimSnowman() to verify that
s_hasClaimedSnowman[receiver] is false before proceeding with the claim.
--- a/src/SnowmanAirdrop.sol
+++ b/src/SnowmanAirdrop.sol
@@ -32,6 +32,7 @@ contract SnowmanAirdrop is EIP712, ReentrancyGuard {
error SA__InvalidProof(); // Thrown when the provided Merkle proof is invalid
error SA__InvalidSignature(); // Thrown when the provided ECDSA signature is invalid
error SA__ZeroAddress();
error SA__ZeroAmount();
+ error SA__AlreadyClaimed();
// >>> TYPE
@@ -76,6 +77,9 @@ contract SnowmanAirdrop is EIP712, ReentrancyGuard {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
# 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.