A Merkle tree second preimage attack works when leaf and internal node hashes use one function with no domain separation. An attacker submits two concatenated sibling digests as a leaf; the verifier hashes them, lands on the internal node, and returns true for bytes never logged. RFC 6962 fixes it: leaves H(0x00 || leaf), nodes H(0x01 || left || right).
Ross Jones — Founder, The Hopium Lab. Last modified 21 July 2026.
Almost everything ranking for this problem is Solidity, and almost all of it is about airdrop claims. The framing here is audit logs: a tamper-evident log is where the flaw surfaced during development of evidence infrastructure, caught by a test before anything shipped. Node-as-leaf is the failure where a verifier accepts an internal node's preimage as a leaf and returns a valid inclusion proof for bytes that were never logged as a record.
What can an attacker actually forge?
An attacker forges a valid inclusion proof for bytes they do not choose — typically the 64-byte concatenation of two sibling digests under SHA-256. Verifiers that accept a precomputed 32-byte leaf hash instead of the leaf bytes have a second variant: hand them an internal digest and no hashing happens at all. Dean Jerkovich, in the write-up most implementers are pointed at (21 February 2018), states the ceiling plainly: the attacker controls neither the forged value nor its format. The same flaw filed against the most widely used Merkle proof library on the EVM (OpenZeppelin issue #3091, 7 January 2022) was assessed as likely small real-world impact.
Node-as-leaf is not a remote log-forgery exploit, and the preconditions are what make that true. What breaks is soundness.
A verifier that returns true for something that is not a leaf is not a weak verifier. It is a verifier whose contract is a lie.
Whether broken soundness becomes chosen-record forgery depends on leaf encoding. Under a permissive schema — CBOR or protobuf, unknown fields ignored — pseudorandom bytes can parse into something a downstream consumer treats as a record. An attacker who can influence tree contents widens the gap.
What does the failing test look like?
The failing test builds a four-leaf log, proves a real record, then proves a 64-byte string that was never a record. Write it before the fix, watch it print True, and the bug stops being a paragraph you skimmed.
# second_preimage_test.py — python 3, stdlib only
#
# PRECONDITIONS, stated so nobody calls the demo rigged:
# 1. >= 4 leaves in this balanced demo (>= 3 in CT's unbalanced construction).
# 2. The log accepts leaves of arbitrary length, including 64 bytes.
# 3. Leaves and internal nodes are hashed by the same function, untagged.
# 4. Internal digests are public — anyone holding one proof already has them.
# 5. SHA-256 is not broken. Nothing here attacks the hash function.
import hashlib
def H(b: bytes) -> bytes:
return hashlib.sha256(b).digest()
def leaf_hash(data: bytes) -> bytes:
return H(data) # BUG
def node_hash(left: bytes, right: bytes) -> bytes:
return H(left + right) # BUG: same function, untagged
def verify(leaf: bytes, proof, root: bytes) -> bool:
h = leaf_hash(leaf)
for sibling, sibling_on_right in proof:
h = node_hash(h, sibling) if sibling_on_right else node_hash(sibling, h)
return h == root
records = [b"evt-1", b"evt-2", b"evt-3", b"evt-4"]
L = [leaf_hash(r) for r in records]
n01, n23 = node_hash(L[0], L[1]), node_hash(L[2], L[3])
root = node_hash(n01, n23)
honest_proof = [(L[1], True), (n23, True)]
assert verify(records[0], honest_proof, root) is True
forged_leaf = L[0] + L[1] # 64 bytes. Never logged.
forged_proof = [(n23, True)]
print("forged leaf accepted:", verify(forged_leaf, forged_proof, root))
print("proof lengths, honest:", len(honest_proof), " forged:", len(forged_proof))
Proof length is the tell. Every honest record in a four-leaf log sits at depth two and needs two sibling hashes; the forgery needs one, because it starts a level up. A verifier blind to depth cannot see the difference. Neither can the auditor reading its output.
Why do RFC 6962's prefixes fix it?
Tagging fixes it by making the leaf-hash inputs and the node-hash inputs disjoint sets: every leaf preimage begins 0x00, every internal preimage begins 0x01, so no digest can be simultaneously an honestly computed leaf hash and an honestly computed node hash.
def leaf_hash(data: bytes) -> bytes:
return H(b"\x00" + data) # RFC 6962 section 2.1
def node_hash(left: bytes, right: bytes) -> bytes:
return H(b"\x01" + left + right) # RFC 6962 section 2.1
Two lines. Replace the two # BUG definitions in place, rerun, and the forgery prints False: H(0x00 || L0 || L1) and H(0x01 || L0 || L1) are different digests, and only the second is the root's actual left child. RFC 6962 (June 2013, section 2.1) gives the reason in a parenthetical most implementers scroll past: "This domain separation is required to give second preimage resistance." RFC 9162 (December 2021, section 2.1.1) carries the identical construction with a hash-agile HASH.
The partition is of the hash function's input domain, not its output space.
Domain separation does not make the hash stronger. It removes a structural attack that exists even when the hash is perfect.
Residual security reduces to the strength of H itself, which is where you wanted it. RFC 8554 (April 2019, section 7.1) lands in the same place for LMS hash-based signatures: D_LEAF = 0x8282, D_INTR = 0x8383, plus a tree identifier and the node index bound into every hash. Certificate Transparency and LMS were written by different working groups for different problems and reached the same leaf/node tagging decision — standards-body consensus, not a CT quirk.
Does sorted-pair hashing fix it?
Sorted-pair hashing does not fix it. OpenZeppelin's MerkleProof sorts each pair before hashing and was vulnerable anyway. Sorting decides operand order; whether a digest can be reinterpreted as a record is a different question.
Sorted-pair hashing neither causes nor cures the node-as-leaf failure. Sorting decides operand order; it decides nothing about node typing, and people keep filing it under the wrong heading.
The mitigation OpenZeppelin actually shipped is double-hashing the leaf, so leaf preimages are always 32 bytes while internal preimages are always 64. Effective, and separation by accident of size rather than explicit intent — it holds only while every call site actually double-hashes. One helper that hashes a leaf once, or an API that accepts a precomputed 32-byte leaf hash, restores the ambiguity with no visible change to the tree. An explicit tag byte survives a call site that forgets.
Is tagging the only defence?
Tagging is not the only defence, and claiming otherwise loses any reader who has implemented Certificate Transparency. RFC 9162 section 2.1.3.2 takes leaf_index and tree_size as explicit verifier inputs, fails when leaf_index >= tree_size, and terminates only when its size counter reaches zero — which pins proof length to the honest depth for that index. A short forged path fails on structure alone, tags or no tags.
| Defence | Stops node-as-leaf? | What it relies on | Where it breaks |
|---|---|---|---|
Tag bytes, 0x00 / 0x01 (RFC 6962 §2.1) | Yes | Disjoint input sets, enforced inside the hash | One path tagged and the other not — and the usual "fix" is to delete the tag rather than add it |
Double-hashed leaf, H(H(leaf)) | In practice | Leaf preimage 32 bytes, internal preimage 64 | Any path that hashes a leaf once, or accepts a precomputed leaf hash |
| Sorted-pair (commutative) hashing | No | Operand ordering only | Never stops it; separately introduces proof malleability |
Binding leaf_index and tree_size (RFC 9162 §2.1.3.2) | Yes, structurally | Proof length pinned to honest depth | Every call site must pass index and size, and pass them honestly |
| Node index inside the hash (RFC 8554 §5.3) | Yes | Each digest committed to its position, not just its type | More state to thread correctly; LMS also binds a tree identifier |
| Fixed-width leaf encoding | Partly | Leaf width never equals 2N | The day somebody adds a variable-length field |
Tagging earns its place as the default because it makes the tree sound under a weaker verifier contract. Position binding requires trusting every call site; a tag byte requires trusting the hash. Certificate Transparency tags the hashes and binds position in the verifier; LMS tags the hashes and binds position inside the hash itself.
Where does the same ambiguity hide inside a leaf?
Field concatenation inside a leaf is a separate defect class with the same shape: H("alice" || "bob123") equals H("alice1" || "bob23"), so two different records commit to one digest. NIST SP 800-185 (December 2016) is the citation: TupleHash encodes a sequence of strings unambiguously via encode_string and its left_encode/right_encode length prefixes, and NIST's own motivating example is a banking application that concatenates fields naively.
RFC 6962's node hash does not length-prefix its operands, and does not need to: both are fixed-width digests, so 0x01 || N || N parses uniquely. Its leaf is a different matter — MerkleTreeLeaf is TLS-serialised, and TLS presentation language length-prefixes every variable-length vector. Cite RFC 6962 for the node construction and NIST SP 800-185 for unambiguous field encoding; swapping them is the error a cryptographer flags first.
Which similar-sounding bugs is this not?
Three failures share the vocabulary and none of the mechanism. Bitcoin's CVE-2012-2459 is root malleability from duplicating the final node on odd-count levels; Certificate Transparency never duplicates a node. Kelsey–Schneier (2005) is a long-message second preimage attack on the Merkle–Damgård hash function construction, not on how a tree types its nodes. And RFC 6962 defines MTH({}) as the hash of the empty string, with no prefix — a deliberate un-tagged special case worth knowing before somebody points at it.
Terminology: RFC 6962 section 2.1.1 says Merkle audit path, RFC 9162 section 2.1.3 says Merkle inclusion proof. RFC 9162 obsoletes RFC 6962; the CT logs browsers enforce today are still v1.
What should you check in your own log?
Check six things, on both the write path and the verify path, because asymmetric tagging is the failure that produces green tests and forged proofs.
NODE-AS-LEAF AUDIT — six checks — v1.0 (21 July 2026)
The Hopium Lab. Supports a conformity case. Does not confer one.
Node-as-leaf is the failure where a verifier accepts an internal node's
preimage as a leaf and returns a valid inclusion proof for bytes that
were never logged as a record.
[ ] Leaf hashing and node hashing use different tag bytes
(RFC 6962: 0x00 leaf, 0x01 node). Grep both paths, not just the writer.
[ ] Tagging is symmetric: writer and verifier apply identical tags.
Verify a proof produced by a DIFFERENT implementation to prove it.
[ ] Verifier rejects any proof whose length != the honest depth for the
claimed index. If the verifier never sees an index, that is the finding.
[ ] Leaf preimage is unambiguous across fields: length-prefixed or
TupleHash-style encoding, never bare concatenation (NIST SP 800-185).
[ ] Odd-node handling never duplicates the last node (CVE-2012-2459).
[ ] A failing test exists in the repo that forges an internal node as a
leaf and asserts False. Delete the tag bytes; the test must go red.
The failing-test check is the only one of the six that keeps working after you leave. A comment saying "domain separated per RFC 6962" is deleted by whoever refactors the hashing helper in eighteen months; a red test is not.
Node-as-leaf is one line in my Article 12 logging checklist and deserved a whole post, because it is the rare integrity bug that takes two lines to fix and stays invisible for years if the fix is skipped. Write the failing test first. Watching a verifier confidently accept 64 bytes of nothing beats any explanation of why it does.
Ross Jones, Founder, The Hopium Lab. Last modified 21 July 2026. Engineering guidance, not legal advice.