一、一个具体需求

你的手机钱包收到一笔转账。你想确认:这笔交易真的被打包进了第 900000 号区块吗?

最笨的办法是把整个区块下载下来,检查里面有没有你那笔。但一个比特币区块最大约 4 MB,一个以太坊区块也有百 KB 到 MB 级——而你只关心其中一笔。

有没有办法,只下载几百字节就能确信?

有。这就是 Merkle 树要解决的问题:

给定一个「集合的摘要」,如何用远小于集合大小的证据,证明某个元素在这个集合里?

二、构造

打个比方

想象一场淘汰赛的对阵表

一千个选手两两捉对,赢的进下一轮,最后剩一个冠军。现在有人问你:“第 387 号选手真的参加过这场比赛吗?”

你不需要把全部一千人的名单给他。你只需要给出这一条晋级路线上、每一轮的对手是谁——大约十个名字。他顺着这十场比赛一路推上去,如果最后推出的冠军和公布的冠军一致,那么第 387 号必定在这一千人里。

改动任何一个人,都会导致上面某一场的结果对不上,最后推出来的冠军就不一样了。

⭐ Ralph Merkle 1979 年的想法就是这个:两两哈希,一层层往上合并。 那个"冠军"是根哈希,那"十个对手"就是 Merkle 证明。

以 8 笔交易为例:

                        Root
                   ┌──────┴──────┐
                 H(AB)         H(CD)         ← 第 2 层
              ┌────┴────┐   ┌────┴────┐
            H(A)      H(B) H(C)     H(D)     ← 第 1 层
          ┌──┴──┐  ┌──┴──┐ ...
         h₀    h₁  h₂    h₃  h₄  h₅  h₆  h₇   ← 叶子 = 每笔交易的哈希
         │     │   │     │
        tx₀   tx₁ tx₂   tx₃ tx₄ tx₅ tx₆ tx₇

规则:

叶子节点:   leaf(i) = H(0x00 ‖ txᵢ)          ⚠️ 前缀的作用见第六节
内部节点:   node    = H(0x01 ‖ 左 ‖ 右)
根:         Root —— 只有 32 字节,放进区块头

关键性质:改动任何一笔交易,它上面那条路径的所有哈希都会变,最终 Root 一定变。Root 是整个交易集合的一个 32 字节承诺。

三、包含证明

⭐ 这就是那条晋级路线:你只给出沿路每一轮的对手,不给整张对阵表。 下面看它具体有多小。

现在回到需求。要证明 tx₂ 在树里,需要给出什么?

                        Root
                   ┌──────┴──────┐
                 H(AB)         [H(CD)]  ← ③ 需要
              ┌────┴────┐
            [H(A)]    H(B)               ← ② 需要
                    ┌──┴──┐
                   h₂    [h₃]            ← ① 需要
                    │
                  tx₂  ← 要证明的交易

只需要三个哈希h₃H(A)H(CD)——也就是从叶子到根这条路径上,每一层的兄弟节点

验证过程:

① 自己算 h₂ = H(0x00 ‖ tx₂)
② 用 h₃ 合并:       H(B)  = H(0x01 ‖ h₂ ‖ h₃)          ← tx₂ 是左边,所以兄弟在右
③ 用 H(A) 合并:     H(AB) = H(0x01 ‖ H(A) ‖ H(B))      ← 这次自己在右
④ 用 H(CD) 合并:    Root' = H(0x01 ‖ H(AB) ‖ H(CD))
⑤ 检查 Root' == 区块头里的 Root

⚠️ 注意第 ②③ 步的方向不同——所以证明里除了兄弟哈希,还必须记录自己在左边还是右边。方向搞反是实现这个算法最常见的 bug。

大小

树高是 log₂ n,所以证明包含 log₂ n 个哈希:

交易数 证明大小
8 3 × 32 = 96 字节
1,024 10 × 32 = 320 字节
1,000,000 20 × 32 = 640 字节
10 亿 30 × 32 = 960 字节

