摘要
2022年8月2日,名为Nomad Bridge的跨链桥遭到攻击,损失近2亿美元。根本原因是链上智能合约升级版本中的检查不正确。
背景
Nomad Bridge是一个新兴的跨链资产桥,采用欺诈证明设计。其工作原理如下:
- Nomad在每个支持的区块链上部署一个名为Replica的核心合约,作为任何跨链消息的邮箱。
- 链下代理以Merkle树的形式中继和安排跨链消息,并通过向此合约发布签名的新的树根哈希来更新树根。
- 需要在链上确认的新消息必须经过
prove()和process()这两个过程。prove()过程验证Merkle树中的消息和证明,然后将消息标记为已证明。process()过程在消息先前已证明且关联的树根已确认的情况下,检查并执行消息。
代码
在以太坊上,Replica是一个Beacon代理,部署在0x5d94309e5a0090b165fa4181519701637b6daeba。该逻辑合约有两个版本,第一个版本部署在0x7f58bb8311db968ab110889f2dfa04ab7e8e831b,第二个版本部署在0xb92336759618f55bd0f8313bd843604592e27bd8。
我们首先检查逻辑合约的旧版本,特别是process()函数:
function process(bytes memory _message) public returns (bool _success) {
bytes29 _m = _message.ref(0);
// 确保消息是为该域设计的
require(_m.destination() == localDomain, "!destination");
// 确保消息已被证明
bytes32 _messageHash = _m.keccak();
require(messages[_messageHash] == MessageStatus.Proven, "!proven");
// 检查重入锁
require(entered == 1, "!reentrant");
entered = 0;
// 更新消息状态为已处理
messages[_messageHash] = MessageStatus.Processed;
我们只展示了函数的一部分。在这个代码片段中,计算了消息哈希,并将哈希与messages映射进行检查,以确保该消息先前已证明,然后进行重入检查,最后更新消息状态。
我们还简要回顾了旧的prove()函数:
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// 确保消息未被证明或处理
require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
// 根据证明计算预期的根
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// 如果根有效,则将状态更改为Proven
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = MessageStatus.Proven;
return true;
}
return false;
}
这里没什么特别的:重复检查,计算树根,如果可接受,则标记为已证明。因此,在Replica合约的旧版本中,所有已证明的消息都有一个特殊标记(MessageStatus.Proven = 1)。
然后,我们检查逻辑合约的第二个版本。对于新版本,我们首先检查prove()函数:
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// 确保消息未被处理
// 注意:这允许在新的根下重新证明。
require(
messages[_leaf] != LEGACY_STATUS_PROCESSED,
"already processed"
);
// 根据证明计算预期的根
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// 如果根有效,则将状态更改为Proven
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = _calculatedRoot;
return true;
}
return false;
}
我们立即注意到这里有一个重大变化:出于某种原因,开发人员决定将计算出的根记录为已证明的状态,而不是一个特殊标记。对于这个函数来说这是可以的,因为Merkle树根哈希被确保不为零。这也是合理的,因为一旦树根被确认,任何用该树根证明的新消息都已准备好执行。
然后我们检查新版本中的process()函数:
function process(bytes memory _message) public returns (bool _success) {
// 确保消息是为该域设计的
bytes29 _m = _message.ref(0);
require(_m.destination() == localDomain, "!destination");
// 确保消息已被证明
bytes32 _messageHash = _m.keccak();
require(acceptableRoot(messages[_messageHash]), "!proven");
// 检查重入锁
require(entered == 1, "!reentrant");
entered = 0;
// 更新消息状态为已处理
messages[_messageHash] = LEGACY_STATUS_PROCESSED;
我们注意到messages[_messageHash]这一行。检索不存在的映射条目返回零是一个常见的陷阱。 在这种情况下,这意味着与该消息哈希关联的Merkle树根为零。我们需要进一步检查这种零值的结果。因此,我们应该仔细检查新的acceptableRoot()函数。
function acceptableRoot(bytes32 _root) public view returns (bool) {
// 这是为了向后兼容之前版本证明/处理的消息
if (_root == LEGACY_STATUS_PROVEN) return true;
if (_root == LEGACY_STATUS_PROCESSED) return false;
uint256 _time = confirmAt[_root];
if (_time == 0) {
return false;
}
return block.timestamp >= _time;
}
基本上,这个函数检查confirmAt映射,以查看Merkle树根是否已被确认。
不幸的是,在Replica合约的两个版本中,零哈希在初始化时都被设置为1:
function initialize(
uint32 _remoteDomain,
address _updater,
bytes32 _committedRoot, // 初始化时此值为零
uint256 _optimisticSeconds
) public initializer {
__NomadBase_initialize(_updater);
// 设置存储变量
entered = 1;
remoteDomain = _remoteDomain;
committedRoot = _committedRoot;
// 预先批准已提交的根。
confirmAt[_committedRoot] = 1;
_setOptimisticTimeout(_optimisticSeconds);
}

在Replica合约的旧版本中,这完全没问题:在prove()中,任何树根哈希都不可能为零,因此将confirmAt映射中的零哈希条目设置为1是安全的。
然而,在新版本中,对于新消息,messages[_messageHash]返回零。然后acceptableRoot会访问confirmAt映射中的零哈希条目,然后返回true。
攻击
从上面的代码分析中,我们知道任何以前未见过的消息都可以通过验证逻辑并得到执行。所以只需要伪造一个消息并调用process()。
有趣的是,在这个合约中第一次调用process()函数是在两天前(在块15249565)在0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0。
在下图所示中,我们可以看到该消息的messages状态变量的存储槽最初为零,这意味着Replica合约之前并不知道此消息。

然后该槽被设置为二(即LEGACY_STATUS_PROCESSED状态,表示该消息已被处理。这表明一条无效消息绕过了prove()逻辑并被直接处理。
结论
这是另一个利用了从映射中检索的未检查返回值来执行的经典攻击。Solidity开发者在处理映射时应特别注意,以避免意外结果。



