cld-toys › Toys › merkle-tree

Commentary: merkle-tree

A valid inclusion proof for a block that was never in the file — forged with two concatenations and a list slice, no cryptography. A study guide for merkle.py.

merkle-tree/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with merkle.py open beside you. merkle.py is the toy itself (156 lines, two classes); demo.py runs the same three-act script against both; test_merkle.py pins the failures so this page can't rot. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0), arm64 Apple Silicon, Python 3.15.0a8, stdlib only (hashlib). There is no clock and no randomness anywhere in this toy, so every digest on this page is byte-identical on every run — I ran demo.py twice and diffed: no output difference.
cd merkle-tree
python3 demo.py         # the aha (§6)
python3 test_merkle.py  # pins the two failures this page describes
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy implements two Merkle trees side by side — the naive one you get from the usual whiteboard explanation ("hash the blocks, hash pairs of hashes, repeat"), and RFC 6962's, the one Certificate Transparency actually deploys. They differ in two places totalling about four lines. The demo runs the same script against both.

The mechanism is the inclusion proof: given a 32-byte root you trust, and log n sibling hashes, decide whether a block you were just handed is part of the committed data — without holding the data.

The aha is what the root does not commit to. Against the naive tree you can build a valid inclusion proof for a block that was never in the file, using no cryptography at all: two concatenations and a list slice. It verifies against the real, honest root.

By the end you should be able to:


2. The problem this mechanism exists to solve

You have a large collection — a file split into blocks, a block of Bitcoin transactions, every TLS certificate a CA has ever issued — and someone who only holds a 32-byte digest of it wants to check one member.

Hashing the whole collection into a single digest gives you integrity but no selectivity: to check one block you need all of them. Hashing each block separately gives you selectivity but no compactness: the verifier now stores n digests, which is the problem you started with.

A Merkle tree is the standard resolution. One digest at the top; log n digests to check any member. That buys three things at once, and real systems want different ones:

The competing goals that make more than one design defensible:


3. Background you need

None of this is deep, but the commentary below leans on it hard.

ConceptWhere it's used hereOne source
Domain separation leaf_hash vs. node_hash, merkle.py:106-111 — the one-byte prefix that is the whole fix RFC 6962 §2.1
Ambiguous concatenation node_hash glues two 32-byte digests with no delimiter, merkle.py:52 — so a 64-byte string is indistinguishable from a digest pair Attacking Merkle trees with a second preimage attack
Second-preimage resistance What SHA-256 gives you, and what it doesn't rescue you from when the input format is ambiguous Merkle tree § Second preimage attack
Fixed-size chunking demo.py:14-24 — blocks are 64 bytes, exactly two SHA-256 digests Merkle tree
Duplicating an odd level merkle.py:40, the one line marked as the CVE Bitcoin CVEs § CVE-2012-2459
RFC 6962's split rule TaggedTree._split, merkle.py:113-119 — largest power of two strictly less than n RFC 6962 §2.1

The two that carry the result are the first two. Domain separation is the fix; ambiguous concatenation is the hole it fixes. If you only internalise one row, take the second: SHA-256 is not broken here. Nothing on this page involves finding a hash collision. The attack works because a 64-byte string of bytes and a pair of 32-byte digests are the same thing, and the naive hashing rule never records which one it meant.


4. The mental model

Before any code. Four 64-byte blocks, hashed pairwise up to one root. These are the real digests from demo.py, truncated to 8 hex characters:

NAIVE — leaf hashes and internal hashes are the same function of bytes. root = H( n0 ‖ n1 ) fff2e8dd ┌────────┴────────┐ n0 = H(l0‖l1) n1 = H(l2‖l3) fb235ee5 d2030d4e ┌──────┴──────┐ ┌──────┴──────┐ l0 = H(blk0) l1 = H(blk1) l2 = H(blk2) l3 = H(blk3) 00463edd 68204a56 f19fcae4 d60bf5bd │ │ │ │ blk0 blk1 blk2 blk3 (64 B) (64 B) (64 B) (64 B) l0 ‖ l1 is 64 bytes. So is a block. H(l0 ‖ l1) is written n0 above — but it is also, byte for byte, leaf_hash of the 64-byte "block" l0 ‖ l1. Nothing in a digest records the level it came from.

That last line is the whole toy. An inclusion proof is a fold: start at a leaf, absorb siblings, land on the root. The verifier is handed a starting value and a list of steps, and it has no way to check how far down the starting value was supposed to be. So the attacker starts higher up:

what the verifier is told what is actually there block = l0 ‖ l1 (64 bytes) an internal node's two children proof = [ R : n1 ] the level-1 sibling fold: H( leaf_hash(block) ‖ n1 ) = H( n0 ‖ n1 ) = root ✓ Proof length 1, not 2. Nobody checked.

