本文是解说我在 go-ethereum(Geth)客户端中发现的 Bug 系列的第二篇。

这篇文章要讲的 bug 坐落 Geth 客户端的状况下载器内,它能够用来诈骗下载器,使之不能与主网正确同步。进犯者能够运用这个 bug 给以太坊区块链设置陷阱、恣意触发硬分叉。

同步

当你想运行一个以太坊节点的时候,首要有必要同步上整个网络,即,下载和核算构建最新区块时刻的区块链状况所需的一切数据。依据用户自身的需要,同步方法能够在安全性和速度之间有所取舍,所以(在撰文之时) Geth 支撑两种同步形式:彻底同步和快速同步。

望文生义,彻底同步便是独登时履行完对以太坊区块链的整个同步过程。这就意味着,你的 Geth 节点会下载和验证每个区块的工作量证明(PoW),此外,它还会核算区块内的每一条业务;由此,节点能够在本地生成区块链的最新状况,而无需信任其它节点。这种形式更安全,但速度上有很大献身。Geth 的彻底同步或许要花几天乃至几周不等的时刻。

可是,有些用户或许不想等上几周。也许他们的时刻很紧,又或者,他们并不觉得这种献身是值得的。因而,Geth 供给了一个形式:在近期的某个区块(称为 “pivot 区块”)之前的一切链数据,都用更快的方法来同步,只要 pivot 区块之后的区块链,才运用更慢的彻底同步算法。在快速同步形式中,Geth 会下载区块,但仅随机选取一些区块来验证工作量证明,而不是每个区块都验证;而且,它也将不再自己履行业务,而是从网络中的其它节点处直接下载状况树,以此取得最终的区块链状况。

当然,Geth 也不会盲目信任其他节点发回的状况树数据,由于一个歹意的节点也能够声称某个账户只要一点点钱(但实践有很多)。要了解 Geth 怎么能区分收到的数据正确与否,咱们先要了解默克尔帕特里夏树(Merkle-Patricia trie)。

默克尔帕特里夏树

默克尔帕特里夏树(MPT)是 Geth 客户端中的一种关键数据结构,它是默克尔树和帕特里夏树两者的结合。

简而言之,帕特里夏树会基于数据的前缀将数据存到一个树状结构中。相较于其它技能(如哈希表,hashmap)来说,帕特里夏树自身十分适合存储类似的数据,尽管在速度上或许有所献身。下面来看看,多个以 r 开头的单词是怎么存到一棵帕特里夏树里的。

在以太坊上安装 “炸弹”

- 图源:https://commons.wikimedia.org/w/index.php?curid=2118795 -

接着来说说默克尔树。在一棵默克尔树上,每个叶节点是数据的哈希值,每个非叶节点是它的两个子节点的哈希值。假如用户知道了默克尔树的默克尔根(即,顶端哈希值),并且想要确认某个数据是否存储在这棵树里,他只需要用到这棵树上的一条途径,这条途径所涉及的节点数量只跟叶节点数量的对数(留意不是叶节点数量)成正比。如下图所示,假定用户要证明 L2 存储在这棵树里,他只需供给 Hash 0-0 和 Hash 1。接着,验证者生成 Hash 0-1Hash 0 和 Top Hash,再将 Top Hash 与其所预期的默克尔根进行比较。

在以太坊上安装 “炸弹”

- 图源:https://commons.wikimedia.org/w/index.php?curid=18157888 -

默克尔帕特里夏树将帕特里夏树基于前缀的存储结构与默克尔树相结合,发明出了一种新的数据结构,不仅支撑密码学验证方法,而且还能保持杰出的运行时功能。

在默克尔帕特里夏树中,键和值都是恣意的字节串。要想取得一个键的值,咱们首要要将这个键转换成一个十六进制字符序列,即,将每个字节变成两个十六进制字符。然后,咱们要先依据序列中的第一个字符,向根节点查询下一个节点是什么;得到此子节点后,再依据第二个字符向下查询节点,顺次类推,直至找到最终一个节点,取得最终一个字符。

在下面这个比如中,咱们能够看到默克尔帕特里夏树实践上包括三种不同的节点。扩展节点(在 Geth 代码库中又被称为短节点)是经过优化的,负责存储一连串字符。在下图所示事例中,根节点存储了一切以 a7 开头的键,无需运用两个不同的节点来代表 a 和 7。分支节点(或称 “完好节点”)包括每个或许字符的指针以及一个额定的空档来存储当前节点的值。最终,叶节点(又称值节点)的 key-end 字段必然与其所存储的 key 的后缀相匹配 1 。

在以太坊上安装 “炸弹”

状况树

已然咱们已经知道默克尔帕特里夏树是怎么运作的了,咱们能够开端探求什么是大局状况树。

