本文是一篇针对 Across 协议的智能合约代码审计报告,该协议旨在实现以太坊 L1 和 L2 链之间的快速 token 转移。审计发现了包括关键和中等严重程度漏洞在内的多个问题,并提出了改进代码清晰度、可读性和健壮性的建议。主要问题包括 gas token 金额的错误缩放、过时的 SafeERC20 合约以及潜在的 ETH 锁定等。
TypeDeFiTimelineFrom 2024-08-28To 2024-08-30LanguagesSolidityTotal Issues13 (8 解决)Critical Severity Issues1 (1 解决)High Severity Issues0 (0 解决)Medium Severity Issues1 (0 解决)Low Severity Issues4 (2 解决)Notes & Additional Information7 (5 解决)
我们审核了 across-protocol/contracts 仓库的拉取请求 (PR) #584 和 #585。合并提交 f56146a 被用作两组变更的参考。
审计范围包括以下文件:
contracts
├── chain-adapters
│ ├── Arbitrum_CustomGasToken_Adapter.sol
│ └── Arbitrum_CustomGasToken_Funder.sol
└── SpokePool.sol
Across Protocol 旨在实现跨多个区块链网络的即时代币转账。协议的核心是以太坊主网中的 HubPool
合约,它作为所有合约的中央流动性中心和跨链管理员。该池管理在各种网络上部署的 SpokePool
合约,这些合约或启动代币存款,或作为转移的最终目的地。
该协议最近通过 PR #584 和 #585 进行了更新。
PR #584 修改了协议如何处理中继操作的独占性。它用 exclusivityPeriod
替换了 exclusivityDeadline
,确保根据原始链交易被挖掘的时间动态计算独占窗口。此更新包括:
实施上述变更的另一个原因是,现在这一阶段是相对于交易包含在区块中的时间,而不再依赖于外部时间戳。
PR #585 引入了两个新合约,专门用于增强以太坊与 Arbitrum 网络之间的协议操作:
Arbitrum_CustomGasToken_Funder
: 该合约安全存储用作跨链交易费用的气体代币的 ERC-20 代币。合约限制代币提现的能力仅限于所有者,确保对这些资金的安全管理。
Arbitrum_CustomGasToken_Adapter
: 该合约复制了现有的 Arbitrum_Adapter
功能。此外,它允许使用自定义气体代币而不是 ETH 来支付跨链消息的交易费用,从而在管理Gas成本方面提供更多灵活性。
关于审核的代码库的安全模型和信任假设,发现以下观察点:
气体代币管理: Arbitrum_CustomGasToken_Adapter
合约依赖于资金合约对气体代币的正确管理。它使用这些代币来支付跨链交易费用,并且这些代币的安全提取严格由继承 OpenZeppelin 的 Ownable
合约的合约所有者控制。这确保只有所有者可以执行提取操作。
Delegatecall 假设: 根据 Arbitrum_CustomGasToken_Adapter
合约的文档和设计,预计通过 delegatecall
调用该合约。这意味着安全实践和保护措施,包括重入保护,必须在执行 delegatecall
的合约中实现。
合约初始化: Arbitrum_CustomGasToken_Adapter
合约引入了使用自定义气体代币来支付交易费用的灵活性。这对于某些 Arbitrum L2 和 L3 环境特别有帮助。在安全模型中的一个关键假设是,在部署时设置的 Arbitrum_CustomGasToken_Adapter
合约的初始配置(例如 L2_MAX_SUBMISSION_COST
和其他关键变量)被正确且安全地定义。
抢先机会和假设: 由于协议的提案机制设计,抢先行为不被视为风险。为了与 HubPool
交互,提案者必须提交一个通过活跃性挑战期的捆绑,通常持续 2 个小时。在此期间后,提案被视为有效,协议在假设下运行,即没有恶意根捆绑会被验证。此外,给定一组区块范围的情况下,只能存在一个有效提案,确保没有竞争的有效提案能够重叠。严格的验证规则,结合提案者对于提案实际内容的控制仅限于在预定义约束内设置区块范围,极大地减少了抢先或操控提案结果的可能性。
这种结构有效地减轻了抢先行为的风险,因为最终提案在活跃性周期后被锁定并公开验证。由于提案的执行,包括任何预资金的代币(如自定义气体代币),仅在提案被视为有效后发生,因此在此阶段没有抢先的机会。
如上所述,在 PR #585 中引入的合约中,仅有的特权角色在于 Arbitrum_CustomGasToken_Funder
合约。所有者应该是 HubPool
合约,该合约将 delegatecall
Arbitrum_CustomGasToken_Adapter
合约,触发从资金合约中提取资金的逻辑。
相比之下,Arbitrum_CustomGasToken_Adapter
合约没有包含任何直接的访问控制机制。它被设计为通过 delegatecall
调用,这意味着访问控制和权限应由执行 delegatecall 的合约(例如 HubPool
)强制实施。
Arbitrum_CustomGasToken_Adapter
合约 是一个适配器,旨在处理目标链使用自定义代币来收取Gas费用的情况。这样的代币可能具有非标准的小数,因此必须进行适当的缩放,以正确计算金额。
该合约的 _pullCustomGas
函数 由 relayTokens
和 relayMessage
函数使用,以计算支付所选操作所需的气体代币数量。该函数首先在 getL1CallValue
函数 中计算应付的金额,然后 从 CUSTOM_GAS_TOKEN_FUNDER
合约中提取这笔金额(一个专门保存所需气体代币资金的合约)。
getL1CallValue
函数使用以下公式计算所需的气体代币数量:
return L2_MAX_SUBMISSION_COST + L2_GAS_PRICE * l2GasLimit
在上述公式中:
L2_MAX_SUBMISSION_COST
是用于支付 L1->L2 操作的基本提交费用的气体分配量。根据 AbsInbox
Arbitrum 合约,该数额必须用 18 小数位表示。L2_GAS_PRICE
是用于立即 L2 执行尝试的气体价格。在同一 AbsInbox
合约中,该数量的缩放也应为 18 小数位。l2GasLimit
是一个以纯气体单位表示的参数,取决于正在执行的操作(relayTokens
或 relayMessage
)。如上所述,从 getL1CallValue
函数返回的金额是以 18 小数位表示,并且直接用于从自定义气体代币资金中提取资金。然而,这不正确,因为自定义气体代币可能具有不同的缩放。
例如,如果自定义气体代币为具有 6 位小数的 USDC,则收取的金额缩放因子为 10**(18 - 6)
。结果是 Arbitrum_CusstomGasToken_Adapter
逻辑将提取的金额远大于所需金额。更糟的是,这样的金额随后直接传递给 L1 Arbitrum inbox 合约的 createRetryableTicket
函数,该函数负责 提取 从调用合约中提取这些代币,从而使所需金额过高。
考虑通过自定义气体代币的小数位数量来缩放 getL1CallValue
函数返回的金额,使用缩放值从资金合约中提取正确金额,并将其传递给 createRetryableTicket
函数。
更新: 已在 拉取请求 #589 中解决。Risk Labs 团队表示:
很好的发现。
SafeERC20
合约未先批准为零一些 ERC-20 代币(如以太坊主网的 USDT)在尝试从现有非零值更改允许量时不能正常工作。Arbitrum_CustomGasToken_Adapter
合约 目前 使用的是过时的 OpenZeppelin SafeERC20
库版本,该版本在更新允许量到新值之前不将支出者的允许量设置为零。
为降低需要在任何更新之前将允许量重置为零的代币可能出现的问题,建议将 OpenZeppelin SafeERC20
库更新到最新版本。这将确保与那些强制要求先将允许量设置为零的代币兼容,从而防止相关问题。
更新: 已确认,未解决。Risk Labs 团队表示:
更新到最新的 OZ 版本将引入许多合约变化和对等依赖更新,我们认为这不在本次审核的范围内。例如,参见合约变化 这里。如你所见,这很复杂。
关于批准问题,我们认为你们应该更明确地指出为什么我们应该将批准设置为 0。我们不得不阅读发行说明才能得出 此 线程,才了解到某些代币(如 USDT)需要将批准首先设置为 0。
这对于我们来说是有道理的,但我们认为我们不需要进行任何更改。合约没有其他方式来设置允许量(除非所有者通过管理操作手动执行),并且允许量预计在函数调用中完全使用。 因此,我们希望在没有交易的情况下,允许量为 0。这就是我们认为我们在处理具有这种批准逻辑的代币时是安全的原因。
Pragma 指令应该固定,以明确标识合约将编译的 Solidity 版本。
在整个代码库中,发现多个浮动 pragma 指令实例:
Arbitrum_CustomGasToken_Adapter.sol
中的 solidity ^0.8.0
浮动 pragma 指令。Arbitrum_CustomGasToken_Funder.sol
中的 solidity ^0.8.0
浮动 pragma 指令。考虑使用固定的 pragma 指令。
更新: 已确认,未解决。Risk Labs 团队表示:
_这符合现有风格,我们更喜欢在 Solidity 文件中使用浮动 pragma,并在
hardhat.config
中设置确切版本。对于Linea_SpokePool
、Arbitrum_SpokePool
和SpokePoolVerifier
等某些合约,我们需要固定版本,因此我们在这里设置。_
在整个代码库中,发现多个缺少文档字符串的实例:
Arbitrum_CustomGasToken_Adapter.sol
中,FunderInterface
接口Arbitrum_CustomGasToken_Adapter.sol
中,withdraw
函数Arbitrum_CustomGasToken_Adapter.sol
中,L2_CALL_VALUE
状态变量Arbitrum_CustomGasToken_Adapter.sol
中,RELAY_TOKENS_L2_GAS_LIMIT
状态变量Arbitrum_CustomGasToken_Adapter.sol
中,RELAY_MESSAGE_L2_GAS_LIMIT
状态变量Arbitrum_CustomGasToken_Adapter.sol
中,L1_INBOX
状态变量Arbitrum_CustomGasToken_Adapter.sol
中,L1_ERC20_GATEWAY_ROUTER
状态变量Arbitrum_CustomGasToken_Adapter.sol
中,CUSTOM_GAS_TOKEN_FUNDER
状态变量Arbitrum_CustomGasToken_Funder.sol
中,Arbitrum_CustomGasToken_Funder
合约考虑彻底记录所有合约公共 API 部分的函数(及其参数)。实现敏感功能的函数,即使不是公开的,也应明确记录。在编写文档字符串时,考虑遵循 以太坊自然规范格式 (NatSpec)。
更新: 已在 拉取请求 #592 中解决。
require
语句如 Solidity 0.8.4 的 官方 发布所述,利用自定义错误可以减少运行时和部署成本,正如以下基准所示,同时改善错误处理的清晰性。
考虑将所有 require
语句 替换为自定义错误。
更新: 已在 拉取请求 #593 中解决。
Arbitrum_CustomGasToken_Adapter
合约将 relayMessage
和 relayTokens
函数标记为 payable
。然而,这些函数设计为使用自定义气体代币而不是 ETH 支付Gas费用。因此,payable
属性是不必要的,因为在这些交易中不使用 msg.value
(ETH)。将 relayMessage
和 relayTokens
函数标记为 payable
可能导致潜在问题,即发送到合约的 ETH 将被锁定并无法访问,因为在这些函数执行期间 ETH 将变得不可用或不可退还。
为确保 ETH 不会意外地被锁定在合约中,考虑从两个函数中删除 payable
属性。如果由于 HubPool
需要在保留 payable
关键字的同时执行 delegatecall
而无法删除,该行为应考虑进行文档说明。
更新: 已确认,未解决。Risk Labs 团队表示:
_我们不能这样做,因为其他适配器实现这些函数时确实使用了
msg.value
。仅从Arbitrum_CustomGasToken_Adapter
中删除它会产生以下编译时错误:_
TypeError: Overriding function changes state mutability from "payable" to "nonpayable".
--> contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol:195:5:
|
195 | function relayMessage(address target, bytes memory message) external override {
| ^ (Relevant source part starts here and spans across multiple lines).
Note: Overridden function is here:
--> contracts/chain-adapters/interfaces/AdapterInterface.sol:22:5:
|
22 | function relayMessage(address target, bytes calldata message) external payable;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Overriding function changes state mutability from "payable" to "nonpayable".
--> contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol:221:5:
|
221 | function relayTokens(
| ^ (Relevant source part starts here and spans across multiple lines).
Note: Overridden function is here:
--> contracts/chain-adapters/interfaces/AdapterInterface.sol:34:5:
|
34 | function relayTokens(
| ^ (Relevant source part starts here and spans across multiple lines).
Error HH600: Compilation failed
此外,适配器旨在被
HubPool
委托调用,该合约确实具有回退函数,所以这根本不是风险。
在智能合约中提供特定的安全联系方式(例如电子邮件或 ENS 名称)可以显著简化识别代码中的漏洞时的沟通过程。这一做法非常有利,因为它允许代码所有者决定漏洞披露的沟通渠道,消除了因不知该如何做而导致的错误沟通或未报告的风险。此外,如果合约包含第三方库并且出现了 bug,相关维护者更容易联系到合适的人以解决问题并提供缓解方案。
在整个代码库中,发现多个缺少安全联系方式的合约实例:
考虑在每个合约定义上方添加一个包含安全联系方式的 NatSpec 注释。建议使用 @custom:security-contact
约定,因为它已被 OpenZeppelin Wizard 和 ethereum-lists 采用。
更新: 已在 拉取请求 #601 中解决。
代码库中使用的非显式导入可能会降低代码的清晰度,并可能导致本地定义和导入变量之间的命名冲突。这在同一 Solidity 文件中存在多个合约或继承链较长时尤其相关。
在整个代码库中,发现多个全局导入的实例:
Arbitrum_CustomGasToken_Adapter.sol
中的 导入 "./interfaces/AdapterInterface.sol";Arbitrum_CustomGasToken_Adapter.sol
中的 导入 "@openzeppelin/contracts/token/ERC20/IERC20.sol";Arbitrum_CustomGasToken_Adapter.sol
中的 导入 "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";Arbitrum_CustomGasToken_Adapter.sol
中的 导入 "../external/interfaces/CCTPInterfaces.sol";Arbitrum_CustomGasToken_Adapter.sol
中的 导入 "../libraries/CircleCCTPAdapter.sol";Arbitrum_CustomGasToken_Funder.sol
中的 导入 "@openzeppelin/contracts/access/Ownable.sol";Arbitrum_CustomGasToken_Funder.sol
中的 导入 "@openzeppelin/contracts/token/ERC20/IERC20.sol";Arbitrum_CustomGasToken_Funder.sol
中的 导入 "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";遵循 clearer code is better code 原则,考虑使用命名导入语法 ( import {A, B, C} from "X"
) 明确声明正在导入哪些合约。
更新: 已在 拉取请求 #600 中解决。
Arbitrum_CustomGasToken_Adapter
合约的 L2_MAX_SUBMISSION_COST
和 CUSTOM_GAS_TOKEN_FUNDER
参数 在构造函数中设置,而没有任何输入验证。
考虑评估上述参数是否需要输入验证。如果需要,考虑实施验证检查以确保适配器的完整性。
更新: 已确认,未解决。Risk Labs 团队表示:
该适配器设计为由部署者硬编码,如果需要更改任何参数,则替换它。它还旨在被委托调用,因此很容易替换。我们不认为在构造函数中验证是必要的。相反,我们认为这应该是部署者添加安全部署脚本的责任。
新的 Arbitrum_CustomGasToken_Adapter
合约 是参考 Arbitrum_Adapter
合约 而设计的。然而,在将其调整为承载自定义气体代币逻辑时,与遗留路由器的向后 兼容性 被移除,使自定义气体代币适配器与 DAI 不兼容。
考虑记录当前代码库与作为其灵感来源的代码库之间的相关差异。这将有助于更好地传达代码的意图并使其更易于理解。
更新: 已确认,未解决。Risk Labs 团队表示:
_这显然不是个问题。我们在
Arbitrum_Adapter
中有一个特殊的 DAI 边缘情况是因为 DAI Arbitrum 代币桥与默认桥的接口不同。尚需观察 DAI 是否会在通过CustomGasToken_Adapter
桥接到新的 L2 时具有不同的接口。现有 DAI 代码显然无法正常工作的原因是因为该 DAI 桥使用原生代币支付 L1 到 L2 消息,而不是自定义气体代币。_
在 Arbitrum_CustomGasToken_Adapter.sol
中,发现多个不完整文档字符串的实例:
nativeToken
和 bridge
函数中,并非所有返回值都有文档说明。getL1CallValue
函数中,l2GasLimit
参数未被记录。Arbitrum_CustomGasToken_Adapter
合约的 constructor
中,_l2MaxSubmissionCost
参数未被记录。Arbitrum_CustomGasToken_Adapter
合约的第 126 行 中,文档字符串要么不清晰,要么不正确。SpokePool
合约的第 526 行 中,“reayer” 应为 “relayer”。考虑彻底记录所有公共 API 部分的函数/事件(及其参数或返回值)。在编写文档字符串时,考虑遵循 以太坊自然规范格式 (NatSpec)。
更新: 已在 拉取请求 #599 中解决。
在 SpokePool
合约的 depositV3
函数 中,getCurrentTime
函数在 第 595 行 再次被调用,尽管在 第 558 行 中已经被调用。
考虑重用缓存值,而不是重复函数调用。
更新: 已在 拉取请求 #594 中解决。
exclusivityPeriod
更新的向后兼容性问题最近的 PR #584 通过用 exclusivityPeriod
替换 exclusivityDeadline
输入引入了重大变化。此更新改变了独占截止日期的确定方式,从固定的未来时间戳更改为相对于原始链交易被挖掘时的时间段。此更改不具有向后兼容性,因为集成者现在需要传递一个相对时间段,而不是之前那样传递一个具体的未来时间戳。如果集成者未能适应此更改,存在资金被锁定很长时间的风险,可能导致重大中断。
考虑在升级说明或迁移指南中明确记录上述变化。清晰的文档将帮助集成者理解新要求,并避免因不当设置 exclusivityPeriod
输入而导致的问题。
更新: 已在 拉取请求 #595 中解决。
Across 协议可以快速实现以太坊 L1 和 L2 链之间的代币转账。用户存入资金,转发者将资金转移到目的地,存款者收到总额减去费用。位于 L1 的 HubPool
合约管理流动性,并与每个支持链上的 SpokePool
合约进行协调。另一方面,Oval 捕获了通过 DeFi 协议中的价格更新生成的预言机可提取价值 (OEV)。
该协议最近进行了一系列重大更新,作为 PR #584 和 #585 的一部分。这些更新引入了对中继独占性管理方式的更改,并增强了跨链Gas费用处理,特别是在以太坊与 Arbitrum 之间。值得注意的是,此更新现在允许跨链交易使用自定义气体代币而不是 ETH 来支付费用。
审计发现一个高严重性和一个中等严重性漏洞,以及若干低严重性漏洞。此外,还提出多项建议,以提高代码库的清晰度、可读性和稳健性。
在整个审计过程中,Risk Labs 团队为我们提供了有用的背景信息、快速而详细的解释以及宝贵的见解,帮助我们更好地理解代码库及其变更。
- 原文链接: blog.openzeppelin.com/ac...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!