RFC 6962's fix is to put the level into the hash — one byte, 0x00 for a leaf and 0x01 for an internal node — so the two roles can never produce the same digest.


5. Reading the source

156 lines, two classes, one of which is four methods of the other. Read it in this order.

5.1 The whole difference, stated in the module docstring

merkle.py · lines 11–15
  NaiveTree   leaf = H(block)          node = H(left || right)
  TaggedTree  leaf = H(0x00 || block)  node = H(0x01 || left || right)

  NaiveTree   an odd level duplicates its last node to make a pair
  TaggedTree  an n-leaf tree splits at the largest power of two below n

Two differences, four lines of code, and it is worth being clear now that they are independent fixes for independent bugs. The tags fix the forgery (§6.2). The split rule fixes the root collision (§6.3). Neither covers for the other, which I checked rather than assumed — §7.3 has the 2×2.

Everything else in TaggedTree is machinery to express those two changes.

5.2 NaiveTree.__init__ — building the levels, and the CVE line

merkle.py · lines 33–44
    def __init__(self, blocks):
        if not blocks:
            raise ValueError("a Merkle tree needs at least one block")
        level = [self.leaf_hash(b) for b in blocks]
        self.levels = []
        while len(level) > 1:
            if len(level) % 2:
                level = level + [level[-1]]   # the CVE-2012-2459 line
            self.levels.append(level)
            level = [self.node_hash(level[i], level[i + 1])
                     for i in range(0, len(level), 2)]
        self.levels.append(level)

Note self.leaf_hash(b) and self.node_hash(...) — dispatched through self, not called as NaiveTree.leaf_hash. That single choice is why TaggedTree can inherit this entire constructor's logic while replacing its hashes, and why verify gets written once (§7.6).

self.levels is a list of levels, bottom-up, and the whole tree is materialised eagerly. A production tree stores only what it needs and recomputes the rest; here, keeping every level is what makes proof() a three-line loop instead of a recursion, and it makes the tree inspectable, which §6.3 uses.

The commented line is the famous one. If a level has an odd number of nodes you cannot pair them all, and you must do something. Duplicating the last node is the cheapest something: no new hash function, no special empty value, no change to the pairing loop. Bitcoin does exactly this. What it costs is that the padded level [a, b, c, c] is now indistinguishable from a genuine 4-element level [a, b, c, c] — and there is a real block list that produces one. §6.3.

Note also if not blocks: raise. An empty tree has no natural root, and the alternatives (return H(""), return 32 zero bytes) both create a root that some other input might also produce. Refusing is the honest option.

5.3 The two hashes, and the walk that collects siblings

merkle.py · lines 46–52
    @staticmethod
    def leaf_hash(block):
        return hashlib.sha256(block).digest()

    @staticmethod
    def node_hash(left, right):
        return hashlib.sha256(left + right).digest()

Six lines, and the entire vulnerability is in them. leaf_hash is sha256(x) for a 64-byte x. node_hash is sha256(left + right) — which, since digests are 32 bytes, is sha256(x) for a 64-byte x. They are the same function. The names are the only thing distinguishing a leaf from an internal node, and names don't survive into the digest.

merkle.py · lines 58–67
    def proof(self, index):
        """The sibling digests needed to recompute the root from block
        `index`, bottom level first.
        """
        path = []
        for level in self.levels[:-1]:
            sibling = index ^ 1
            path.append((level[sibling], RIGHT if index % 2 == 0 else LEFT))
            index //= 2
        return path

index ^ 1 flips the low bit: even index → its right neighbour, odd index → its left. index //= 2 is the move to the parent. Two integer ops per level, no tree traversal, because the level lists are already laid out in order.

The side tag matters, and I checked how much: a verifier that ignored it and always folded H(running ‖ sibling) still verifies blk0 (whose siblings all sit on the right) and fails on blk3 (whose siblings all sit on the left):

blk0 (all siblings right) as written True side-ignoring True blk3 (all siblings left) as written True side-ignoring False

So side is load-bearing, but it is not a security control — it is the index, re-encoded. LEFT/RIGHT are the strings "L"/"R" (merkle.py:23-24) rather than booleans purely so the printed proofs in §6 read as R:d2030d4e.

5.4 verify — and the argument that is deliberately missing

merkle.py · lines 69–83
    @classmethod
    def verify(cls, root, block, proof):
        """Does `block` sit under `root`, given `proof`?

        Note what is NOT an argument: the number of leaves, or the expected
        proof length. The verifier folds whatever it is handed and compares.
        See commentary section 5.4 — this omission is the attack surface.
        """
        running = cls.leaf_hash(block)
        for sibling, side in proof:
            if side == LEFT:
                running = cls.node_hash(sibling, running)
            else:
                running = cls.node_hash(running, sibling)
        return running == root

