Beosin
2022-09-15 15:18
订阅专栏
收藏此文章
跨链桥安全回顾:Nomad 去中心化抢劫事件带给我们什么启发?
跨链桥 Nomad 互操作性、跨链实现方式与安全事件解析。


原文标题:《跨链桥安全研究 ( 二 ) | 首次去中心化抢劫 Nomad 跨链桥事件带给我们什么启发?》

撰文:成都链安


欢迎大家来到成都链安出品的「跨链桥安全研究」系列文章,在上一篇文章里(深度 | Web3 世界的信任边界,跨链互操作性技术会影响区块链发展的未来吗?),我们详细介绍跨链互操作性技术会影响区块链发展的未来,后面的文章,我们将选取一些出名的跨链桥协议,分析其关键技术,以及可能面临的安全问题。


今天,成都链安安全研究团队将对 Nomad 跨链桥协议再次进行专业的技术分析,毕竟这个项目曾在 8 月被黑客攻击,损失约 1.9 亿美元,本次事件带给我们哪些启发,请继续往下看。


1 跨链桥 Nomad 是什么?


首先,我们先来认识本篇文章的主角——Nomad,一种用于区块链之间发送任意消息的互操作性协议。


Nomad 自称能提供安全的互操作性解决方案,旨在降低成本并提高跨链消息传递的安全性,与基于验证者的跨链桥不同,Nomad 不依赖大量外部方来验证跨链通信,而是通过利用一种 optimistic-rollup 机制,让用户可以安全地发送消息和桥接资产。


支持以下四种用户:


  • 用户:代币桥接
  • 资产发行者:多链代币的部署
  • DAO 贡献者:跨链治理
  • 开发者:跨链应用的开发


Nomad 协议包括链上智能合约和链下代理两部分,具体的架构如下图所示:



链上智能合约:


实现了 Nomad 消息传递 API,使开发人员可以将消息按序入列并访问不同链上的复制状态,主要包括 Home、Replica 合约两部分。其中,Home 合约主要负责跨链消息 Message 的格式化、维护 Message 默克尔消息树和默克尔树 root 值队列;Replica 合约是所有想要接收跨链消息的区块链都必须部署的,主要负责维护与 Home 合约对应的默克尔消息树和 root 值队列、Message 的验证和执行。


Nomad 与其他一对一的跨链通信模型不同,其允许一对 N 的广播通信。其中,Home 合约负责消息生成,而任何希望复制该消息状态或从 Home 合约接收消息的目标链都必须部署一个与该 Home 合约对应的 Replica 合约。


链下代理合约:


跨链的安全和状态中继,形成消息传递层的主干。主要包括:Updater、Relayer、Processor 和 Watcher。其中,Updater 主要负责监听原链上的 Home 合约,对 Home 合约生成的新 root 值进行签名,再生成对应的证明(包括前一个 root 的证明和新 root 的证明)并发回;Watcher 主要负责保证 Updater 的安全性,通过监听 Updater 和 Home 合约之间的交互,提交 Updater 的恶意或错误认证;Relayer 负责转发 Home 合约向许多 Replica 合约发送的 update 消息;Processor 负责验证待处理消息的有效性并将其发送给最终的接收者。


2 Nomad 的互操作性


Nomad 采取了 optimistic-rollup 跨链技术,这种 optimistic 的验证方式不同于其他需要保证大多数节点是诚实的外部验证方式(如:多签、PoS、预言机等),其仅需一个诚实的验证者即可保证整个系统的安全性。



为了实现 optimistic 验证,Nomad 引入了 Watchers 负责标记链上的欺诈行为。


3 Nomad 如何实现跨链消息传递


下图为使用 Nomad 进行消息传递的流程,以用户 Alice 使用 Nomad Token Bridge 将其以太坊账户上的 1000 USDC 发送到其在 Moonbeam 上的账户中为例(代币桥接)进行介绍:



1、Alice 在以太坊上通过 RPC 接口(如:Token Bridge GUI 或 Etherscan)构造一笔交易,这笔交易会调用以太坊上的 BridgeRouter 合约中的 send() 函数发起一笔跨链代币桥接交易。


其中,BridgeRouter 合约由 DApp 开发人员遵照 Nomad Router 合约开发规范实现,是用户在原链上进行交互的入口点,必须实现消息的接收和发送功能。下面是 Alice 调用 BridgeRouter 合约中 send 函数的示例。代码地址:


https://etherscan.io/address/0x15fda9f60310d09fea54e3c99d1197dff5107248#code



