Snowman Merkle Airdrop

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

[M-2] Claim Fails as Live Balance Used for Merkle Verification Mismatches Static Entitlement

[M-2] Claim Fails as Live Balance Used for Merkle Verification Mismatches Static Entitlement

Description

  • The SnowmanAirdrop contract enables users, defined in a Merkle tree with specific claim amounts, to receive Snowman NFTs by staking their Snow tokens. Claims can be authorized via EIP-712 signatures.

  • The claimSnowman function and its helper getMessageHash incorrectly use the user's live/current Snow token balance to determine the amount for both EIP-712 signature validation and for constructing the Merkle leaf during on-chain proof verification. This causes a mismatch if the user's live balance differs from the static amount defined for them in the Merkle tree, leading to valid claims failing.

// Contract: SnowmanAirdrop.sol
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) /* ... */ {
// ...
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) { // @> Uses getMessageHash which relies on live balance
revert SA__InvalidSignature();
}
@> uint256 amount = i_snow.balanceOf(receiver); // @> Amount for leaf is derived from live balance
@> bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); // @> Leaf constructed with live balance
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { // @> Proof for static amount fails against leaf from live balance
revert SA__InvalidProof();
}
// ...
}
function getMessageHash(address receiver) public view returns (bytes32) {
// ...
@> uint256 amount = i_snow.balanceOf(receiver); // @> Amount for signature based on live balance
return _hashTypedDataV4( /* ... SnowmanClaim({receiver: receiver, amount: amount}) ... */ );
}

Risk

Likelihood: Medium

  • Users' token balances are expected to change due to normal blockchain activities (earning, buying, transfers).

  • The claim mechanism's reliance on this live balance for critical verification data makes discrepancies with the Merkle tree's static entitlement highly probable.

Impact: Medium

  • Eligible users are unable to claim their airdropped NFTs if their live Snow balance deviates from their static Merkle tree entitlement, as Merkle proof verification will incorrectly fail.

  • The airdrop's intended distribution is impaired, diminishing its effectiveness and fairness, and resulting in a negative user experience.

Proof of Concept

1. Alice's Merkle tree entitlement: 1 Snow. Initial balance matches.

2. Alice's Snow balance increases to 2 (newLiveBalance) via snow.earnSnow().

3. Signature is generated for Alice based on newLiveBalance (2 Snow).

4. claimSnowman is called with Alice's original Merkle proof (for 1 Snow)
// and the signature (for 2 Snow).

5. Result: Signature check passes. Merkle leaf constructed with newLiveBalance (2 Snow).
// MerkleProof.verify fails as proof is for 1 Snow. Reverts SA__InvalidProof.

Add the following test to test/TestSnowmanAirdrop.t.sol :

function testClaimFailsWhenLiveBalanceDiffersFromMerkleTreeAmount() public {
uint256 merkleTreeEntitlementAmount = 1;
assertEq(snow.balanceOf(alice), merkleTreeEntitlementAmount);
vm.warp(block.timestamp + 1 weeks + 1 seconds); // Allow earning again
vm.prank(alice);
snow.earnSnow(); // Alice's balance increases to 2
uint256 newLiveBalance = snow.balanceOf(alice);
vm.prank(alice);
bytes32 digest = airdrop.getMessageHash(alice); // Uses newLiveBalance (2) for signature
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(alice);
snow.approve(address(airdrop), newLiveBalance);
vm.prank(satoshi); // Another address attempts claim for Alice
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
// AL_PROOF is for merkleTreeEntitlementAmount (1)
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}

Recommended Mitigation

Considering the issue where the user's live balance can cause discrepancies with their static Merkle tree entitlement, I suggest modifying the claim process to explicitly use the entitledAmount (the fixed amount from the Merkle tree data) as the basis for all verification steps and token operations. This involves passing the entitledAmount as a parameter to the claimSnowman function and using it consistently for EIP-712 signature generation, Merkle leaf construction, and the actual token transfer and minting amounts. This approach ensures that claim validity is tied directly to the immutable Merkle tree data, rather than a user's potentially volatile live balance.

// File: src/SnowmanAirdrop.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// ... imports ...
contract SnowmanAirdrop is EIP712, ReentrancyGuard {
// ... (errors, other struct members, variables) ...
+ error SA__InsufficientSnowBalanceForClaim();
+ error SA__AlreadyClaimed(); // Recommended from H-1 (Replay Attack)
struct SnowmanClaim {
address receiver;
- uint256 amount;
+ uint256 entitledAmount; // Use the static amount from Merkle tree
}
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ // Corrected typo (addres -> address) and field name (amount -> entitledAmount)
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 entitledAmount)");
function claimSnowman(
address receiver,
+ uint256 entitledAmount, // Pass the static entitled amount
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
+ if (s_hasClaimedSnowman[receiver]) { // Prevent re-claims (H-1 mitigation)
+ revert SA__AlreadyClaimed();
+ }
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
+ if (entitledAmount == 0) { // Check the entitlement itself
+ revert SA__ZeroAmount();
+ }
+
+ // Ensure user has enough Snow to cover their static entitlement
+ if (i_snow.balanceOf(receiver) < entitledAmount) {
+ revert SA__InsufficientSnowBalanceForClaim();
+ }
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ // Validate signature against the static entitledAmount
+ if (!_isValidSignature(receiver, getMessageHash(receiver, entitledAmount), v, r, s)) {
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver); // No longer use live balance here
- bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
+ // Construct leaf using the static entitledAmount
+ bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, entitledAmount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
- i_snow.safeTransferFrom(receiver, address(this), amount);
+ // Transfer the static entitledAmount
+ i_snow.safeTransferFrom(receiver, address(this), entitledAmount);
s_hasClaimedSnowman[receiver] = true;
- emit SnowmanClaimedSuccessfully(receiver, amount);
+ emit SnowmanClaimedSuccessfully(receiver, entitledAmount);
- i_snowman.mintSnowman(receiver, amount);
+ i_snowman.mintSnowman(receiver, entitledAmount);
}
// ... _isValidSignature remains the same ...
- function getMessageHash(address receiver) public view returns (bytes32) {
- uint256 amount = i_snow.balanceOf(receiver); // No longer use live balance
+ // getMessageHash now takes the static entitledAmount
+ function getMessageHash(address receiver, uint256 entitledAmount) public view returns (bytes32) {
return _hashTypedDataV4(
- keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
+ keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, entitledAmount: entitledAmount})))
);
}
// ... other functions ...
}
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.