Skip to main content
Sigvex

Merkle Proof Verification

Detects insecure Merkle proof verification patterns vulnerable to second preimage attacks and proof malleability.

Merkle Proof Verification

Overview

The Merkle proof verification detector identifies custom Merkle tree implementations that do not double-hash leaf values before verification. Without double-hashing, the proof is vulnerable to second preimage attacks: an attacker can construct a valid proof for a value that was never included in the original tree by reinterpreting an internal tree node as a leaf.

Custom Merkle implementations have caused $10M+ in whitelist bypasses, unauthorized NFT minting, and airdrop theft. Contracts that roll their own verification instead of using audited libraries carry elevated risk.

Why This Is an Issue

In a standard Merkle tree, internal nodes are computed as hash(left || right). If leaf values are hashed with the same function without distinction (hash(leaf)), an attacker who knows the value of an internal node can submit it as a leaf with a shortened proof. The verifier cannot distinguish between a genuine leaf and an internal node because both are produced by the same hash function.

Double-hashing – hash(hash(leaf)) – ensures that leaf hashes are in a different domain than internal node hashes, preventing this confusion.

How to Resolve

// Before: Vulnerable -- single hash of leaf
function verify(bytes32[] calldata proof, bytes32 root, address account, uint256 amount) public pure returns (bool) {
    bytes32 leaf = keccak256(abi.encodePacked(account, amount));
    return MerkleProof.verify(proof, root, leaf);  // Second preimage risk
}

// After: Fixed -- double hash of leaf
function verify(bytes32[] calldata proof, bytes32 root, address account, uint256 amount) public pure returns (bool) {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
    return MerkleProof.verify(proof, root, leaf);
}

Examples

Vulnerable

function claimAirdrop(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    require(!claimed[msg.sender], "Already claimed");
    claimed[msg.sender] = true;
    token.transfer(msg.sender, amount);
}

Fixed

function claimAirdrop(bytes32[] calldata proof, uint256 amount) external {
    // Double-hash prevents second preimage attacks
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    require(!claimed[msg.sender], "Already claimed");
    claimed[msg.sender] = true;
    token.transfer(msg.sender, amount);
}

Sample Sigvex Output

[HIGH] merkle-proof-verification
  Potential insecure Merkle proof verification in claimAirdrop
  Location: claimAirdrop @ block 0, instruction 0
  Confidence: 0.70

  Function claimAirdrop appears to implement Merkle proof verification
  (iterative hashing with loop control flow) but does not double-hash
  leaf values before verification.

Detection Methodology

  1. Function name matching: Identifies functions with names containing merkle, proof, verify, claim, whitelist, or airdrop.
  2. Pattern matching: Detects iterative hashing patterns (multiple KECCAK256 operations with loop control flow) that characterize Merkle proof walking.
  3. Double-hash check: Examines the first few blocks for two consecutive KECCAK256 operations in the same block, which indicates the leaf is double-hashed before entering the proof loop.
  4. Proxy suppression: Skips proxy contracts where iterative hashing in the fallback/dispatch function is for selector routing, not Merkle verification.

Limitations

False positives: Functions that perform iterative hashing for non-Merkle purposes (e.g., hash chains, commit-reveal schemes) may be flagged if their name matches Merkle-related patterns. Contracts using OpenZeppelin’s MerkleProof library are suppressed. False negatives: Merkle verification performed in a library contract via DELEGATECALL is not analyzed in the calling contract’s context.

References