其中:


  • _token:代币地址
  • _amount:代币数量
  • _destination:目标链所在域,即远程链上的 BridgeRouter 合约
  • _recipient:接收者地址


下面我们将对代码进行具体介绍。


2、以太坊上的 BridgeRouter 合约将首先执行具体的代币发送逻辑


本例中为:


进行输入参数的基本验证,包括:发送的代币数量不能为 0,、接收者不能是 0 地址等。



首先获取跨链交易的目的 BridgeRouter 合约,如果未获取到则直接 revert。接着检查要发送的 token 是本地链的还是远程链,如果来自本地链上的则将其存储在 Router 中保管,否则将该远程链上的映射币销毁。


注意:BridgeRouter 合约可以直接销毁非原生代币,这是因为非原生代币的合约最初就是其部署的。



对要发送的消息进行格式化,使其遵守 BridgeMessage 合约规范



业务逻辑执行完毕后,BridgeRouter 合约会调用 Home 合约中的 dispatch() 函数,将要发送的消息写入队列。



3、开始执行 Nomad Bridge 的核心逻辑


Nomad Bridge 通过 Home 合约对消息进行格式化和哈希处理,并将其插入到默克尔消息树中,其中 Merkle 树是 Nomad 的核心数据结构,包含了从该 Home 发送的所有消息。


以下是 dispatch() 函数的源码:


  • _destinationDomain:目标链所在域,即目标 BridgeRouter 地址
  • _recipientAddress:接收人地址
  • _messageBody:被 BridgeMessage 格式化后的消息 body



下面将对其进行详细介绍:


1)首先校验消息的长度不能超过 2K(即 2*2**10),接着获取目标域的下一个 nonce 值并加 1,目的是为了防止重放:



2)接着会对消息进行预处理,增加了 localDomain、msg.sender、nonce 值等数据,再重新进行格式化:



  • localDomain:原链上的 BridgeRouter 合约所在域
  • _nonce:目标域的 nonce 值
  • _destinationDomain:目标链上 BridgeRouter 合约所在域
  • _recipientAddress:接收者在目标链上的地址
  • _messageBody:原始的 message 消息


3)接着将处理后的消息作为叶子节点插入到消息 Merkle tree 中:



4)重新生成新的默克尔树的 root 值,并添加到 root 值队列中:



5)发送一个 Dispatch 事件,通知 Updater 有新的消息,等待其进行签名:


  • _messageHash:被插入默克尔树的 message 叶子节点
  • leafIndex:叶子节点在默克尔树的索引,此处为 count() - 1,因为新的叶子节点已经被插入到 tree 中
  • destinationAndNonce:目标域和目标域的 nonce 值,计算方式为:((destination << 32) & nonce)
  • committedRoot:最后一次签名更新中提交的 root 值



4、Updater 对 root 值签名


当 Updater 监测到 Dispatch 事件后,Updater 调用以太坊上 Home 合约中的 update() 函数,提交被 Updater「公证」后的 root 值签名,同时更新 Home 合约中的 committedRoot 值,并且发布该签名。具体源码如下:


  • _committedRoot:当前被更新过的默克尔 root 值
  • _newRoot:新的需要被更新的 root 值
  • _signature:Updater 需要对_committedRoot 和_newRoot 两个值同时进行签名



1)为了防止 Updater 提交虚假的更新消息,所以函数会首先校验其提交消息的合法性,即_newRoot 是否包含在 root 值列中。如果该值不存在,将对 Updater 进行 slash 惩罚。



2)接着删除队列中所有包含在此次更新中的中间 root 值:



3)使用最新签名的 root 值更新 Home 合约中的状态变量 committedRoot,并提交 Update 事件;



5、Relayers 将 Update 消息发送到目标链


一旦 Home 合约提交了 update 消息,Relayers 会将消息发送到所有的链上与该 Home 相关的 Replica 合约中。由于 Relayers 是不可信的,所以也没有任何特殊权限。它仅仅是调用任意 Replica 合约中的 update() 函数更新新的 root 值,使其与 Home 合约保持一致。


注意:实际上,该函数可以被任何人调用,不只是 Relayers。



该函数将首先校验提交的 root 值是否未更新,如果是则验证_newRoot 值的有效性。验证通过,则设置新 root 值提交的时间(当前的区块时间戳 +optimistic 欺诈证明时间),最后更新 root 值。由于 Nomad Bridge 采取的是 optimistic-rollup 跨链技术,所以该函数被调用后将开启一个 7 天争议挑战期,在此期间 Watcher 可以对更新的 root 值提出质疑,具体内容见跨链桥系列第一篇文章。


