Snowman Merkle Airdrop

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

Race Condition Amount Manipulation (incorrect merkle tree implementation)

Author Revealed upon completion

Root + Impact

Description

  • The normal behavior is that users' Snow token balances at merkle tree creation time determine their airdrop eligibility, and they should be able to claim corresponding Snowman NFTs by providing valid proofs.

  • The specific issue is that both signature verification and merkle proof verification use the user's current balance instead of their balance at merkle tree creation time, creating a race condition where balance changes after tree creation permanently break users' ability to claim.

// Root cause in the codebase with @> marks to highlight the relevant section
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
// ... validation checks ...
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver); // @> Uses current balance
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof(); // @> Fails when current balance != merkle tree balance
}
// ... rest of function
}
function getMessageHash(address receiver) public view returns (bytes32) {
uint256 amount = i_snow.balanceOf(receiver); // @> Signature also uses current balance
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk

Likelihood:

  • high likelihood that users naturally acquire more Snow tokens through buySnow() or earnSnow() after merkle tree creation, changing their balances

  • Users transfer tokens or participate in other DeFi activities that modify their Snow balance (also high likelihood).

Impact:

  • Permanent Loss of Airdrop - Users who increase their balance after merkle tree creation can never claim their deserved NFTs

  • Denial of Service - The airdrop mechanism becomes fundamentally broken for affected users with no recovery mechanism

Proof of Concept

function testRaceConditionExploit() public {
deal(address(weth), alice, 100 ether);
vm.startPrank(alice);
weth.approve(address(snow), 100 ether);
snow.buySnow(100); //Step 1 - alice has 100 tokens when tree is created
vm.stopPrank();
// Create merkle tree with Alice's current balance (100)
merkle = new Merkle();
bytes32[] memory leaves = new bytes32[](2);
leaves[0] = keccak256(bytes.concat(keccak256(abi.encode(alice, 100))));//Step 2: Merkle Tree Creation (Snapshot Time)
leaves[1] = keccak256(bytes.concat(keccak256(abi.encode(bob, 50))));
bytes32 merkleRoot = merkle.getRoot(leaves);
bytes32[] memory merkleProof = merkle.getProof(leaves, 0);
airdrop = new SnowmanAirdrop(merkleRoot, address(snow), address(snowman));
deal(address(weth), alice, 200 ether);
vm.startPrank(alice);
weth.approve(address(snow), 200 ether);
snow.buySnow(50); // Step 3: Alice Buys More Tokens (The Race Condition) now shes got 150
vm.stopPrank();
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
bytes32 messageHash = airdrop.getMessageHash(alice); //Step 4: Alice Tries to Sign
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePrivateKey, messageHash);
// Step 5: The Claim Fails-- merkle proof for 100 tokens, signature for 150 tokens
vm.expectRevert();
vm.prank(alice);
airdrop.claimSnowman(alice, merkleProof, v, r, s);
// Step 6: Alice is Permanently Locked Out
}

Recommended Mitigation

- remove this code
+ add this code
+ // Store the expected amount in merkle tree at contract creation
+ mapping(address => uint256) private s_airdropAmounts;
+ constructor(bytes32 _merkleRoot, address _snow, address _snowman, address[] memory _recipients, uint256[] memory _amounts) EIP712("Snowman Airdrop", "1") {
+ // Store expected amounts for each recipient
+ for (uint256 i = 0; i < _recipients.length; i++) {
+ s_airdropAmounts[_recipients[i]] = _amounts[i];
+ }
+ // ... rest of constructor
+ }
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
// ... validation checks ...
- uint256 amount = i_snow.balanceOf(receiver);
+ uint256 amount = s_airdropAmounts[receiver]; // Use fixed amount from merkle tree
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
// ... rest of function
}
function getMessageHash(address receiver) public view returns (bytes32) {
- uint256 amount = i_snow.balanceOf(receiver);
+ uint256 amount = s_airdropAmounts[receiver]; // Use fixed amount for signature
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 12 hours 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.