一百万笔交易,640 字节就能证明其中任意一笔的存在。 这就是轻客户端(第 13 讲)能在手机上运行的原因。

但要注意它证明了什么、没证明什么:

✅ 证明了:tx₂ 在这个 Root 所承诺的集合里
❌ 没证明:这个 Root 是"正确的链"上的
❌ 没证明:tx₂ 本身是合法的(签名有效、没有双花)

第 13 讲会讲轻客户端如何补上这两条,以及它必须付出的信任代价。

四、坑一:CVE-2012-2459

真实世界里,交易数很少正好是 2 的幂。奇数个节点怎么办?

比特币的做法是:复制最后一个

3 个叶子: [h₀, h₁, h₂]
补齐成:   [h₀, h₁, h₂, h₂]   ← 把 h₂ 复制一份

这个决定引入了一个严重漏洞。观察下面两个区块:

区块 X 的交易列表: [A, B, C]
  第 0 层: h(A), h(B), h(C)  → 补齐 → h(A), h(B), h(C), h(C)
  第 1 层: H(h(A),h(B)),  H(h(C),h(C))
  Root_X = H( H(h(A),h(B)), H(h(C),h(C)) )

区块 Y 的交易列表: [A, B, C, C]   ← ⚠️ C 出现了两次
  第 0 层: h(A), h(B), h(C), h(C)   ← 已经是偶数,不用补
  第 1 层: H(h(A),h(B)),  H(h(C),h(C))
  Root_Y = H( H(h(A),h(B)), H(h(C),h(C)) )

⟹  Root_X == Root_Y   完全相同!

两个交易列表不同的区块,Merkle 根却一样。 而区块头里只有 Root,所以两个区块的区块哈希也完全相同

攻击怎么进行

① 矿工挖出合法区块 X = [A, B, C],广播
② 攻击者截获,构造 Y = [A, B, C, C],转发给受害节点
③ 受害节点验证:
      区块头哈希 ✅(和 X 一样)
      Merkle 根 ✅(和 X 一样)
      交易内容 ❌ —— C 出现两次,这是双花,区块无效
④ 节点把"这个区块哈希"记入无效缓存
⑤ ⚠️ 真正的区块 X 到达时,节点一看哈希已在无效列表 → 直接丢弃
⟹ 该节点永久停在旧链上,与全网分叉

这是一个「永久 DoS」:不需要算力,只需要转发一个变异区块,就能让一个全节点掉队。

修复

比特币 0.6.3 的修复很直接:计算 Merkle 根时,如果某一层出现了重复的相邻节点,直接判定区块无效。

更根本的教训:Merkle 树的构造必须保证「树 → 根」是单射的。任何两个不同的叶子序列都必须给出不同的根。比特币的复制策略破坏了这一点。

更好的做法(多数现代系统采用):在叶子哈希里编码总数,或者干脆不补齐,允许不平衡树。

五、坑二:第二原像攻击与域分隔

再看一个更微妙的问题。假设我们不加第二节里的 0x00 / 0x01 前缀:

leaf(i) = H(txᵢ)
node    = H(左 ‖ 右)

考虑一棵 4 叶子的树:

Root = H( H(h₀‖h₁) ‖ H(h₂‖h₃) )

现在攻击者宣称:这其实是一棵只有 2 个叶子的树,两个叶子分别是 (h₀‖h₁)(h₂‖h₃) 这两串 64 字节数据:

攻击者的"树": Root = H( leaf'₀ ‖ leaf'₁ )
    其中 leaf'₀ = h₀‖h₁ ,leaf'₁ = h₂‖h₃

验证者算:H( H(leaf'₀) ‖ H(leaf'₁) ) ...

这里的核心问题是:内部节点的值和叶子的值来自同一个哈希函数,无法区分。攻击者可以把一棵树的内部节点冒充成叶子,从而对同一个 Root 给出一个完全不同的"原像"。

