Snowman Merkle Airdrop

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

Fragile Signature Hash

Author Revealed upon completion

Root + Impact

Description

In EIP-712, signatures are meant to be generated off-chain for a fixed message, then validated on-chain by regenerating the hash and verifying the signer using ECDSA.recover.
In this contract, the getMessageHash() function uses i_snow.balanceOf(receiver) at the time of signing and again at the time of claiming, expecting them to match exactly.

This coupling of message content with mutable state (token balance) violates the design principle of EIP-712, which assumes immutable message contents.

  • The contract signs a hash that depends on balanceOf(receiver). Since token balances can change, the off-chain signature may no longer match when submitted.

  • Using balanceOf() in signed message ties signature to mutable state.

  • By embedding balanceOf(receiver) into the signed payload, a user’s off‑chain signature becomes invalid if their token balance changes between signing and claiming.

// Location:
function getMessageHash(address receiver) public view returns (bytes32) {
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH,
SnowmanClaim({ receiver: receiver, amount: amount })
))
);
}

Risk

Likelihood:

  • Reason 1 // Legitimate users may get their signature rejected if their balance changes.

  • Reason 2 // Users who move or spend tokens after signing will experience signature mismatches.

Impact:

  • Impact 1 Legitimate users may encounter signature‑mismatch errors, leading to poor UX and abandoned claims.

  • Impact 2 Legitimate users will often fail to claim if their balance changes in between signing and submitting the claim.

Proof of Concept

// Signature generated when balance was 100
bytes32 hash1 = snowmanAirdrop.getMessageHash(user);
bytes signature = sign(hash1, signerKey);
// Later: balance changed
i_snow.transfer(other, 50);
// New hash generated
bytes32 hash2 = snowmanAirdrop.getMessageHash(user);
// Now signature is invalid
snowmanAirdrop.claimSnowman(user, proof, v, r, s); // Fails due to mismatch

Recommendoned Mitigati

- remove this code
function getMessageHash(address receiver) public view returns (bytes32) {
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({ receiver: receiver, amount: amount })
))
);
}
+ add this code
-function getMessageHash(address receiver) public view returns (bytes32) {
- uint256 amount = i_snow.balanceOf(receiver);
+function getMessageHash(address receiver, uint256 amount) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(
MESSAGE_TYPEHASH,
- SnowmanClaim({ receiver: receiver, amount: amount })
+ SnowmanClaim({ receiver: receiver, amount: amount })
))
);
}
// Off‑chain: Generate and sign the exact amount used in the Merkle tree rather than querying live balances.
Updates

Lead Judging Commences

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