This is the subsection the source docstring points at, so read it slowly.

The omission is a design decision, not an oversight. verify takes a root, a block, and a proof. It does not take n, or the expected depth, and it will happily fold a proof of length 0, 1, or 40. That is written down in the docstring on purpose, because the honest version of this toy's claim is not "here is a bug I found" — it is "here is the interface almost every tutorial gives you, and here is what that interface cannot check."

Ask what verify would need in order to reject a shortened proof. It would need to know how tall the tree is, which means knowing n, which means n has to reach the verifier over an authenticated channel — and the verifier's whole selling point was that it holds 32 bytes and nothing else. So the omission is exactly the thing that makes an inclusion proof cheap. §7.2 is about what pinning the length would buy, and what it wouldn't.

Second thing to notice: cls.leaf_hash(block) — the fold starts by applying the leaf hash. Under NaiveTree that is sha256(block), identical to what node_hash does to a 64-byte input. Under TaggedTree it is sha256(b"\x00" + block), which no internal node can ever equal. The one-line fix lives entirely inside this one call.

Third: verify never looks at len(block). Blocks being 64 bytes is a convention of demo.py, not an invariant merkle.py enforces — see §7.5, where that turns out to matter less than you'd expect and more than you'd like.

5.5 TaggedTree — the two changes

merkle.py · lines 105–119
    @staticmethod
    def leaf_hash(block):
        return hashlib.sha256(b"\x00" + block).digest()

    @staticmethod
    def node_hash(left, right):
        return hashlib.sha256(b"\x01" + left + right).digest()

    @staticmethod
    def _split(n):
        """RFC 6962 section 2.1: k is the largest power of two strictly less
        than n. So a 5-leaf tree is a perfect 4-leaf tree beside a 1-leaf
        tree, never a padded 8-leaf one.
        """
        return 1 << ((n - 1).bit_length() - 1)

These are RFC 6962 §2.1 transcribed. The RFC writes them as

RFC 6962 §2.1 MTH({d(0)}) = SHA-256(0x00 || d(0))
MTH(D[n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n]))

with k "the largest power of two smaller than n (i.e., k < n <= 2k)". The RFC's own justification for the prefixes, restated in its successor RFC 9162, is one sentence: "this domain separation is required to give second preimage resistance."

_split is 1 << ((n - 1).bit_length() - 1), which is the largest power of two strictly below n. The - 1 before .bit_length() is what makes it strict: for n = 8, (8-1).bit_length() is 3, giving 4, not 8. Get that wrong and _subtree(0, 8) splits into (0,8) and (8,8) and recurses forever — I wrote the broken version, and it raises RecursionError on the first 8-leaf tree.

The consequence of splitting instead of padding: an odd count never duplicates anything, so no two distinct block lists can produce the same level. A 5-leaf tree is a perfect 4-leaf tree next to a lone leaf, with a proof of length 3 for the first four blocks and length 1 for the fifth. Non-uniform, on purpose — §7.2.

5.6 _subtree — the tree as a memoised recursion

merkle.py · lines 121–129
    def _subtree(self, lo, hi):
        if (lo, hi) not in self._cache:
            if hi - lo == 1:
                self._cache[lo, hi] = self.leaves[lo]
            else:
                k = lo + self._split(hi - lo)
                self._cache[lo, hi] = self.node_hash(self._subtree(lo, k),
                                                     self._subtree(k, hi))
        return self._cache[lo, hi]

NaiveTree stores levels; TaggedTree stores subtree ranges, keyed (lo, hi). That is not decoration — it is the shape of RFC 6962's tree. The levels representation assumes every node has a level, which an unbalanced tree makes meaningless (the fifth leaf of a 5-leaf tree is one hop from the root while the first is three). Ranges are the only description that survives.

The _cache also previews the real reason CT builds trees this way: the digest of (0, k) for a perfect power-of-two range never changes when the log grows past k. Append entries and the old subtree digests are still valid, still cacheable, still servable. Padding out to a power of two would invalidate everything above the fill line on every append.

5.7 TaggedTree.proof — same proof, different walk

merkle.py · lines 135–151
    def proof(self, index):
        """Same walk as NaiveTree's, but down the recursive split instead of
        along stored levels. Collected top-down, so it is reversed to hand
        back the same bottom-up order.
        """
        path = []
        lo, hi = 0, len(self.leaves)
        while hi - lo > 1:
            k = lo + self._split(hi - lo)
            if index < k:
                path.append((self._subtree(k, hi), RIGHT))
                hi = k
            else:
                path.append((self._subtree(lo, k), LEFT))
                lo = k
        path.reverse()
        return path

Descending from the root means siblings arrive top-down, but verify folds bottom-up, so the list is reversed. That path.reverse() is load-bearing and not merely cosmetic — I removed it and re-ran, including at n=5 where the path is genuinely lopsided:

