Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: high
Likelihood: high

Missing Claim Duplication Prevention in `claimSnowman()` Function of `SnowmanAirdrop.sol` Allowing Multiple Token Transfers and NFT Mints

Author Revealed upon completion

Root + Impact

Description

Normal behavior

The snowmanAirdrop contract is designed to let eligible users claim a Snowman NFT by verifying a Merkle proof and signing a message. The intent is that each user can only claim once using their i_snow token balance.


Issue

The contract does not implement any mechanism to prevent duplicate claims by the same user. While the contract tracks whether an address has already claimed using the s_hasClaimedSnowman mapping, it fails to check this mapping in the claimSnowman function. As a result, a user can repeatedly call the claimSnowman() function and claim multiple NFTs, potentially draining the airdrop pool.

1 function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
2 external
3 nonReentrant
4 {
5 if (receiver == address(0)) {
6 revert SA__ZeroAddress();
7 }
8 if (i_snow.balanceOf(receiver) == 0) {
9 revert SA__ZeroAmount();
10 }
11
12 if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
13 revert SA__InvalidSignature();
14 }
15
16 uint256 amount = i_snow.balanceOf(receiver);
17 bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
18
19 if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
20 revert SA__InvalidProof();
21 }
22
23 i_snow.safeTransferFrom(receiver, address(this), amount);
24
25 // MISSING check here:
26 // if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
27 // s_hasClaimedSnowman[receiver] = true;
28
29 emit SnowmanClaimedSuccessfully(receiver, amount);
30
31 i_snowman.mintSnowman(receiver, amount);
32 }

Risk

Likelihood: High

  • Any user who is part of the Merkle tree and has a valid signature can repeatedly call the function.

  • No additional constraints are enforced by the contract to prevent repeated use.

Impact: High

  • Repeated claims allow an attacker to mint unlimited NFTs using the same balance.

  • The total airdrop supply can be drained by a single malicious or unaware user

Proof of Concept

Assume the receiver has 1000 SNOW tokens and a valid Merkle proof and signature.

// Assume `receiver` is a valid user with SNOW token balance > 0
// Setup
address receiver = 0x123...; // an eligible address
bytes32[] memory proof = [...]; // valid Merkle proof for (receiver, amount)
(uint8 v, bytes32 r, bytes32 s) = signClaim(receiver, 1000); // valid EIP712 signature
// First claim: this works as expected
airdrop.claimSnowman(receiver, proof, v, r, s);
// Second claim: succeeds again, even though already claimed
airdrop.claimSnowman(receiver, proof, v, r, s);
// Third claim: succeeds again — unlimited NFT minting!
airdrop.claimSnowman(receiver, proof, v, r, s);

CopyEdit

The claimSnowman() function does not verify whether a user has already claimed, allowing malicious users to repeatedly claim Snowman NFTs by reusing valid Merkle proofs and signatures. This leads to unlimited unauthorized claims, causing direct and severe token inflation.

Recommended Mitigation

Add a check to ensure a user can only claim once, using the s_hasClaimedSnowman mapping that's already being written to.

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();
}
+ // Add a duplication check to prevent multiple claims
+ if (s_hasClaimedSnowman[receiver]) {
+ revert("Snowman already claimed");
+ }
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);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Support

FAQs

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