扩展阅读:深度 | Web3 世界的信任边界,跨链互操作性技术会影响区块链发展的未来吗?


6、Processor 验证和执行 Message


在争议期过后,同样不可信和无任何特殊权限的 Processor,将调用 Replica 合约中的 prove() 函数,通过传入 message 对应的叶子节点信息、默克尔路径和叶子节点对应的索引去验证 message 信息的有效性。在本例中,Processor 将调用 Replica 合约中的 prove() 函数首先证明 Alice 在以太坊发送的消息是否存在于 Merkle 树中,如果存在则调用 process() 函数,该函数会将消息转发到对应的 BridgeRouter 合约中,再调用其 handle() 函数执行具体的业务逻辑。



上述 prove() 函数会首先检查更新的消息是否还未执行,如果还未执行则根据提交的 proof 计算出对应的 root 值,将其与更新后的 root 值进行对比,如果一致代表校验通过。证明 Alice 确实在原链上发送了该条消息,接着将该验证通过的结果存入 messages 中,再调用 process() 函数执行该消息,即该 message 消息发送到目标链上的对应的 BridgeRouter 合约中。



该函数会首先校验消息是否是发送到本地 BridgeRouter 中的,如果是接着验证该消息是否已经被 prove() 函数证明过。由于 process() 函数涉及到转账等敏感业务逻辑,所以需要防止其被重入。接着修改消息执行状态为已处理,调用对应的 DApp 中的 handle() 业务逻辑,该接口由 DApp 实现。


7、执行目标 DApp handle 中的具体业务逻辑


一旦 handle() 函数在 Moonbeam BridgeRouter 合约上被调用,BridgeRouter 会其所在链上执行业务逻辑。


4 Nomad 安全事件分析


2022 年 8 月 2 日,据成都链安鹰眼 - 区块链安全态势感知平台舆情监测显示,跨链通讯协议 Nomad 遭遇攻击,损失约 1.9 亿美元。现在我们再来回顾一下。



相关攻击信息,部分攻击交易如下:



被攻击合约:0xB92336759618F55bd0F8313bd843604592E27bd8


成都链安安全团队以其中一笔攻击交易(0x87ba810b530e2d76062b9088bc351a62c184b39ce60e0a3605150df0a49e51d0)进行分析:



前文的介绍中我们知道,完整的一次跨链消息传递过程为:


  • 用户在原链上调用某一 DApp 的 BridgeRouter 合约中的 send() 函数,该函数会进行简单的参数校验
  • 执行 Home 合约中的 dispatch() 函数,将其写入消息队列,并发送 Dispatch 事件
  • Updater 监测到事件后,对 root 值进行签名,并调用 Home 合约的 update() 函数更新签名,并发送 Update 事件
  • Relayers 调用 Replica 合约中的 update() 函数,将 Update 消息发送到目标链
  • Processor 调用 Replica 合约中的 prove() 函数对消息进行校验
  • 校验通过,Processor 调用 Replica 合约中的 process() 函数执行对应业务逻辑


注意:由于 Processor 是无信任的,所以实际上任何人都可以调用 process() 函数


由上图调用栈可知,攻击者直接略过了前面跨链消息的传递,直接调用了某一 Replica 合约的 process() 函数,成功提取了该 Replica 合约对应 BridgeRouter 的金库代币。那么攻击者是如何绕过 prove() 检测的呢?


前面我们分析过,Processor 调用 prove() 函数验证通过后,会将 root 值写入 messages 变量中。所以,未经 prove() 验证的 root 值对应的 messages[root]值为 0。但是由下图调用栈可知,acceptableRoot(0) 的结果竟然是 true。



跟踪到 acceptableRoot() 函数中:




由上文 Nomad 源码分析可知,confirmAt[_root]的值用于检测跨链消息是否经过了 optimistic 欺诈证明时间。此处,由于函数返回 true 值,所以 block.timestamp ≥ confirmAt[0] 且 confirmAt[0] 不为 0,这代表 confirmAt[0]在某处被初始化。Replica 合约中存在 initialize() 函数,具体源码如下:




综上,因为 _root 设置为零 (0x000000....),使得 confirmAt[_root] 等于 1,同时任意区块的 timestamp 都大于 1,导致判断恒成立,攻击者就能提取资金。因此,任何攻击者只需要复制第一个黑客的交易并使用一个未曾使用过的攻击地址将其替换,然后点击通过 Etherscan 发送,就能盗取项目资金。同时由于存在问题的是 Replica 合约,所以其对应的所有 BridgeRouter 相关 DApp 都会受到影响,因此被盗资表现出多币种的特点。

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

推荐专栏