Geth叔块验证漏洞解析:时间戳处理不当引发的分链风险

本文详细分析了go-ethereum客户端在叔块验证过程中存在的时间戳处理漏洞,该漏洞可能导致Geth和Parity节点意外分叉。文章深入探讨了整数类型选择对共识协议的影响,并展示了具体代码实现差异。

一月挖出的区块,584942419325

在共识协议中,最简单的错误都可能带来毁灭性影响。

samczsun

2021年3月30日 • 4分钟阅读

这是我关于在go-ethereum(Geth,以太坊协议的官方Golang实现)中发现bug的系列博客文章的第一篇。虽然你不需要深入理解Geth就能理解这些博客文章,但了解以太坊本身的工作原理会有所帮助。

第一篇帖子是关于Geth叔块验证程序中的一个bug,该程序在遇到特殊构造的叔块时行为不正确。如果被利用,这可能导致Geth和Parity节点之间意外分叉。

区块和叔块

每个区块链都有一个规范链,通过某些指标定义,比如链的总长度或产生总链所需的工作量。然而,网络延迟意味着有时可能同时产生两个区块。只有一个区块能被纳入规范链,所以另一个区块必须被排除在外。

像比特币这样的区块链会完全忽略这些区块,使它们成为规范链的孤块。而像以太坊这样的区块链仍然会奖励那些付出努力但在区块传播中运气不佳的矿工。在以太坊中,这些仍然被包含的孤块被称为叔块。

叔块需要满足特定条件才能被视为有效。首先,根据正常的共识规则,区块的所有属性必须有效;其次,叔块必须是距离当前链头最多6个区块的区块的子块。然而,有一个例外:虽然普通区块不能超过未来15秒,但叔块不受此限制。

关于整数的简要插曲

大多数编程语言都有平台相关整数和固定宽度整数的概念。平台相关整数可能是32位或64位(或其他!),具体取决于程序编译的平台。在C/C++和Go中,你可能会使用uint,而在Rust中可能会使用usize

然而,有时程序员可能希望保证他们的变量能够容纳64位数据,即使平台是32位的。在这些情况下,程序员可以使用固定宽度整数类型。在C/C++中是uint64_t,在Go中是uint64,在Rust中是u64

这些内置整数类型的好处是它们都是一等公民,因此使用起来非常简单。考虑这个支持64位整数的Collatz猜想的实现:

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

然而,这个实现有一个小缺陷,它不支持大于64位的输入。为此,我们需要大整数。大多数语言在标准库本身(如Go中的big.Int)或通过C/C++或Rust的外部库支持这一点。不幸的是,使用大整数有一个大缺点:它们使用起来笨重得多。这在重新实现支持任意大整数的Collatz猜想中得到了说明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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位版本编写和阅读起来要简单得多,所以程序员在可能的情况下喜欢使用简单整数类型也就不足为奇了。

期望与现实

在以太坊中,大多数数据预计适合256位,尽管某些字段预计只是整数值,大小没有限制。值得注意的是,区块时间戳Hs被定义为256位整数。

以太坊黄皮书,第6页

Geth团队试图忠实于这个定义,通过验证叔块上的时间戳不大于2^256-1。回想一下,叔块对未来的挖矿没有限制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 验证头部的时间戳
if 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位整数,以便计算区块的正确难度。

1
2
// 根据时间戳和父难度验证区块难度
expected := ethash.CalcDifficulty(chain, header.Time.Uint64(), parent)

如果Parity行为相同,这不会太糟糕,但Parity会将时间戳饱和在2^64-1而不是溢出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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::<U256>(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,该PR将所有时间戳切换为使用uint64

结论

参与共识协议的每个客户端必须行为完全相同,所以看似完全良性的操作实际上可能是导致一半网络断开的触发因素。这也表明,你不需要高度技术化就能找到有影响力的bug,所以如果这看起来像是你感兴趣的事情,没有比直接潜入更好的开始方式了。

下次,我们将探讨Geth如何存储构成以太坊的数据,以及熟练的攻击者如何埋下一个在引爆时会硬分叉链的定时炸弹。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计