as written (reversed) verify(blk0) True without reverse() verify(blk0) False 5 leaves, as written verify(b00) True 5 leaves, no reverse verify(b00) False

The point of paying that cost is that both classes hand back proofs in one format, so verify is written once and inherited. The two schemes then run the identical fold, and the only variable left in the experiment is the two hash functions.


6. The demo, and what it proves

demo.py builds four 64-byte blocks and runs three acts against each scheme. The block size is the interesting constant:

demo.py · lines 14–24
CHUNK = 64  # bytes per block — deliberately equal to two SHA-256 digests


def block(text):
    """A fixed-size storage block: 64 bytes, dot-padded. Fixed-size chunking
    is what real content-addressed stores do, and 64 = 2 x 32 is the size
    that makes act 2 work.
    """
    raw = text.encode()
    assert len(raw) <= CHUNK, text
    return raw.ljust(CHUNK, b".")

Fixed-size blocks are what real content-addressed storage does. 64 bytes is a plausible choice and also exactly two SHA-256 digests, which is what makes the forged string a legal block rather than obvious garbage. §7.5 has the counterfactual on how much that actually buys.

python3 demo.py
=== NAIVE leaf=H(b) node=H(l||r) === [1] honest inclusion proof for blk0 root fff2e8ddd7cfe365f316d6a564fbdf592ec7e1b2969c5b166acf7f0f02c527aa proof(0) ['R:68204a56', 'R:d2030d4e'] (len 2) verify(blk0) True [2] forged proof for a block that was never in BLOCKS forged block 00463edd8f959048b51c74912d74d5af... (64 bytes) in BLOCKS? False forged proof ['R:d2030d4e'] (len 1) verify(forged) True [3] two different block lists, one root? root([a,b,c]) 54c72de8d0cfaedca7eb44ccf016b721f8f0ea592f1443a2617c52c930d98cd4 root([a,b,c,c]) 54c72de8d0cfaedca7eb44ccf016b721f8f0ea592f1443a2617c52c930d98cd4 same root? True === TAGGED leaf=H(00||b) node=H(01||l||r) === [1] honest inclusion proof for blk0 root 6c82d35aa4b1ccafa2058580ebfe03c32530cef0a402e2964101d1850943a3db proof(0) ['R:dc4bc695', 'R:430d5244'] (len 2) verify(blk0) True [2] forged proof for a block that was never in BLOCKS forged block cf3725b177b8dba1d5fbe775b3637370... (64 bytes) in BLOCKS? False forged proof ['R:430d5244'] (len 1) verify(forged) False [3] two different block lists, one root? root([a,b,c]) 39012668aae067e1117e17b7273fa400bc82ab83fcd2b7e3b7394bdc29814401 root([a,b,c,c]) 0d1f66cee1fc2061154ee7b37a8cfabb6da94a815b5c991bee4cb60926320ec2 same root? False

6.1 Act 1: the honest proof, folded by hand

The four leaf hashes, and the two internal nodes, from the shipped code:

H(blk0) = 00463edd8f959048b51c74912d74d5afa25eeac05d74c0e9431cc7e6c6dc6b88 H(blk1) = 68204a562ccd1c577d74b6e2914d45efefc854cf335528db3b9e54025a9ed94a H(blk2) = f19fcae44630926df6d41f0661c46ab671f38c5b47fb1180b080577e355ae1a1 H(blk3) = d60bf5bda20fc92bf872a244dab8f28597e67f44fbe70fbc40459fda62394968 n0 = fb235ee509183966fc03fca288ce8b92ad2531547d3bb6f75ad2cf5c3914f749 n1 = d2030d4e9d302bb8fe1c476131f04bac8cab7e1513de761cdffb7f845306dfc7 root = fff2e8ddd7cfe365f316d6a564fbdf592ec7e1b2969c5b166acf7f0f02c527aa

proof(0) is ['R:68204a56', 'R:d2030d4e'] — the two right-hand siblings, which are exactly H(blk1) and n1. The verifier folds:

  1. running = leaf_hash(blk0) = 00463edd…
  2. sibling 68204a56… is on the right, so running = H(00463edd… ‖ 68204a56…) = fb235ee5…, which is n0
  3. sibling d2030d4e… is on the right, so running = H(fb235ee5… ‖ d2030d4e…) = fff2e8dd…, which is the root

True. Two hashes, 64 bytes of proof, and the verifier never touched blk1, blk2 or blk3.

6.2 Act 2: the forgery — THE AHA

demo.py · lines 57–59
    scheme = type(tree)
    forged = scheme.leaf_hash(BLOCKS[0]) + proof0[0][0]
    forged_proof = proof0[1:]