区块链的绝大部分数据都存储在大局状况树中。尽管将状况树作为绝无仅有的实体包括在每个区块内这个设想看似便利,但实践上每个区块都要复制完好的状况树是极其低效的,由于每个区块之间的状况树只要细微差别。Geth 采用了不同的方案。你能够想象一下,Geth 保护了一个 MPT 节点池。每个区块的状况树只是整个池的子集。每逢有新的区块被挖出或导入,就会有新的 MPT 节点被添加到池中。

要想识别节点池中的根节点,咱们有必要查询区块头。每个区块都包括一个指向 stateRoot 的字段,该字段指向 MPT 的根节点(而该 MPT存储以账户地址 2 作为键的账户数据)。这样一来,Geth 就能够运用咱们上文描述的算法查询账户信息,如恣意地址的 nonce 或余额。

在以太坊上安装 “炸弹”

请留意,假如是合约的话,账户状况将包括一个非空的 storageRoot 字段和 codeHash 字段。storageRoot 字段指向另一个根节点。可是,此时所涉及的 MPT 的用处是存储该合约的存储项数据;该 MPT 会将存储空档(storage slot)作为键,将原始数据作为值。

在以太坊上安装 “炸弹”

为了将 MPT 存储在磁盘上,Geth 挑选运用 LevelDB 作为数据库。但是,LevelDB 是只支撑字符串到字符串映射的键值数据库,MPT 不是字符串到字符串映射。为了解决这个问题,Geth 将每个节点编写成键值对,然后完成大局状况树的扁平化:将节点的哈希值作为键,将序列化的节点作为值。这样一来,Geth 就能查询恣意区块的状况树,由于区块头中的 stateRoot 字段便是键,能够用来查找 LevelDB 中的序列化 MPT 节点(译者注:即一棵 MPT 的根节点)。

树混乱

因而,假定你启动了一个 Geth 节点,并运用快速同步形式连接到网络。Geth 将快速下载一切区块数据,可是不履行任何业务。不久之后,你将得到一个没有状况信息的区块链。此时,Geth 经过状况下载器从 pivot 区块的 stateRoot 开端进行同步。

// NewSync creates a new trie data download scheduler.func NewSync(root common.Hash, database ethdb.KeyValueReader, callback LeafCallback, bloom *SyncBloom) *Sync https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg ts := &Synchttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg database: database, membatch: newSyncMemBatch(), requests: make(map[common.Hash]*request), queue: prque.New(nil), bloom: bloom, } ts.AddSubTrie(root, 0, common.Hashhttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg}, callback) return ts}

- 来历:https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L246-L255 -

状况下载器会向对等节点恳求与 MPT 节点键对应的 MPT 节点数据。收到成果后,状况下载器会对节点数据进行哈希核算,验证得到的哈希值是否与节点键相同。假如相同,状况下载器就知道这个 MPT 节点是正确的,然后就会发送更多恳求,恳求该 MPT 节点的每个子节点。

 // Create and schedule a request for all the children nodes requests, err := s.children(request, node) if err != nil https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg return committed, i, err } if len(requests) == 0 && request.deps == 0 https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg s.commit(request) committed = true continue } request.deps += len(requests) for _, child := range requests https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg s.schedule(child) }

- 来历:https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L205-L218 -

假如遇到叶节点(包括序列化的账户状况),账户的代码和状况树将排队等候被获取。

callback:=func(leaf[]byte, parentcommon.Hash) errorhttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpgvarobjAccountiferr:=rlp.Decode(bytes.NewReader(leaf), &obj); err!=nilhttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpgreturnerr}syncer.AddSubTrie(obj.Root, 64, parent, nil)syncer.AddRawEntry(common.BytesToHash(obj.CodeHash), 64, parent)returnnil}

- 来历:https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/core/state/sync.go#L31-L39 -

请留意同步子树和原始条目之间的差异。尽管二者都下载恣意的数据块,可是假如同步器预期要同步的是原始树,它会将该数据解析为树节点,并开端同步其子节点。另一方面,假如同步器预期要同步的是原始条目,它会将数据块写入数据库并终止。

// If the item was not requested, bail outrequest:=s.requests[item.Hash]ifrequest==nilhttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpgreturncommitted, i, ErrNotRequested}ifrequest.data!=nilhttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpgreturncommitted, i, ErrAlreadyProcessed}// If the item is a raw entry request, commit directlyifrequest.rawhttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpgrequest.data=item.Datas.commit(request)committed=truecontinue}

- 来历:https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L183-L197 -

另外还要留意的是,Geth 想要屡次向同一个节点发送恳求的状况并不少见。例如,假如两个合约存储相同的数据,那它们的 stateRoot 或许相同,也有或许出现两个账户具有相同的账户状况。在这些状况下,Geth 不想让网络充斥这些恳求,因而会将它们兼并。

