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
- Function name matching: Identifies functions with names containing
merkle,proof,verify,claim,whitelist, orairdrop. - Pattern matching: Detects iterative hashing patterns (multiple
KECCAK256operations with loop control flow) that characterize Merkle proof walking. - Double-hash check: Examines the first few blocks for two consecutive
KECCAK256operations in the same block, which indicates the leaf is double-hashed before entering the proof loop. - 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.
Related Detectors
- Hash Collision – detects hash collision risks from
abi.encodePackedwith dynamic types - Signature Verification – detects improper signature checks