That is the entire attack. scheme.leaf_hash(BLOCKS[0]) is H(blk0), which the attacker computes from a block they were legitimately given. proof0[0][0] is H(blk1), which the honest proof handed them. Concatenate the two:

forged = 00463edd8f959048b51c74912d74d5afa25eeac05d74c0e9431cc7e6c6dc6b88 68204a562ccd1c577d74b6e2914d45efefc854cf335528db3b9e54025a9ed94a

32 + 32 = 64 bytes, which is a legal block. The demo confirms in BLOCKS? False — it was never in the file. And:

H(forged) = fb235ee509183966fc03fca288ce8b92ad2531547d3bb6f75ad2cf5c3914f749 n0 = fb235ee509183966fc03fca288ce8b92ad2531547d3bb6f75ad2cf5c3914f749 equal = True

Of course they're equal — leaf_hash(forged) is sha256 of those 64 bytes, and n0 = node_hash(H(blk0), H(blk1)) is sha256 of the same 64 bytes. Identical input, identical output. No collision was found; there was never anything to find.

So the forged block's leaf hash is the internal node one level up. Drop the now-redundant first step of the proof and the fold is:

  1. running = leaf_hash(forged) = fb235ee5… = n0
  2. sibling d2030d4e… on the right: running = H(n0 ‖ n1) = fff2e8dd…

which is the honest root. verify(forged) True, against a proof of length 1 instead of 2. Nothing checked the length, because verify was never told what the length should be (§5.4).

test_forged_leaf_verifies_under_naive (test_merkle.py lines 34–46) asserts all four parts of this: 64 bytes, not in BLOCKS, honest proof length 2, forged proof verifies.

What the attacker does not get Be precise, because this is where write-ups oversell. The forged block's contents are fixed by the file — it is H(blk0) ‖ H(blk1), a specific 64 bytes of digest. The attacker cannot make it say anything; I checked, and it is not even printable text. What they get is a lie about membership: a byte string that was never a block, which any verifier holding only the root will certify as one. That is worth something when downstream logic acts on membership rather than meaning — deduplication, billing, "this chunk is already stored, don't fetch it", set reconciliation — and it is worth a great deal as evidence that the root does not commit to the tree's shape. Act 3 is what happens when you cash that in.

6.3 Act 3: two block lists, one root

root([a,b,c]) 54c72de8d0cfaedca7eb44ccf016b721f8f0ea592f1443a2617c52c930d98cd4 root([a,b,c,c]) 54c72de8d0cfaedca7eb44ccf016b721f8f0ea592f1443a2617c52c930d98cd4 same root? True

Dumping the levels of both trees shows why there is nothing subtle here:

naive 3-leaf level0: ['de4016a7', '52ddc1ca', 'b2f16059', 'b2f16059'] naive 3-leaf level1: ['3fb382f6', '896289e1'] naive 4-leaf level0: ['de4016a7', '52ddc1ca', 'b2f16059', 'b2f16059'] naive 4-leaf level1: ['3fb382f6', '896289e1']

[a, b, c] has an odd level 0, so merkle.py:40 appends H(c) again. The result is [H(a), H(b), H(c), H(c)] — character for character the level 0 of [a, b, c, c]. From there the two computations are the same computation.

This is CVE-2012-2459. NVD's entry is famously uninformative ("Unspecified vulnerability in bitcoind and Bitcoin-Qt … allows remote attackers to cause a denial of service (block-processing outage and incorrect block count)"). The Bitcoin wiki says what actually happened: "Block hash collisions can easily be made by duplicating transactions in the merkle tree." An attacker takes a valid block, duplicates its last transaction (or last pair — see below), and relays the mutated block. It has the same merkle root, therefore the same block hash, but it is invalid because it contains a duplicate transaction. A vulnerable node rejects it and remembers the hash as invalid — so when the real block arrives, it is rejected too. Fixed in 0.4.6, 0.5.5, 0.6.0.7 and 0.6.1rc2 by detecting the mutation rather than by changing the hash rule, which Bitcoin could not do without a hard fork. The comment survives in src/consensus/merkle.cpp to this day, with the example [1,2,3,4,5,6] and [1,2,3,4,5,6,5,6].

Which sizes collide is worth knowing, because "duplicate the last one" is not the general rule. I enumerated every n from 1 to 12 against every "repeat the last k" for k in 1..4:

n -> n+k naive roots equal? tagged roots equal? 3 -> 4 (repeat last 1) naive True tagged False 5 -> 6 (repeat last 1) naive True tagged False 6 -> 8 (repeat last 2) naive True tagged False 7 -> 8 (repeat last 1) naive True tagged False 9 -> 10 (repeat last 1) naive True tagged False 10 -> 12 (repeat last 2) naive True tagged False 11 -> 12 (repeat last 1) naive True tagged False 12 -> 16 (repeat last 4) naive True tagged False (48 pairs tried, 8 collide) 4 vs 5 (dup last) naive False

