Snowman Merkle Airdrop

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

`SnowmanAirdrop::claimSnowman` Uses Live On-Chain Snow token Balance which can invalidate generated Merkle Proofs.

Summary

The SnowmanAirdrop::claimSnowman() function relies on the caller’s live token balance to verify the Merkle proof. If the user's balance changes between proof generation and claim submission, the Merkle proof becomes invalid, preventing eligible users from claiming their airdrop Snowman tokens.

Description

The SnowmanAirdrop::claimSnowman() function is designed to validate Merkle proofs tied to a user’s Snow token balance. However, the amount used in proof verification is fetched live from the chain at the time of the transaction.

uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));

This design assumes the user’s balance hasn’t changed since the proof was originally generated, which is a unsafe assumption. If the user buys more Snow Tokens, transfers, or receives additional Snow tokens after generating the proof has been generated, the Merkle leaf becomes incorrect, invalidating the entire claim — even though the user was eligible.

This results in eligible users not being able to claim their airdropped Snow tokens. Furthermore, this restricts their on-chain activity with the Snowman Protocol as they cannot purchase more SnowTokens as this would invalidate their claim.

An eligible users balance may change due to:

  1. malicious intent: front-running the claim transaction to transfer tokens to the recipient

  2. User buying more snow tokens

  3. User transferring tokens in/out.

Affected Area

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)) { // -> signature verficiation
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver); //@audit: using on-chain data. This will be problematic.
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);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount); //minting the snowman
}

Impact

The impact of this vulnerability is eligible users cannot claim their airdroped Snowman NFTs. Furthermore, this vulnerability restricts users onchain activity with the Snowman Protocol as any activity that changes their balance, will invalidate their claims. e.g users cannot purchase more SnowTokens as this would mean being unable to claim their Snowman NFT.

The Impact is High due to the flawed logic breaking core protocol functionality by preventing airdrops to eligible users.
The Likelihood is also High as the value of the SnowMan NFT is based on the number of Snow tokens a user holds. There is a high chance a user would try and obtain more tokens in order to get a higher value NFT.

Proof of Concept

As proof of the validity of this issue, I have created a runnable PoC to demonstrate the issue.

Description

  1. Alice is eligble for a NFT airdrop and is included in the merkle tree

  2. Satoshi can claim on behalf of Alice

  3. MaliciousUser Bob has snow tokens and sees Satoshi's transaction claim in the mempool.

  4. They front-run the transaction by transferring 1 snow token to Alice meaning Alice now holds 2 Snow Tokens

  5. Satoshi's transaction completes but the Merkle proof is invalidated as the claimSnowman function fetches the amount from live on-chain data which creates an invalid leaf

  6. Alice cannot receive her free Snowman token unless she transfers the "excess" elsewhere

  • Note_1: the attacker can keep front running any claims as long as they have Snow tokens.

  • Note_2: see the above description for other scenarios where token balances may change.

Code

Run with: forge test --mt testInvalidatingProofs

// 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 maliciousUser = makeAddr("maliciousUser");
address satoshi;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
satoshi = makeAddr("gas_payer");
vm.deal(maliciousUser, 10 ether);
}
function testInvalidatingProofs() public {
//Confirming alice has one snow token and 0 Snowman NFTs
assert(snow.balanceOf(alice) == 1);
assert(nft.balanceOf(alice) == 0);
//Creating Signature
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Get alice's digest
bytes32 alDigest = airdrop.getMessageHash(alice);
// Alice signs a message for satoshi to use
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
//MaliciousUser sends 1 token to Alice to invalidate her proof
vm.startPrank(maliciousUser);
uint256 fee = snow.s_buyFee();
uint256 amountToBuy = 2;
snow.buySnow{value: amountToBuy * fee}(amountToBuy);
assertEq(snow.balanceOf(maliciousUser),amountToBuy);
snow.transfer(alice,amountToBuy); //Invalidating Alice proof via balance
assertEq(snow.balanceOf(alice),1 + amountToBuy);
vm.stopPrank();
// Satoshi calls claims on behalf of alice using her signed message but this will fail due to the function using live data which invalidates proof
vm.prank(satoshi);
vm.expectRevert();
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}
}

Mitigation

The recommended mitigation is to have the user pass in the expected amount in the function call instead of generating the amount using on-chain data.

In the claimSnowman function

+ function claimSnowman(address receiver, uint256 _amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{ //@dev: added the parameter `uint256 _amount`
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if(_amount == 0)revert SA__ZeroAmount();
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver, _amount), v, r, s)) { //@dev: utilzing the _amount for changed getMessageHash arguments
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver); //@dev: remove this as its the root issue
+ bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, _amount)))); // @dev: utilzing the _amount parameter
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
}

In helper function getMessageHash

+ function getMessageHash(address receiver, uint256 _amount) public view returns (bytes32) { //@dev: added the parameter `uint256 _amount`
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
- uint256 amount = i_snow.balanceOf(receiver); //@dev: remove. no longer needed
//@dev: implementing the _amount parameter
+ return _hashTypedDataV4(keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, +amount: _amount}))));
}
Updates

Lead Judging Commences

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

Invalid merkle-proof as a result of snow balance change before claim action

Claims use snow balance of receiver to compute the merkle leaf, making proofs invalid if the user’s balance changes (e.g., via transfers). Attackers can manipulate balances or frontrun claims to match eligible amounts, disrupting the airdrop.

Support

FAQs

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