修复:域分隔(domain separation)

leaf(x) = H(0x00 ‖ x)
node    = H(0x01 ‖ 左 ‖ 右)

加一个字节的前缀,叶子哈希和内部节点哈希就落在两个不相交的空间里,冒充不可能发生。

这是 RFC 6962(Certificate Transparency)确立的标准做法,如今几乎所有新系统都遵循。⚠️ 比特币没有这么做——它靠"叶子必须是合法交易的序列化"这一额外约束来间接防御,但这是一个更脆弱的保证。

六、Go 实现

package merkle

import (
	"bytes"
	"crypto/sha256"
	"errors"
)

const (
	leafPrefix = 0x00 // ⚠️ 域分隔:防止内部节点被冒充成叶子
	nodePrefix = 0x01
)

func hashLeaf(data []byte) []byte {
	h := sha256.New()
	h.Write([]byte{leafPrefix})
	h.Write(data)
	return h.Sum(nil)
}

func hashNode(left, right []byte) []byte {
	h := sha256.New()
	h.Write([]byte{nodePrefix})
	h.Write(left)
	h.Write(right)
	return h.Sum(nil)
}

// Tree 保存每一层的节点,levels[0] 是叶子层。
type Tree struct {
	levels [][][]byte
}

// New 构建 Merkle 树。
// 奇数节点时不复制最后一个(那会导致 CVE-2012-2459),
// 而是把它原样提升到上一层。这样"叶子序列 → 根"仍然是单射的。
func New(items [][]byte) (*Tree, error) {
	if len(items) == 0 {
		return nil, errors.New("merkle: 空集合")
	}

	level := make([][]byte, len(items))
	for i, it := range items {
		level[i] = hashLeaf(it)
	}

	t := &Tree{levels: [][][]byte{level}}
	for len(level) > 1 {
		next := make([][]byte, 0, (len(level)+1)/2)
		for i := 0; i < len(level); i += 2 {
			if i+1 == len(level) {
				next = append(next, level[i]) // 落单的节点直接上提
			} else {
				next = append(next, hashNode(level[i], level[i+1]))
			}
		}
		t.levels = append(t.levels, next)
		level = next
	}
	return t, nil
}

func (t *Tree) Root() []byte { return t.levels[len(t.levels)-1][0] }

// Step 是证明路径上的一步:一个兄弟哈希,以及它在左还是在右。
type Step struct {
	Hash    []byte
	IsRight bool // true 表示兄弟在右边(即自己在左边)
}

// Proof 生成第 index 个元素的包含证明。
func (t *Tree) Proof(index int) ([]Step, error) {
	if index < 0 || index >= len(t.levels[0]) {
		return nil, errors.New("merkle: 下标越界")
	}
	var path []Step
	for lv := 0; lv < len(t.levels)-1; lv++ {
		level := t.levels[lv]
		if index%2 == 0 {
			if index+1 < len(level) { // 有右兄弟
				path = append(path, Step{Hash: level[index+1], IsRight: true})
			}
			// 落单节点被直接上提,这一层不产生证明步骤
		} else {
			path = append(path, Step{Hash: level[index-1], IsRight: false})
		}
		index /= 2
	}
	return path, nil
}

// Verify 在只知道 root 的情况下验证 item 属于集合。
// 验证方不需要任何其他数据——这正是轻客户端能工作的原因。
func Verify(root, item []byte, path []Step) bool {
	cur := hashLeaf(item)
	for _, s := range path {
		if s.IsRight {
			cur = hashNode(cur, s.Hash)
		} else {
			cur = hashNode(s.Hash, cur)
		}
	}
	return bytes.Equal(cur, root)
}

⚠️ 注意 Verify 的签名:它只需要 rootitempath不需要树,不需要其他任何交易。 这个函数可以跑在手机上、跑在智能合约里——第七节的空投白名单用的就是它。