The pattern: a duplication collides exactly when it reproduces the padding the tree was going to do anyway. n = 4 is already a perfect tree, so adding a fifth block creates a whole new level and a different root — the attack does not apply. n = 6 is even at level 0 but odd at level 1, so the duplication has to happen a level up, which at level 0 means repeating the last two blocks. That is exactly the [1,2,3,4,5,6,5,6] in Bitcoin's source comment. test_duplicating_the_last_block_keeps_the_naive_root (test_merkle.py lines 64–72) pins 3-vs-4, 5-vs-6 and 6-vs-8.

6.4 The tagged column: what survives

Same three acts, TaggedTree. Act 1 still passes — the fix is not "reject everything." Act 2 gives verify(forged) False: the forged block's leaf hash is sha256(0x00 ‖ 64 bytes), a 65-byte input, while the internal node above it is sha256(0x01 ‖ 64 bytes), a different 65-byte input. 68379ffb… versus 095b82eb…. The two roles now live in disjoint domains and no amount of concatenating can move a value between them.

Act 3 gives two different roots, 39012668… and 0d1f66ce…, because _split(3) is 2 — the 3-leaf tree is (a,b) beside c — while the 4-leaf tree is (a,b) beside (c,c). Nothing was duplicated, so nothing collided.

6.5 A third attack the demo doesn't run: the zero-length proof

The forgery generalises upward. The two level-1 nodes concatenated, n0 ‖ n1, are also 64 bytes, and sha256 of them is the root itself. So:

len(forged) 64 bytes in BLOCKS? False naive verify(., []) True tagged verify(., []) False

A valid inclusion proof of length zero. The verifier hashes the block, finds it equal to the root, and returns True. Under the naive scheme the root of any 2^k-leaf tree is itself a legal forged block for that tree, at every level of the tree at once.

6.6 The tests

python3 test_merkle.py
PASS test_honest_proofs_round_trip PASS test_honest_proof_rejects_the_wrong_block PASS test_forged_leaf_verifies_under_naive PASS test_forged_leaf_fails_under_tagged PASS test_duplicating_the_last_block_keeps_the_naive_root PASS test_tagged_split_separates_those_lists PASS test_proof_length_would_have_caught_the_forgery_but_not_the_collision PASS test_empty_block_list_is_rejected All 8 tests PASSED

Eight tests, stdlib asserts, no pytest. Four of them pin the two failures and the two fixes. test_honest_proofs_round_trip is the guard rail that stops a "fix" from being return False: it verifies every block at every tree size from 1 to 9, under both schemes.

6.7 The boundary condition — where the attack vanishes

Where the effect vanishes Four places, and you can check your own system against them.

Conversely, the attack is at its strongest where this toy puts it: fixed-size binary blocks, a verifier that holds only a root, and downstream logic that cares about membership rather than content.


7. Design decisions and roads not taken

7.1 Why verify accepts a variable-length proof

This is the decision the whole page rests on, so it is stated in the source docstring rather than left implicit. verify(root, block, proof) is the interface you will find in most Merkle tutorials, most blog posts, and a fair number of libraries. Adding n to it is not free:

RFC 6962 makes a different call — its inclusion proof carries the tree size and the leaf index — but it does that in addition to domain separation, not instead of it. Writing verify without them here is what makes the toy show the failure at all; a verifier that pinned depth would hide the hashing bug behind an unrelated check, and you'd learn nothing about why the tags exist.

7.2 The cheap defence: pin the proof length

The obvious cheap fix, and worth taking seriously because it does work against this forgery. Add one line: reject unless len(proof) == ceil(log2 n). Under the naive tree every proof at a given n is the same length — the levels are shared — so this is well-defined. I ran it:

verify as written honest True forged True verify + depth=2 pinned honest True forged False

The forgery dies. And it cannot be repaired by padding the proof back out to length 2 — a longer forged proof would require an actual preimage:

forged + duplicate first step len=2 verify=False forged proof + its own repeat len=2 verify=False forged proof + zero sibling len=2 verify=False

So why is this the partial defence and tagging the real one? Three reasons, all of them things I ran rather than reasoned about.

It does nothing about the collision. test_proof_length_would_have_caught_the_forgery_but_not_the_collision (test_merkle.py lines 83–93) is named after this. A 3-leaf tree and a 4-leaf tree both produce length-2 proofs and the same root:

len(NaiveTree([a,b,c]).proof(0)) = 2 len(NaiveTree([a,b,c,c]).proof(0)) = 2 same root? = True

The length check cannot distinguish them, because the whole problem is that the root doesn't commit to n. Which means, circularly, that you cannot derive the n you need for the check from the thing you trust.