func(s*Sync) schedule(req*request) https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg// If we're already requesting this node, add a new reference and stopifold, ok:=s.requests[req.hash]; okhttps://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpgold.parents=append(old.parents, req.parents...)return}// Schedule the request for future retrievals.queue.Push(req.hash, int64(req.depth))s.requests[req.hash] =req}

- 来历:https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L246-L255 -

但是,同步器不会兼并恳求的 raw 属性。这意味着,假如已经有了一个未决的原始条目恳求,可是同步器又组织了一个具有相同哈希值的子树恳求,后者将被兼并,最终成果还是只要一个原始条目恳求。

请记住,原始条目恳求不会为了同步子节点而处理节点(不像子树恳求)。这意味着,假如咱们能以某种方法在原始条目和子树节点之间引发冲突,咱们就能让 Geth 同步一个不完好的状况树。另外,遇到一个本地不存在的树节点时,Geth 不会退出(报错),这等于是假定,假如下载器陈述同步成功,这种(本地缺失状况数据的)状况就不会产生。这就意味着,缺少一个树节点的 Geth 节点在行为上与其它彻底同步树的节点天壤之别。

那么,怎么引发冲突呢?事实证明十分简略:咱们只需用咱们操控的某个合约的序列化状况根布置另一个合约,因而该合约代码的哈希值必定与(咱们所操控的合约的)状况根哈希值相同,这就意味着假如合约代码先同步,另一个合约的状况树不会被悉数下载下来。

综上

假如有人运用这个缝隙作恶,需完成要多个过程并等候很长时刻。

首要,咱们布置一个合约(咱们会运用缝隙将这个合约的状况树覆盖掉)。这个合约应该有一个绝无仅有的 stateRoot,然后避免与这个 stateRoot 相关的 MPT 节点提早同步。

contract Discrepancy https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg uint public magic = 1; // make sure we have a unique stateRoot uint[] private filler = [1,2,3,4,5,6,7,8,9,0];}

接着,咱们布置第二个合约,以便运用对立触发硬分叉。

contract Hardfork https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg function hardfork(Discrepancy target) public https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg require(target.magic() == 0, "no discrepancy found"); selfdestruct(msg.sender); }}

最终,咱们用 Discrepancy 合约的状况根作为代码布置第三个合约。这儿咱们有必要留意一点,第三个合约的地址要比 Discrepancy 合约的地址 “小”,然后确保它一直先于 Discrepancy 合约同步。

contract Exploit https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg constructor(bytes memory stateRoot) public https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg assembly https://bicoin8.com/wp-content/uploads/2023/04/202304211cHpE0.jpg return(add(stateRoot, 0x20), mload(stateRoot)) } }}

现在咱们就功德圆满了。当新的 Geth 节点运用快速同步参加网络时,它们会先恳求 Exploit 合约,同步其状况子树及代码。当 Exploit 合约的代码被同步时,它会创建一个看起来与 Discrepancy 的状况根恳求彻底相同的原始条目恳求,但它不会被当作子树恳求处理。这意味着,该节点永久不会下载 Discrepancy 的状况 trie,因而未来读取 magic 的恳求将回来 0 而非 1

经过足够长的时刻后,咱们要做的便是调用 Hardfork.hardfork(discrepancy)。每个正确同步整个网络的节点都会看到一个回滚买卖,而每个运用快速同步参加网络的 Geth 节点都会看到一个成功的买卖。这将导致一个区块产生两个不同的状况根,也便是说咱们能够为所欲为地触发链割裂。

Geth 团队经过处理 PR #21039 中的树读取错误快速解决了该进犯,然后经过区分 PR #21080 中的代码部分和树部分彻底修复了这个缝隙。

定论

这是一个十分风趣的缝隙,它能够让进犯者在以太坊网络上设置一个 “炸弹”,并随时引爆,然后导致一切运用快速同步的 Geth 节点从主网中分叉。这个陷阱运用的是 Geth 的同步和数据存储代码中极其复杂的逻辑,这或许是它很长时刻来都没有引起人们留意的原因。

敬请期待本系列的第三篇也是最终一篇文章。在这篇文章中,咱们将探究 Geth 客户端的最新 bug,详细细节不便透露。

脚注

  1. 从技能层面来说,Geth 中的值节点不包括后缀。你能够将其了解成一个后边跟着值节点(只包括原始数据)的扩展节点。

  2. 实践上,Geth 运用的是 “安全的 trie”,即,经过 SHA-3 算法对一切键进行哈希核算,然后确保一切键都是固定长度。

(完)

视野开拓

从这个意义上讲,整个社会契约学说是制衡欲望策略的一个分支。-《欲望与利益》

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注