摘要
2022 年 8 月 2 日,一个名为 Nomad Bridge 的跨链桥遭到攻击,导致近 2 亿美元的损失。根本原因是链上智能合约升级版本中的检查逻辑存在错误。
背景
Nomad Bridge 是一个新兴的跨链资产桥,采用基于欺诈证明(fraud-proof)的设计。其工作原理如下:
- Nomad 在每个支持的区块链上部署名为 Replica 的核心合约,作为所有跨链消息的邮箱。
- 链下代理负责中继消息并将其组织在 Merkle 树中,通过向该合约提交已签名的最新树根哈希来更新树根。
- 需要在链上确认的新消息必须同时经过
prove()和process()流程。prove()流程验证 Merkle 树中的消息和证明,然后将该消息标记为已验证(proven)。process()流程检查并执行已验证的消息,前提是该消息此前已被验证且关联的树根已获确认。
代码分析
在以太坊上,Replica 是部署在 0x5d94309e5a0090b165fa4181519701637b6daeba 的信标代理(Beacon proxy)。逻辑合约有两个版本,第一个版本部署在 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 合约此前并不知道此消息。

随后该槽位被设置为 2(即 LEGACY_STATUS_PROCESSED 状态,表示此消息已处理)。这表明存在一条无效消息绕过了 prove() 逻辑,被直接处理了。
结论
这是又一起利用映射返回值未进行正确校验的典型攻击。Solidity 开发人员在处理映射时应格外小心,以避免出现意外结果。