It moves the trust problem rather than solving it. Now n must arrive authenticated. Tagging costs one byte per hash and no protocol changes at all.

The length isn't uniform in the tree you actually want. Under RFC 6962's unbalanced tree there is no single depth to pin:

n ceil(log2 n) naive proof lengths tagged proof lengths 3 2 [2, 2, 2] [2, 2, 1] 5 3 [3, 3, 3, 3, 3] [3, 3, 3, 3, 1] 6 3 [3, 3, 3, 3, 3, 3] [3, 3, 3, 3, 2, 2] 9 4 [4, 4, 4, 4, 4, 4, 4, 4, 4] [4, 4, 4, 4, 4, 4, 4, 4, 1]

The check that works is per-index, not per-tree — which means the verifier needs the leaf index too, authenticated. That is exactly what RFC 6962's inclusion proof carries, and it is a much bigger interface than "one byte in front of each hash."

7.3 Which change fixes which defect — the 2×2

TaggedTree makes two changes at once (§5.1), which is a bad experiment. I split them into four schemes and ran all three properties against each:

naive (no tag, pads ) honest=True forgery works=True 3==4 collision=True tagged (tag, split) honest=True forgery works=False 3==4 collision=False no-tag+split honest=True forgery works=True 3==4 collision=False tag+pad honest=True forgery works=False 3==4 collision=True

Clean separation, and it is the most useful thing on this page after the aha itself:

If you had guessed that "use RFC 6962's hashing" was one fix, this is the correction. It is two, and a system can easily ship one.

7.4 Why two distinct tag bytes rather than one

0x00 and 0x01 look like ceremony — surely one tag is enough to separate the domains? On this toy's fixed 64-byte blocks, it is:

no tags (as shipped, naive) honest=True forgery works=True both tags 00/01 (RFC) honest=True forgery works=False leaf tagged only honest=True forgery works=False node tagged only honest=True forgery works=False both tagged, SAME byte 00/00 honest=True forgery works=True

Two things there. The last row first: tagging both with the same byte restores the attack completely. H(0x00 ‖ block) and H(0x00 ‖ l ‖ r) are the same function of a 64-byte argument again; the prefix cancels. What separates domains is that the tags differ, not that they exist.

The middle rows are more interesting and I nearly wrote them up as "one tag is enough." It isn't — the separation there is accidental, coming from the input lengths (65 bytes vs 64), and it evaporates the moment a shorter block is legal. Under leaf-tagging only, leaf_hash(b) = H(0x00 ‖ b) equals node_hash(l, r) = H(l ‖ r) whenever l begins with 0x00 and b is the remaining 63 bytes. That is a 1-in-256 chance per node, so I searched for one:

attempts to find a leaf digest starting 0x00 458 leaf digest of blk0 00ceaf0546e21ed9 forged block length 63 bytes verify(forged, proof[1:]) True honest verify still fine True same trick against 0x00/0x01 False

458 tries on a laptop. Two distinct prefixes are immune because the tag is inside the hash input on both sides and the two inputs differ in their first byte no matter how the rest lines up.

7.5 Why 64-byte blocks — and what that choice does not do

The natural claim is "64-byte blocks are what make the forgery possible." I ran it, and that claim is wrong in an instructive way. Rebuilding the demo with 96-byte blocks:

forged length 64 bytes (blocks here are 96) verify(forged) True

verify never looks at len(block) (§5.4), so the forged 64-byte string still verifies against a tree whose blocks are all 96 bytes. What the 64-byte choice buys is not the acceptance — it's the plausibility. At 64 bytes the forged string is a well-formed block: the right size, indistinguishable from data, something a storage layer would hand to an application without comment. At 96 it is a malformed block that verify blesses and the layer above throws out.

That distinction is the entire practical severity argument, and it's why the boundary condition in §6.7 lists parsing separately from verifying. It is also the honest version of the caveat in Dean Jerkovich's write-up of this attack: you get a colliding input, but you do not get to choose what it says.

7.6 Why TaggedTree inherits from NaiveTree

verify is written once, on NaiveTree, and inherited unchanged (merkle.py:69-83). TaggedTree overrides leaf_hash, node_hash, __init__, and proof — but the fold itself is shared code.

This is not code golf; it is the experimental control. If each class had its own verify, every "tagged rejects it, naive accepts it" result on this page would be open to the objection that the two verifiers differ somewhere else. They can't: it is literally the same function object, and the only thing that varies is which leaf_hash/node_hash cls resolves to.

The cost is a TaggedTree that inherits an __init__ it immediately overrides and a self.levels it never has. A shared abstract base with two concrete subclasses would be tidier and would also add a third class to a two-class file. For a toy whose point is "these differ in four lines," inheritance says it better than an ABC would.


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Each answer is derivable from the source, and each was verified by running it.

Question 1

