本次审计评估了Across协议的智能合约,包括移除白名单机制、引入通用适配器和SpokePool。审计发现了一些低风险问题,包括rootBundle
可能被重复执行,以及Universal_SpokePool
构造函数中未验证SOURCE_CHAIN_ID
。同时,也提出了改进建议,例如添加安全联系方式、更正文档和变量名,以提高代码可读性和安全性。
TypeCross-ChainTimelineFrom 2025-03-31To 2025-04-09LanguagesSolidityTotal Issues5 (3 resolved)Critical Severity Issues0 (0 resolved)High Severity Issues0 (0 resolved)Medium Severity Issues0 (0 resolved)Low Severity Issues2 (0 resolved)Notes & Additional Information3 (3 resolved)
OpenZeppelin 审计了 across-protocol/contracts 仓库的 pull request #916 和 #926。所有更改都包含在 march-evm-audit-universal-adapter 分支中的 commit 9b58d8e。
范围包括以下文件:
contracts
├── chain-adapters
├── Universal_Adapter.sol
├── Solana_Adapter.sol
└── utilities
└── HubPoolStore.sol
├── external
└── interfaces
├── IHelios.sol
├── interfaces
└── SpokePoolInterface.sol
├── SpokePool.sol
└── Universal_SpokePool.sol
由于 Universal_SpokePool
依赖于 SP1Helios.sol
合约,因此也审计了部分合约。对 SP1Helios.sol
的完整审查包含在另一份审计报告中。
Across 系统作为一个跨链传输加速器,通过实现跨各种区块链的即时 token 传输。这是通过激励第三方用户(称为“relayer”)使用他们自己的资金在目标链上填写跨链传输请求来实现的。然后,系统会退还给 relayer 填写的金额以及对其服务的奖励。退款过程使用链的规范桥,因此 relayer 本质上是将他们的资金借出一段时间。
以太坊主网上的 HubPool
合约是系统的核心及其流动性中心,而 SpokePool
合约部署在每个受支持的 L2 链上。SpokePool
合约既可以是传输请求的入口点,也可以是填写的目的地。HubPool
能够通过 Adapter 合约的通用接口向任何 SpokePool
发送消息,以便发送有关 SpokePool
之间资金再平衡、relayer 退款或慢速执行填写的指令。有关系统功能的更多详细信息,请参见以前的报告。
本次审查考虑了代码库中的两种更改:
SpokePool.sol
中删除 token/route 白名单,包含在 pull request #926 中。Universal_Adapter
和 Universal_SpokePool
,包含在 pull request #916 中。通过删除 SpokePool
合约的 deposit 函数中的相关检查,并将 enabledDepositRoutes
映射标记为已弃用,从而删除了从源到目标的 token 路由的白名单。作为一项对策,保护免受填写无价值 token 存款的责任转移到系统的链下组件。本质上,仅当 HubPool
中的 PoolRebalanceRoutes
映射包含已存入 token 的某些路由时,填写才会以不同于已存入 token 的退款 token 退款。否则,relayer 将被迫以存款来源链上的已存入 token 和金额退款。UMIP-179
将会更新,以正式指定这些规则。
Universal_Adapter
和 Universal_SpokePool
到目前为止,Across 协议支持的任何区块链都必须在 L1 上部署自己的 Adapter 合约,并在 L2 链上部署 SpokePool
合约。Adapter 合约负责使用支持每个 L2 链与 L1 通信的特定 L1 基础设施来 relay L1 -> L2 消息或 token 转移。这些消息允许 HubPool
与 SpokePool
通信,通常包括有关池再平衡、relayer 退款和慢速填写指示的 rootBundles
数据,以及 HubPool
合约的所有者 relay 的任何消息。
Universal_Adapter
和 Universal_SpokePool
允许为 EVM L2 链提供通用接口和跨链通信机制,而不是使用 L2 链的特定基础设施。本质上,只需要在 L1 上一个 Universal_Adapter
,以及新的 HubPoolStore
合约,以便与 Universal_SpokePool
支持的任何 L2 链进行通信。
这是通过 SP1 零知识 VM (zkVM) 和 Helios 轻客户端在单个合约 SP1Helios
中组合来实现的。本质上,通过在 SP1 中运行 Reth 和 Revm,可以生成以太坊区块执行的 zk 证明。然后,在 Universal_SpokePool
支持的每个 L2 链上,都会部署一个 SP1Helios
合约。SP1Helios
充当 L2 区块链上的 L1 轻客户端,可以在其中提交和验证 SP1 区块执行证明,并通过这种方式与 L1 synchronize。
从高层次上讲,L1 -> L2 通信是通过促进 Universal_Adapter
、Sp1Helios
、HubPoolStore
和 Universal_SpokePool
来实现的,如下所示。当 HubPool
将消息 relay 到 Univeral_Adapter
时,消息数据 存储在 HubPoolStore
合约的特定存储槽中。HubPoolStore
部署在 L1 上,是所有要 relay 到任何 L2 Universal_SpokePool
的消息的公共存储点。
在 SP1Helios
中,Helios 代码已扩展,以便 ProofOutputs
还包括 L1 合约的可验证存储槽值数组。在每次更新操作时,包含在 proof 输出中的所有存储槽值 都存储在 SP1Helios
中。在最后一步,当触发 L2 链上 Universal_SpokePool
中的 executeMessage
函数时,消息的有效性 通过调用 SP1Helios
并检查存储的数据来验证。
根据 pull request #916,该系统还兼容将 RiscZero 与 Helios 轻客户端相结合的替代零知识设置。虽然没有审查 RiscZero + Helios 合约的特定实现,但预计它会实现与 SP1Helios
合约相同的 IHelios
接口。因此,只要底层轻客户端合约遵守预期的接口,Universal_SpokePool
就可以保持与 zkVM 无关。
本次审计是在某些信任假设下进行的,这些假设涉及系统中某些特权角色的行为以及系统依赖的链下组件的行为。
更具体地说,由于已经放弃了源/目标 token 的白名单,因此必须确保不使用合法的 token 填写无价值的已存入 token。UMA 团队已告知我们,链下填写相关规范将更新,以便检查 HubPool
中 PoolRebalanceRoutes
映射中的输入 token 以及请求的目标链 ID。如果它没有映射到输出 token,那么 relayer 将被迫以存款来源链上的已存入 token 和金额退款。我们相信,此规范将确实得到执行,直到部署更改。
此外,为了使 Universal_SpokePool
合约正常运行,应经常将验证 HubPoolStore
中存储更新的 SP1Helios
合约更新到最新的 L1 状态。我们相信,负责这些更新的 PROPOSER_ROLE
实体将执行一致的更新。
此外,假设 Universal_SpokePool
合约将部署在不同链上的不同地址。此假设对于减轻涉及管理消息的重放攻击至关重要。由于存储在 HubPoolStore
合约中的管理消息针对特定地址而不是特定链,因此在多个链上部署相同的合约地址将允许恶意行为者在一个链上重放用于另一个链的管理消息。通过确保每个 Universal_SpokePool
实例都部署在每个链的唯一地址上,该系统可以避免此类漏洞。
在部署了 Universal_SpokePool
的链中,有两个特权角色能够触发关键功能:
HubPool
合约的所有者能够在 Universal_SpokePool
合约中两次执行 rootBundle
。这是可能的,因为 HubPool
合约中的特殊 onlyOwner relaySpokePoolAdminFunction
与用于在 HubPoolStore
合约中存储管理消息的 distinct nonce counter 结合使用。本质上,如果管理员 relay 通过非所有者用户通过 HubPool.executeRootBundle
已经 relay 到 Universal_SpokePool
的执行消息,则管理员能够两次触发 rootBundles
执行。SP1Helios
合约已超过 ADMIN_UPDATE_BUFFER
时间量未更新,则 Universal_SpokePool
的所有者能够 执行敏感的、访问受控的 SpokePool 操作。这仅在 SP1Helios
很长时间未更新的紧急情况下才有用,本质上会暂停 rootBundles
的执行。我们相信 ADMIN_UPDATE_BUFFER
将设置为接近 SP1Helios
更新阈值 的值,以尽可能限制所有者的行动自由。我们相信,上述特权角色将以协议及其用户的最佳利益行事。
rootBundle
HubPool
合约能够通过为此 L2 指定的 adapter 合约向任何 L2 SpokePool 发送跨链消息。在两种情况下,HubPool
会向一个或多个 L2 SpokePool 发送跨链消息。首先,允许 HubPool
的所有者 发送 任意消息到 SpokePool。其次,任何用户都可以通过将 rootBundle
作为消息 relay 到 SpokePool 来启动 L2 上的 rootBundle
执行。在这两种情况下,消息都 通过 adapter 合约 relay。
当使用 Universal_Adapter
合约 relay L1 -> L2 消息时,它确保消息数据 存储 在 HubPoolStore
中。反过来,在 HubPoolStore
中,存储 relay 数据的存储槽取决于 msg.sender
。本质上,由 HubPool
所有者 relay 的消息被赋予一个计数器 uuid
作为 nonce,而由任何其他用户 relay 的消息被赋予 挑战期结束时间戳作为 nonce。
因此,在 HubPool
的所有者在 L1 上为某些特定 L2 SpokePool 触发 rootBundle
的执行,然后用户将相同的 rootBundle
relay 到另一个 L2 SpokePool 的情况下,rootBundle
的数据将在 HubPoolStore
中存储两次,在两个不同的槽中。这将允许对所有者也触发消息 relay 的 SpokePool 执行 rootBundle
中包含的 L2 操作两次。
考虑不允许在 HubPoolStore
的不同存储槽中存储相同的 rootBundle
数据,或者清楚地记录上述情况以及有关 HubPool
所有者的信任假设。
更新: 已确认,未解决。该团队表示:
加粗这是按设计进行的。我们希望管理员能够 relay SpokePool 上已经存在的(或将来会存在的)
rootBundle
。加粗
SOURCE_CHAIN_ID
Universal_SpokePool
合约 与 SP1Helios
合约集成,用于验证以太坊信标链状态更新,使用 SP1 零知识证明。SP1Helios
合约具有一个 immutable SOURCE_CHAIN_ID
,通常对于以太坊设置为 1
。但是,此设置可能会允许验证来自其他链的更新,从而引入可能的配置错误风险。
Universal_SpokePool
的构造函数不验证 SP1Helios
合约的 SOURCE_CHAIN_ID
,而是假设其正确性。这种缺乏验证可能会导致接受来自意外链的更新,从而损害池的完整性。
为了减轻这种情况,建议在 Universal_SpokePool
构造函数中添加一个验证步骤,以确保 SP1Helios
合约的 SOURCE_CHAIN_ID
与预期的链 ID 匹配。这将通过验证来自正确源链的更新来防止配置错误并确保数据完整性。
更新: 已确认,未解决。该团队表示:
加粗我们的目标是拥有 IHelios 合约的最小必需接口。我们可能希望将此
helios
合约与其他也需要实现 SOURCE_CHAIN_ID 的实现进行交换。本质上,HubPool 的管理员负责检查Universal_SpokePool
是否已正确配置为使用从正确源链读取状态的 IHelios 合约。此检查应在执行任何管理操作到HubPool.setCrossChainContracts
并正式“启用”此 spoke pool 之前进行。加粗
在智能合约中提供特定的安全联系人(例如电子邮件或 ENS 名称)可以极大地简化个人在代码中发现漏洞时进行通信的过程。这种做法非常有益,因为它允许代码所有者指定漏洞披露的通信渠道,从而消除了因缺乏如何进行通信而导致沟通不畅或无法报告的风险。此外,如果合约包含第三方库并且这些库中出现错误,则其维护人员可以更轻松地联系到有关该问题的合适人员并提供缓解说明。
在整个代码库中,存在没有安全联系人的合约:
考虑在每个合约定义上方添加包含安全联系人的 NatSpec 注释。建议使用 @custom:security-contact
约定,因为它已被 OpenZeppelin Wizard 和 ethereum-lists 采用。
更新: 在 pull request #951 中已解决。
在整个代码库中,发现了多个误导性文档的实例:
verifiedProofs
映射的 内联文档 可以澄清。虽然它目前描述了映射的内容,但它没有准确地反映出映射键对应于 nonce 本身(而不是 nonce 的哈希),它映射到存储在 HubPoolStore
中的 calldata 的哈希。validateInternalCalls
修饰符和 _requireAdminSender
函数的 内联文档 引用了 receiveL1State
函数。但是,此函数在代码库中不存在,应替换为 executeMessage
。考虑更正上述注释以提高代码库的整体清晰度和可读性。
更新: 在 pull request #952 中已解决。
在整个代码库中,发现了一些误导性或不清楚的变量名实例,这可能会阻碍理解并在开发和审查期间引起混淆:
HubPoolStore
合约中,relayMessageCallData
映射 将 nonce 与 L2 上执行的 calldata 的哈希相关联。然后使用 getStorageSlot
函数 在特定区块号检索此映射中存储的值。但是,分配给 getStorageSlot
结果的变量名为 slotValueHash
,这可能会产生误导。该函数返回存储槽的原始值,而不是存储槽值的哈希,这可能会造成混淆。考虑将 slotValueHash
重命名为 slotValue
以更好地反映其真实内容。此外,更新文档以阐明此值对应于 L2 calldata 的哈希,因为它最初存储在 relayMessageCallData
映射中。Universal_SpokePool
合约中,verifiedProofs
映射实际上并不存储证明。相反,它将每个 nonce 映射到一个布尔值,以指示与此 nonce 链接的 calldata 是否已执行,以防止重放攻击。考虑重命名上述高亮显示的变量,以更准确地反映其目的和内容,从而提高代码可读性并减少潜在的误解。
更新: 在 pull request #952 中已解决。
作为可扩展的跨链传输系统,Across 协议不断发展,从而可以在以太坊和各种 L2 链上实现快速安全的 token 传输。Relayer 使用自己的资金填写用户发起的传输,稍后通过规范桥梁报销,以太坊上的 HubPool
合约与部署在目标链上的 SpokePool
合约协调流动性和消息传递。
本次审计审查了该协议的最新更新,包括删除 token 和路由白名单,以及引入 Universal_Adapter
和 Universal_SpokePool
合约。这些更改表明了向通用化和模块化的明显推动,通过 SP1 或 RiscZero 等兼容替代方案支持 zkVM 驱动的跨链通信。这种设计反映了强大的架构远见,因为该协议旨在将 Across 支持扩展到其他基于 EVM 的 L2。
感谢 Risk Labs 团队在此过程中做出快速响应并提供详细的背景信息。
- 原文链接: blog.openzeppelin.com/ev...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!