以太坊区块链的陷阱设置
这是关于我在go-ethereum(Geth)中发现bug的系列博客文章的第二篇。如果您还没有看过,请先查看第一部分。
今天的文章是关于Geth状态下载器中的一个bug,该bug可被用来欺骗它错误地与主网同步。如果被利用,攻击者可以在以太坊区块链上设置陷阱,并随意触发硬分叉。
同步
当有人想要运行以太坊节点时,他们必须首先与网络同步。这意味着下载或计算构建最新区块链状态完整视图所需的所有数据。根据用户的需求,可以在安全性和速度之间进行一些权衡,因此Geth(当时)支持两种不同的同步模式:完整同步和快速同步。
顾名思义,完整同步执行以太坊区块链的完全同步。这意味着Geth将下载并验证每个区块的工作量证明密封。Geth还将执行区块中的每个交易,这使其能够在本地生成区块链状态,而无需信任其他节点。这更安全,但速度代价很高,因为完整的Geth同步可能需要几天到几周才能完成。
然而,一些用户可能不想等待完整同步完成。也许他们有最后期限要满足,或者他们只是认为速度代价不值得。为此,Geth提供了一种模式,其中直到最近区块(称为枢轴)的链数据使用更快的方法同步,只有少数剩余区块使用较慢的完整同步算法。在快速同步模式下,Geth下载但不验证每个区块的工作量证明,而是随机选择某些区块。Geth也不执行任何交易,而是选择直接从对等节点下载状态树以到达最终的区块链状态。
当然,Geth不会盲目信任对等节点发回的状态树数据,否则恶意对等节点可能声称某个账户拥有的以太币比实际多得多。要理解Geth如何判断刚刚接收的数据是否正确,我们必须首先理解Merkle-Patricia树。
Merkle-Patricia树
Merkle-Patricia树(MPT)是Geth中的一个关键数据结构,是Merkle树和Patricia树的结合。
简单来说,Patricia树基于数据的前缀在树状结构中存储数据。与哈希映射相比,这是存储相似数据的更优化方式,尽管可能存在速度权衡。例如,以下是以’r’开头的几个单词在Patricia树中的存储方式。
同时,Merkle树只是一种树,其中叶节点是数据的哈希,所有其他节点是其子节点的哈希。如果用户知道Merkle树的Merkle根(即顶部哈希),并想检查特定数据是否包含在树中,他们可以仅使用通过树的路径(与叶节点数量的对数成正比,而不是叶节点数量本身)来做到这一点。在下图中,用户可以通过提供Hash 0-0和Hash 1来证明L2是树的成员。验证者然后将生成Hash 0-1、Hash 0和Top Hash,这些可以与它们预期的Merkle根进行比较。
通过MPT,Patricia树的基于前缀的存储布局与Merkle树结合,创建了一个可以加密验证内容同时仍保持良好运行时性能的数据结构。
在MPT中,键和值是任意字节字符串。要检索键的值,首先将键转换为十六进制字符序列,使得每个字节变成两个十六进制字符。然后,查询根节点以确定序列中第一个字符的下一个节点应该是什么。然后查询子节点以获取第二个字符,此过程重复直到为序列中的最后一个字符找到最终节点。
在下面的示例中,我们看到MPT中实际上有三种不同类型的节点。扩展节点(或,在Geth代码库中称为短节点)是一种优化,它声明给定节点负责流中的多个字符。在这种情况下,根节点覆盖所有以a7开头的键,无需两个不同的节点来表示a和7。分支节点(或完整节点)包含每个可能字符的指针以及一个额外的槽用于当前节点的值。最后,叶节点(或值节点)指示给定节点必须匹配直到键的结束。
状态树
现在我们知道Merkle-Patricia树如何工作,我们可以检查全局状态树本身。
全局状态树是存储区块链大部分数据的地方。尽管将状态树想象为每个块唯一的独特实体很方便,但现实是为每个块复制整个状态树将非常低效,因为只有一小部分树从块到块发生变化。相反,Geth维护的可以想象为MPT节点池。每个块的状态树只是整个池的子集,随着新块被挖掘或导入,新的MPT节点被插入到池中。
为了识别节点池中的根节点,必须查询块头。每个块包含一个称为stateRoot的字段,它指向MPT的根节点,该MPT存储按账户地址键控的账户数据。这允许Geth使用我们上面描述的算法查找任何地址的账户信息,如nonce或余额。
注意,对于合约,账户状态将包含非空的storageRoot和codeHash。storageRoot指向另一个根节点,但这次是用于存储合约存储数据的MPT。此MPT键控在存储槽上,值是原始数据本身。
为了在磁盘上存储MPT,Geth选择使用LevelDB作为其数据库。然而,LevelDB是仅支持字符串到字符串映射的键值数据存储,而MPT不是字符串到字符串映射。为了解决这个问题,Geth通过将每个节点写为键值对来展平全局状态树:键是节点的哈希,值是序列化的节点本身。这也是Geth能够查找任何给定块的状态树的原因,因为块头中的stateRoot字段是可用于在LevelDB中查找序列化MPT节点的键。
树混淆
假设您正在启动Geth节点,并且使用快速同步连接到网络。Geth将快速下载所有块数据但不执行任何交易。不久之后,您将有一个块链但没有状态信息。在这里,Geth启动状态下载器,开始从枢轴块的stateRoot同步。
状态下载器将向其对等节点发送对与MPT节点键对应的MPT节点数据的请求。当它收到结果时,它验证节点数据是否哈希到预期的节点键。如果是,则状态下载器知道节点是正确的,并通过为节点的每个子节点分派更多请求来处理它。
如果遇到叶节点,即包含序列化账户状态的节点,则账户的代码和状态树将被排队获取。
注意,同步子树和原始条目之间有区别。虽然两者都将下载任意数据 blob,但如果同步器期望原始树,它将解析数据作为树节点并开始同步其子节点。另一方面,如果同步器期望原始条目,它将简单地将blob写入数据库并终止。
此外,Geth多次请求同一节点并不罕见。例如,如果两个合约包含相同的存储,它们可能共享相同的stateRoot,或者两个账户可能共享相同的账户状态。在这种情况下,Geth不想用虚假请求淹没网络,因此它将它们合并在一起。
然而,同步器不合并请求的原始属性。这意味着如果已经有一个待处理的原始条目请求,但同步器使用相同的哈希调度子树请求,它将被合并,最终结果仍然是原始条目请求。
请记住,原始请求不处理节点的子节点以进行同步。这意味着如果我们可以以某种方式在原始节点和子树节点之间生成冲突,我们将能够导致Geth同步不完整的状态树。此外,Geth(当时)在从不存在