Snowman Merkle Airdrop

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

Multiple Claims Using Same Signature - Loss of NFTs

Description

The SnowmanAirdrop contract is designed to allow users to claim Snowman NFTs by providing a valid Merkle proof and ECDSA signature, burning their Snow tokens in the process. The contract uses a mapping s_hasClaimedSnowman to track whether an address has claimed to prevent multiple claims. However, the claimSnowman function does not check this mapping before processing a claim, allowing users to reuse the same valid signature and Merkle proof multiple times to claim additional Snowman NFTs as long as they have sufficient Snow tokens.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SnowmanAirdrop is EIP712, ReentrancyGuard {
// @> mapping(address => bool) private s_hasClaimedSnowman;
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);
// @> Missing check for s_hasClaimedSnowman[receiver]
s_hasClaimedSnowman[receiver] = true; // @> Sets flag but too late to prevent re-claims
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
}

Risk

Likelihood:

  • Users can call claimSnowman multiple times with the same valid signature and Merkle proof whenever they acquire additional Snow tokens.

  • The issue occurs every time a user with a valid signature and sufficient Snow tokens calls claimSnowman after their first claim, as the contract does not check s_hasClaimedSnowman.

Impact:

  • Multiple Snowman NFTs are minted to the same user, exceeding the intended airdrop allocation.

Proof of Concept

after claiming a snowman nft Alice calls Snow::earnSnow to get another snow token. Satoshi then calls claimSnowman again passing AL_PROOF to claim another nft even though her nft allocation was already claimed.

function testClaimSnowmanSignatureReplay() public {
assert(nft.balanceOf(alice) == 0);
vm.startPrank(alice);
snow.approve(address(airdrop), 1);
vm.stopPrank();
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
vm.startPrank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
vm.warp(block.timestamp + 1 weeks);
vm.startPrank(alice);
snow.earnSnow();
snow.approve(address(airdrop), 1);
vm.stopPrank();
vm.startPrank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assert(nft.balanceOf(alice) == 2);
assert(nft.ownerOf(0) == alice);
}

Recommended Mitigation

add a check to make sure the user hasn't already claimed their snowman nft. this is done only if the merkle proof is successfully validated first so that users that aren't in the merkle tree can stake tokens multiple times.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
...
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
+ } else {
+ if (s_hasClaimedSnowman[receiver]) {
+ revert("already claimed"); // Already claimed
+ }
+ }
i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
...
}
Updates

Lead Judging Commences

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