当重试、重试、再重试导致乱序执行漏洞 - The Trail of Bits Blog
您是否曾想过,rollup 与其基础链(rollup 提交状态检查点的链)是如何通信和交互的?一个仅在基础链上有资金的用户如何与 rollup 上的合约交互?
在 Arbitrum Nitro 中,从基础链调用部署在 rollup 上的合约方法的一种方式是使用可重试交易(也称为可重试票据)。虽然此功能实现了这些交互,但它并非没有陷阱。在我们对 Arbitrum 及与其集成的合约的审查中,我们发现了使用可重试票据时的一些“footguns”(易误用点),这些点并不广为人知,应在创建此类交易时予以考虑。在本文中,我们将分享使用可重试票据如何可能导致意外的竞态条件,并导致乱序执行漏洞。更重要的是,我们为此问题创建了一个新的 Slither 检测器。现在,您不仅能够在代码中识别这些 footguns,还能测试它们。
可重试票据
在 Arbitrum Nitro 中,可重试票据促进了以太坊主网(Layer 1,L1)与 Arbitrum Nitro rollup(Layer 2,L2)之间的通信。要创建可重试票据,用户可以调用 Arbitrum rollup 的 L1 Inbox 合约上的 createRetryableTicket
,如下面的代码片段所示。当可重试票据被创建并排队时,ArbOS 将尝试通过按顺序在 L2 上执行它们来自动“兑换”它们。
|
|
createRetryableTicket
函数接口
假设发送者覆盖了 gas 成本且没有发生故障,交易将按顺序执行,最终状态是交易 B 紧接交易 A 应用后的结果。
图 1:理想情况是交易全部按顺序执行。
等等,“可重试”是什么意思?
因为任何交易都可能失败(例如,交易创建后 L2 gas 价格显著上涨,用户没有足够的 gas 来覆盖新成本),Arbitrum 创建了这些类型的交易,以便用户可以通过提供额外的 gas 来“重试”它们。失败的可重试票据将持久化在内存中,并且可以由任何手动调用 ArbRetryableTx 预编译合约的 redeem
方法的用户重新执行,赞助 gas 成本。失败的可重试票据与回退的正常交易不同,因为它不需要签署新交易即可再次执行。
此外,内存中的可重试票据可以在创建后最多一周内兑换。通过支付额外费用存储,可重试票据的寿命可以再延长一周;否则,它将在到期后被丢弃。
问题出在哪里
虽然这些类型的交易很有用——它们促进了 L2 到 L1 的通信,并允许用户在发生故障时重试交易——但它们带有陷阱,用户和开发人员可能没有意识到的风险。具体来说,可重试票据预期按提交顺序执行,但这并不总是保证发生。
在场景 1 中,交易 A 和 B 都失败并进入内存区域。应用程序的状态保持不变。
图 2:在同一交易中创建了两个可重试票据,但都失败并进入内存区域。
然而,任何人都可以在交易 A 之前手动兑换交易 B,这意味着交易将意外地乱序执行。
图 3:任何人都可以手动乱序兑换内存区域中的交易。
在场景 2 中,交易 A 失败并进入内存区域,但交易 B 成功。再次,交易乱序执行(即交易 A 根本没有执行),最终状态不是预期的。
图 4:只有交易 B 包含在最终状态中。
在场景 3 中,交易 A 成功,但交易 B 没有。这意味着交易 B 必须手动重新执行。交易可以创建多次,这意味着第二组交易 A 和 B 可以在第一组交易 B 重新执行之前提交。如果使用 Arbitrum rollup 系统的协议开发人员没有考虑到协议可能在交易 B 成功之前收到第二个交易 A 的可能性,协议可能无法正确处理这种情况。
图 5:只有交易 A 包含在最终状态中。
乱序执行漏洞
鉴于这些场景,开发人员应考虑交易可能乱序执行。例如,如果队列中的第二个交易依赖于第一个交易的完成,但由于 gas 不足失败,它在第一个交易执行之前执行,它可能回退或无法正常工作。重要的是,rollup 上的被调用者或消息接收者能够稳健地处理诸如以不同于创建顺序接收交易以及由于失败而导致的交易子集较小的情况。如果协议没有预料到可重试票据的重新排序和失败情况,协议可能会崩溃或被黑客攻击。
让我们考虑以下 L2 合约,用户可以通过它根据一些质押的代币来领取奖励。当他们决定取消质押代币时,任何他们尚未领取的奖励都会丢失:
|
|
用户可以使用 L1 处理程序中的以下逻辑为此类操作提交可重试票据:
|
|
这里预期 claim_rewards
会在 unstake
之前调用。然而,正如我们所看到的,claim_rewards
交易并不保证在 unstake
交易之前执行。如场景 1 所涵盖并在图 3 中所示,如果两个交易都失败,攻击者可以使 unstake
在 claim_rewards
之前执行,导致用户失去奖励。也可能只有第二个交易 unstake
成功,如场景 2 所示。
为了减轻此类风险,必须设计协议,使可重试票据具有独立的顺序,其中每个交易的成功不依赖于其他交易的顺序或结果。如何实现独立顺序取决于协议和给定的操作。在此示例中,claim_rewards
可以在 unstake
内部调用。
Slither 来救援
作为安全研究人员,我们始终尝试找到方法来自动发现此类问题,并在开发周期早期(例如代码审查期间)标记它们。为此,我们编写了一个 Slither 检测器,它将标记通过 Arbitrum Nitro Inbox 合约创建多个可重试票据的函数,以提醒开发人员此陷阱。发布后,您可以通过安装 Slither 并在 Solidity 项目的根目录中运行以下命令来使用此检测器:python3 -m pip install slither-analzyer==0.10.1 && slither . –detect out-of-order-retryable
。在我们的示例合约上,Slither 提供以下诊断:
|
|
结论
如果您正在开发使用可重试票据的协议,请确保您的协议能够处理我们在此概述的场景。具体来说,可重试票据的使用不应依赖于它们的顺序或成功执行。您可以使用我们的新 Slither 检测器发现潜在的乱序执行漏洞!
如果您的应用程序与 Arbitrum Nitro 组件交互,或者您正在构建具有 rollup-基础链通信功能的软件,请联系我们,了解我们如何提供帮助。
如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News
页面内容 可重试票据 等等,“可重试”是什么意思? 问题出在哪里 乱序执行漏洞 Slither 来救援 结论 近期文章 构建安全消息传递很难:对 Bitchat 安全辩论的 nuanced 看法 使用 Deptective 调查您的依赖项 系好安全带,Buttercup,AIxCC 的评分回合正在进行中! 使您的智能合约超越私钥风险 Go 解析器中意外的安全 footguns © 2025 Trail of Bits. 使用 Hugo 和 Mainroad 主题生成。