在一个共识协议中,最简单的错误也会导致灾难。

我准备开一个系列,讲解我在 go-ethereum(Geth 客户端)(以太坊协议的正式 Go 语言实现)中发现的 Bug,本篇是第一篇。虽然阅读这系列文章不需要你对 Geth 有多深的理解,但懂得以太坊协议是怎么运行的,会很有帮助。

这篇文章讲的是 Geth 客户端叔块验证程序中的一个 bug,传入一个专门构造的叔块后,该程序的行动是错误的。如果该漏洞被利用,会导致 Geth 节点和 Parity 节点发生分叉。

区块与叔块

每一条区块链都会在运行中产生一条大家都认可的主链(canonical chain);而主链的辨识方法也是协议定义好的:最长的链,或者总工作量最大的链,等等。不过,网络的延迟意味着,可能会有两个区块在同一时间生成。那么,只有其中一个能最终成为主链的一部分,而另一个则必须被抛弃。

某些区块链协议,比如比特币,会完全无视掉这些被落败的区块,让它们成为主链的 “孤块(orphan)”。另一些区块链协议,比如以太坊,依然会奖励挖出这些落败区块并努力传播它的矿工。在以太坊的语境中,这些仍然成为了主链一部分的孤块叫做 “叔块(uncle block)”。

但一个块要成为一个有效的叔块,还需满足一些条件:(1)该区块本身的所有内容都必须是有效的(根据正常的共识规则);(2)区块与其意图标记的叔块,两者的块高度相差不超过 6(一个叔块挖出后,只有在未来的 6 个区块以内被标记为叔块,才是有效的)。但是,这里有个例外:虽然正常的区块的时间戳间隔不应超过 15 秒,但叔块则无此限制。

关于整数的一个小插曲

大部分的编程语言都有依赖于平台的整数(platform-dependent integer)和定长整数(fixed-width integer)两种概念。依赖于平台的整数可能是 32 位或者 64 位的(等等),取决于程序所在的编译平台。在 C/C++ 和 Go 语言中,你可能会使用 uint,而在 Rust 中,你会使用 usize。

但是,有些时候程序员想要保证其程序变量可以存储 64 位的数据,即使程序运行在 32 位的平台上。这时候,程序员可以使用定长的整数类型。在 C/C++ 中就是 uint64_t,Go 语言是 uint64,而在 Rust 中是 u64。

使用这些语言自带的整数型的好处是,它们都具备最高优先级(first-class citizen),使用起来都非常简单。来看看这个支持 64 位整数的 Collatz Conjecture 实现:

func collatz(n uint64) uint64 { if n % 2 == 0 { return n / 2 } else { return 3 * n + 1 }}

但是,这个实现有个瑕疵:它不支持超过 64 位的整数。因此,我们需要 大整数(big integers)。大多数语言都支持大整数,要么是用自带的标准库(比如 Go 的 big.Int),要么是通过外部代码库(比如 C/C++ 和 Rust 都是如此)。

难搞的是,使用大整数有一个很大的缺点:很不灵活。我们用支持任意大整数类型的 Collatz Conjecture 把上面的程序再实现一遍:

var big0 = big.NewInt(0)var big1 = big.NewInt(1)var big2 = big.NewInt(2)var big3 = big.NewInt(3)func collatzBig(n *big.Int) *big.Int { if new(big.Int).Mod(n, big2).Cmp(big0) == 0 { return new(big.Int).Div(n, big2) } else { v := new(big.Int).Mul(big3, n) v.Add(v, big1) return v }}显然,64 位的版本既好写,又好读。所以,不意外的是,程序员都会尽可能使用简单的整数型。

例外 vs. 现实

在以太坊协议中,可以预期大部分数据都不会超过 256 位,虽然某些整数字段的长度是任意的,无法有任何预期。重点是,区块的时间戳 Hs 也定义为一个 256 位的整数。

- 以太坊黄皮书,P6 -

Geth 团队尝试通过验证叔块的时间戳是小于 2^256 - 1 的整数来满足这个定义。再次提醒,叔块的出块时间不受任何限制。

// Verify the header's timestampif uncle { if header.Time.Cmp(math.MaxBig256) > 0 { return errLargeBlockTime }} else { if header.Time.Cmp(big.NewInt(time.Now().Add(allowedFutureBlockTime).Unix())) > 0 { return consensus.ErrFutureBlock }}

- 来源 -

但是,接下来的代码却要将区块时间戳强制调整为一个 64 位的整数,以计算该区块的正确难度。

// Verify the block's difficulty based in it's timestamp and parent's difficultyexpected := ethash.CalcDifficulty(chain, header.Time.Uint64(), parent)- 来源 -

如果 Parity 也是一样的做法,那也不会有什么大问题。但是,Parity 的时间戳在 2^64 - 1 就已到达上限,不会再溢出了。

let mut blockheader = Header { parent_hash: r.val_at(0)?, uncles_hash: r.val_at(1)?, author: r.val_at(2)?, state_root: r.val_at(3)?, transactions_root: r.val_at(4)?, receipts_root: r.val_at(5)?, log_bloom: r.val_at(6)?, difficulty: r.val_at(7)?, number: r.val_at(8)?, gas_limit: r.val_at(9)?, gas_used: r.val_at(10)?, timestamp: cmp::min(r.val_at::(11)?, u64::max_value().into()).as_u64(), extra_data: r.val_at(12)?, seal: vec![], hash: keccak(r.as_raw()).into(),};- 来源 -

也就是说,如果有个恶意的矿工,在所出的区块里纳入了一个叔块,该叔块的时间戳是 584942419325-01-27 07:00:16 UTC,也就是 unix 时间 2^64,那么 Geth 会用 Unix 时间 0 来计算难度,而 Parity 会用 unix 时间 2^64 - 1 来计算难度。结果会不一样,所以其中一个客户端会在验证叔块后从主链分裂出去。

Geth 团队在 PR 19372 中修复了这个 Bug,切换到所有时间戳都使用 unit64 。

结论

每一种参与同一个共识协议的客户端,都必须有同样的行动,因此,一些看起来完全无害的操作可能正是导致一半网络相互隔离的罪魁祸首。

这同样也表明,要发现一个影响巨大的 bug,你并不需要很高的技术水平。如果你对这些东西感兴趣,最好的办法就是立即动手。

下一篇文章,我们会讨论 Geth 客户端如何存储构成以太坊的数据,以及一个手段高明的攻击者可以如何规划一个定时炸弹,在引爆时导致链硬分叉。

原文链接:

https://samczsun.com/the-block-mined-in-january-584942419325/

作者:  samczsun

翻译: 

阿剑