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.
Recommended Fix
Use OpenZeppelin 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 "@openzeppelin/contracts/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.