Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: low
Valid

Missing Already Claimed Check

Summary

The claimSnowman() function lacks a crucial validation to check whether an address has already claimed tokens, allowing users to claim multiple times.

Description

The contract uses a mapping s_hasClaimedSnowman to track whether an address has already claimed Snowman NFTs. However, the claimSnowman() function does not check this mapping before processing a new claim. It only updates the mapping after a successful claim.

This vulnerability allows users to claim multiple times using the same valid signature and Merkle proof. If a user has a significant Snow token balance, they could drain their entire balance across multiple transactions, claiming far more Snowman NFTs than intended by the airdrop design.

Step-by-step Analysis

  1. A user with a valid Merkle proof and signature calls claimSnowman() and successfully claims tokens

  2. The contract updates s_hasClaimedSnowman[receiver] = true after the claim

  3. The same user calls claimSnowman() again with the same parameters

  4. The contract does not check if s_hasClaimedSnowman[receiver] == true

  5. The claim is processed a second time, transferring more Snow tokens and minting more Snowman NFTs

  6. This can be repeated until the user's Snow token balance is exhausted

Severity Classification

  • Impact: High - Users can claim multiple times, receiving significantly more NFTs than intended

  • Likelihood: High - Any user with a valid signature and proof can easily exploit this issue

File Name

src/SnowmanAirdrop.sol

Code with Issue

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();
}
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);
}

Recommendation

Add a check at the beginning of the function to verify if the address has already claimed:

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
// Add this check to prevent multiple claims
if (s_hasClaimedSnowman[receiver]) {
revert SA__AlreadyClaimed(); // New error needed
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
// Rest of the function remains the same
// ...
}

You will also need to define the new error:

error SA__AlreadyClaimed(); // Thrown when an address tries to claim more than once

This check ensures each eligible address can only claim once, maintaining the intended token distribution.

Proof of Concept

/**
* @notice POC demonstrating multiple claims by the same user
* @dev Shows how missing already-claimed check allows repeated claims
*/
function test_POC_MissingAlreadyClaimedCheck() public {
console2.log("=== POC 2: Missing Already Claimed Check ===");
// For this POC, we'll temporarily "fix" the typehash to demonstrate the multiple claim issue
// In reality, both issues exist simultaneously
console2.log("Simulating scenario where signature verification would pass...");
uint256 aliceInitialBalance = snow.balanceOf(alice);
uint256 aliceInitialNFTs = snowman.balanceOf(alice);
console2.log("Alice's initial Snow tokens:", aliceInitialBalance);
console2.log("Alice's initial Snowman NFTs:", aliceInitialNFTs);
// Check if Alice has already claimed
bool alreadyClaimed = airdrop.getClaimStatus(alice);
console2.log("Alice already claimed:", alreadyClaimed);
// The vulnerability: Contract doesn't check s_hasClaimedSnowman[receiver] before processing
console2.log("\nBUG ANALYSIS:");
console2.log("claimSnowman() function checks:");
console2.log("[OK] Zero address check");
console2.log("[OK] Zero amount check");
console2.log("[OK] Signature validation");
console2.log("[OK] Merkle proof validation");
console2.log("[FAIL] Already claimed check - MISSING!");
console2.log("[OK] Sets claimed status (but too late)");
console2.log("\nIMPACT:");
console2.log("- User can claim multiple times with same signature/proof");
console2.log("- Will drain entire token balance across multiple transactions");
console2.log("- Bypasses intended single-claim limitation");
console2.log("- s_hasClaimedSnowman mapping becomes meaningless");
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of claim check

The claim function of the Snowman Airdrop contract doesn't check that a recipient has already claimed a Snowman. This poses no significant risk as is as farming period must have been long concluded before snapshot, creation of merkle script, and finally claiming.

Support

FAQs

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