Snowman Merkle Airdrop

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

Dynamic Message Digest Vulnerability Enables Signature Replay and Invalid Claims

Description

The SnowmanAirdrop::getMessageHash function generates message digests based on real-time token balances, while the SnowmanAirdrop::_isValidSignature function lacks critical ECDSA safeguards. This combination creates a fundamental vulnerability where:

  • Signatures become invalid when token balances change

  • Valid signatures can be reused after balance replenishment

  • Signature malleability allows claim duplication

  • Cross-chain replay attacks are possible

Impact:

Broken Claim Functionality: Legitimate users cannot claim after balance changes

Signature Replay Attacks: Same signature used for multiple claims

Double NFT Minting: Malleable signatures enable duplicate claims

Cross-Chain Exploits: Signatures valid on all Ethereum chains

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

Risk

Likelihood:

• Affects 100% of claims
• Triggered by normal token transfers
• Guaranteed occurrence

Impact:

• Permanent claim system failure
• Unlimited NFT inflation via replay
• Protocol economics destroyed

Proof of Concept

Scenario: Signature Lifetime Cycling
Initial State:

  • Alice holds 100 SNOW

  • Signs claim: DigestA = hash(receiver, 100)

Balance Change:

  • Transfers 100 SNOW to Bob

  • New balance: 0 SNOW

Current digest: DigestB = hash(receiver, 0)

Result: DigestA ≠ DigestB → Signature invalid

Balance Reset:

  • Alice buys 100 SNOW

New balance: 100 SNOW

Current digest: DigestC = hash(receiver, 100)

Result: DigestA == DigestC → Signature valid again!

Attack Exploitation Path:

A[Sign at 100 SNOW] --> B[Claim NFTs]
B --> C[Transfer 100 SNOW out]
C --> D[Buy 100 SNOW]
D --> E[Reuse Signature]
E --> F[Claim Again]

Recommended Mitigation

// FIXED MESSAGE STRUCTURE
struct SnowmanClaim {
address receiver;
uint256 fixedAmount; // Snapshot-based amount
uint256 nonce; // Unique claim identifier
uint256 chainId; // Cross-chain protection
}
// FIXED getMessageHash FUNCTION
function getMessageHash(
address receiver,
uint256 fixedAmount, // Snapshot amount
uint256 nonce
) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({
receiver: receiver,
fixedAmount: fixedAmount, // IMMUTABLE VALUE
nonce: nonce,
chainId: block.chainid
})
)
);
}

Snapshot-Based Amounts:

  • Use fixed Merkle tree amounts instead of live balances

  • Must match values in signed messages

Nonce Management:

mapping(address => uint256) public nonces;
function claimSnowman(...) {
uint256 nonce = nonces[receiver]++;
// ...
}

EIP-712 Domain Update:

constructor(...) EIP712("SnowmanAirdrop", "2") { // Bump version
currentChainId = block.chainid;
}

Signature Tracking:

mapping(bytes32 => bool) public usedSignatures;
Updates

Lead Judging Commences

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