Skip to main content
Sigvex

Remediating Insecure Merkle Proof Verification

How to prevent second preimage attacks and proof malleability by using double-hashed leaves and audited Merkle proof libraries.

Remediating Insecure Merkle Proof Verification

Overview

Related Detector: Merkle Proof Verification

Insecure Merkle proof verification allows second preimage attacks where an internal tree node is reinterpreted as a leaf. The fix is to double-hash leaf values (hash(hash(leaf))) so that leaves and internal nodes are in different hash domains. The simplest approach is to use OpenZeppelin’s MerkleProof library with double-hashed leaves.

Use OpenZeppelin’s MerkleProof with Double-Hashed Leaves

// BEFORE: Single-hashed leaf -- vulnerable to second preimage
function claim(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
    require(MerkleProof.verify(proof, root, leaf), "Invalid");
    _distribute(msg.sender, amount);
}

// AFTER: Double-hashed leaf -- prevents second preimage
function claim(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(
        bytes.concat(keccak256(abi.encode(msg.sender, amount)))
    );
    require(MerkleProof.verify(proof, root, leaf), "Invalid");
    _distribute(msg.sender, amount);
}

Note the use of abi.encode instead of abi.encodePacked to avoid hash collisions with variable-length types.

Alternative Mitigations

Use OpenZeppelin’s multiProofVerify for Batch Claims

For batch airdrop claims, use the multi-proof variant:

import "@audited/utils/cryptography/MerkleProof.sol";

function batchClaim(
    bytes32[] calldata proof,
    bool[] calldata proofFlags,
    bytes32[] calldata leaves
) external {
    require(
        MerkleProof.multiProofVerify(proof, proofFlags, root, leaves),
        "Invalid multi-proof"
    );
    for (uint256 i = 0; i < leaves.length; i++) {
        _processLeaf(leaves[i]);
    }
}

Add Claim Tracking to Prevent Replay

Double-hashing prevents forged proofs, but you must also prevent legitimate proofs from being replayed:

mapping(address => bool) public claimed;

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

Common Mistakes

Mistake: Using abi.encodePacked with Multiple Dynamic Types

// WRONG: abi.encodePacked with two dynamic types causes collision risk
bytes32 leaf = keccak256(abi.encodePacked(name, description));
// "ab" + "c" and "a" + "bc" produce the same packed encoding

Use abi.encode (which pads to 32 bytes) instead of abi.encodePacked when encoding multiple variable-length values.

Mistake: Single Hash on Tree Construction but Double Hash on Verification

// Tree was built with: leaf = keccak256(abi.encode(addr, amount))
// Verification uses: leaf = keccak256(bytes.concat(keccak256(abi.encode(addr, amount))))
// Mismatch: no valid proof will verify

The hashing scheme must be identical between tree construction (off-chain) and on-chain verification.

Mistake: Rolling Your Own Merkle Verification Loop

// RISKY: Custom implementation may have subtle ordering bugs
function verify(bytes32[] calldata proof, bytes32 leaf) internal view returns (bool) {
    bytes32 hash = leaf;
    for (uint256 i = 0; i < proof.length; i++) {
        hash = keccak256(abi.encodePacked(hash, proof[i]));  // Wrong ordering
    }
    return hash == root;
}

OpenZeppelin’s implementation sorts the pair before hashing (commutativeKeccak256) to prevent proof malleability. Use the library instead.

References