You add the missing check: verify now also takes the expected depth and rejects any proof of the wrong length. Does the forgery die? Does the root([a,b,c]) == root([a,b,c,c]) collision?

Answer

The forgery dies; the collision doesn't.

verify as written honest True forged True verify + depth=2 pinned honest True forged False len(NaiveTree([a,b,c]).proof(0)) = 2 len(NaiveTree([a,b,c,c]).proof(0)) = 2 same root? = True

The forged proof is length 1 against a 4-leaf tree that always produces length 2, so pinning kills it. But three leaves and four leaves both produce length-2 proofs, so no length check can tell those two block lists apart — and they share a root. See §7.2, and test_proof_length_would_have_caught_the_forgery_but_not_the_collision.

Question 2

The forged block sits one level up. Can you forge a block that needs no proof at all — an empty list?

Answer

Yes, on any tree whose root is a node_hash. Take the two level-1 digests n0 and n1, concatenate them, and you have a 64-byte "block" whose leaf hash is H(n0 ‖ n1) — the root.

len(forged) 64 bytes in BLOCKS? False naive verify(., []) True tagged verify(., []) False

verify folds zero siblings and compares leaf_hash(block) to root directly. Same bug, one level higher. §6.5.

Question 3

You add domain separation but reach for the same byte twice — H(0x00 ‖ leaf) and H(0x00 ‖ l ‖ r). Are you safe?

Answer

No. The forgery comes straight back:

both tags 00/01 (RFC) honest=True forgery works=False both tagged, SAME byte 00/00 honest=True forgery works=True

A shared prefix cancels: both hashes are again sha256(0x00 ‖ ⟨64 bytes⟩), one function of one argument. Domain separation is about the tags differing, not about tags being present. §7.4.

Question 4

TaggedTree makes two changes: tagged hashes, and RFC 6962's split instead of duplication. Which one fixes CVE-2012-2459?

Answer

The split, not the tags. Running all four combinations:

naive (no tag, pads ) honest=True forgery works=True 3==4 collision=True tagged (tag, split) honest=True forgery works=False 3==4 collision=False no-tag+split honest=True forgery works=True 3==4 collision=False tag+pad honest=True forgery works=False 3==4 collision=True

Row 4 is the answer: tagged hashes plus duplication still collides. The tags fix the forgery; not padding fixes the collision. Two bugs, two independent fixes, and shipping one does not get you the other. §7.3.

Question 5

The collision works for 3 blocks vs. 4. Does it work for 4 vs. 5? Which n are vulnerable?

Answer

4-vs-5 does not collide. Of 48 (n, k) pairs I enumerated, 8 collide:

3 -> 4 (repeat last 1) 6 -> 8 (repeat last 2) 5 -> 6 (repeat last 1) 10 -> 12 (repeat last 2) 7 -> 8 (repeat last 1) 12 -> 16 (repeat last 4) 9 -> 10 (repeat last 1) 11 -> 12 (repeat last 1) 4 vs 5 (dup last) naive False

The rule is that a duplication collides when it reproduces padding the tree was going to do anyway. n = 4 is already perfect, so a fifth leaf builds a taller tree instead. n = 6 is even at level 0 and odd at level 1, so the duplication has to be of the last pair — Bitcoin's own source comment uses [1,2,3,4,5,6] vs [1,2,3,4,5,6,5,6]. §6.3.

Question 6

You are a light client. You hold a root you trust absolutely, you receive a block and a proof, and NaiveTree.verify returns True. Write down everything you are entitled to conclude.

Answer

Almost nothing beyond the arithmetic. Precisely: there exists a fold of these len(proof) siblings, starting from sha256(block), that lands on the root. That is the literal postcondition of merkle.py:77-83, and it is all of it.

You may not conclude:

  • that block was ever a block in the file — §6.2 forges one that wasn't;
  • that block is a leaf at all — it may be an internal node (§6.2) or the root itself (§6.5);
  • how many blocks the file has — 3 and 4 share a root (§6.3);
  • at what index it sits, or that it sits at exactly one index — the duplicated padding node genuinely appears twice;
  • that the file you are checking against is the file the root was computed from, since two files share the root.

Under TaggedTree the first two become sound — 0x00 forces the starting value to be a leaf hash, so it cannot be an internal node or the root — and the last two become sound in the sense that no two block lists share a root any more. What still doesn't follow is the index: even under RFC 6962 the root tells you nothing about where the leaf sits, which is why RFC 6962's inclusion proof transmits the index and the tree size explicitly rather than hoping you'd infer them. A verifier that returns a bare True is throwing away most of the question you wanted answered.


10. Further reading

Every link below was fetched and confirmed live when this was written. Two candidates were dropped as dead: transparency.dev/articles/merkle-trees/ (404) and merkle.com/papers/Certified1979.pdf (404).