七、Merkle 树在区块链里出现在哪

位置 承诺了什么 用途
交易根(每个区块头) 本块所有交易 轻客户端验证"我的交易被打包了"
收据根(以太坊) 所有交易的执行结果与日志 验证"这个事件真的发生了"
状态根(以太坊) 全部账户与合约存储 用的是 Merkle Patricia Trie,第 12 讲
空投白名单 几万个地址 合约里只存 32 字节的根,用户领取时自带证明
数据可用性采样 一个大数据块的所有分片 第 29 讲

空投白名单是最能体现价值的例子:如果把 10 万个地址直接写进合约,Gas 成本高到不可行;用 Merkle 根只需存 32 字节,每个用户领取时自己提供 17 个哈希的证明。成本从 O(n) 变成 O(1) 存储 + O(log n) 验证。

八、排序 Merkle 树与不存在性证明

标准 Merkle 树能证明"在里面",不能证明"不在里面"——因为你无法穷举所有可能。

但如果把叶子按键排序,就可以:

排序后的叶子: ... , key=15 , key=42 , ...
                        ↑        ↑
要证明 key=30 不存在:
   给出相邻的两个叶子 15 和 42 各自的包含证明,
   并且它们在树中是相邻的。
   ⟹ 因为有序,15 和 42 之间不可能存在 30

⭐ 这个技巧叫区间证明(range proof)不存在性证明。第 12 讲的 Merkle Patricia Trie 通过键的路径结构提供了同样的能力。

九、本讲小结

  • Merkle 树解决的问题:用远小于集合大小的证据,证明某元素属于一个由 32 字节摘要承诺的集合。
  • 构造:叶子 = 数据哈希,内部节点 = 两个子节点的哈希,根放进区块头。改动任何叶子必然改变根。
  • 包含证明只需 log₂ n 个哈希。一百万笔交易 → 640 字节。这是轻客户端能跑在手机上的根本原因。
  • 证明里必须记录左右方向,方向搞反是最常见的实现 bug。
  • 它证明的是"在集合里",不是"交易合法",也不是"这条链是主链"。
  • CVE-2012-2459:比特币在奇数节点时复制最后一个,导致 [A,B,C][A,B,C,C] 的 Merkle 根相同 ⟹ 攻击者可让节点把合法区块永久标记为无效,造成永久 DoS。根本教训:「叶子序列 → 根」必须是单射。
  • 缺少域分隔会导致第二原像攻击:内部节点可以被冒充成叶子。修复是给叶子和内部节点加不同的一字节前缀(RFC 6962)。
  • 空投白名单是最典型的应用:合约只存 32 字节根,存储成本从 O(n) 降到 O(1)。
  • 把叶子排序,就能证明"不存在"——给出相邻两个叶子即可。

思考题

  1. 一个区块有 5,000 笔交易,包含证明有多大?如果交易数翻 1000 倍,证明大小变成多少?
  2. 为什么证明里必须记录"兄弟在左还是在右"?举一个方向搞反导致验证通过的例子(或说明为什么不会)。
  3. 完整推演 CVE-2012-2459:给出 [A,B,C][A,B,C,C] 每一层的哈希,说明为什么根相同。
  4. 除了"检测重复相邻节点",还有什么办法能修复这个漏洞?比较各自的代价。
  5. 域分隔攻击中,攻击者需要满足什么条件才能成功?为什么比特币实际上没有因此被攻击?
  6. 上面的 Go 实现中,落单节点被直接上提。请证明这个策略下"叶子序列 → 根"是单射的。
  7. 设计一个空投合约:10 万个地址,每人可领固定数量。写出合约需要存什么、用户提交什么、合约怎么防止重复领取。
  8. 排序 Merkle 树能证明不存在。如果攻击者控制了树的构造,他能伪造一个"不存在证明"吗?需要什么额外假设?