重试、重试、再重试导致的乱序执行漏洞

本文深入探讨了Arbitrum Nitro中可重试交易(retryable tickets)的潜在风险,揭示了多个交易在同一函数中创建时可能出现的乱序执行漏洞,并介绍了如何使用Slither静态分析工具检测此类问题。

当重试、重试、再重试导致乱序执行漏洞 - The Trail of Bits博客

Troy Sargent
2024年3月1日
blockchain, slither

你是否曾想过,一个rollup与其基础链(rollup提交状态检查点的链)是如何通信和交互的?一个仅在基础链上有资金的用户如何与rollup上的合约进行交互?

在Arbitrum Nitro中,从基础链调用部署在rollup上合约方法的一种方式是使用可重试交易(又称可重试票据)。虽然这一功能实现了这些交互,但它并非没有陷阱。在我们对Arbitrum及与其集成的合约的审查中,我们发现了使用可重试票据时的一些隐患,这些隐患并不广为人知,在创建此类交易时应予以考虑。在本文中,我们将分享使用可重试票据如何可能导致意外的竞争条件,并导致乱序执行漏洞。更重要的是,我们为此问题创建了一个新的Slither检测器。现在,你将不仅能够在代码中识别这些隐患,还能测试它们。

可重试票据

在Arbitrum Nitro中,可重试票据促进了以太坊主网(Layer 1,L1)与Arbitrum Nitro rollup(Layer 2,L2)之间的通信。要创建可重试票据,用户可以调用Arbitrum rollup的L1 Inbox合约上的createRetryableTicket,如下面的代码片段所示。当可重试票据被创建并排队时,ArbOS将尝试通过依次在L2上执行它们来自动“赎回”这些票据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
 * @notice 在L2收件箱中放入一条消息,如果回退,可以在固定时间内重新执行
 * @dev 所有msg.value将存入L2上的callValueRefundAddress
 * @dev 不应将gasLimit和maxFeePerGas设置为1,因为那会触发RetryableData错误
 * @param to 目标L2合约地址
 * @param l2CallValue 可重试L2消息的调用值
 * @param maxSubmissionCost 从用户L2余额中扣除的最大gas,用于支付基础提交费用
 * @param excessFeeRefundAddress gasLimit x maxFeePerGas - 执行成本将计入此地址的L2余额
 * @param callValueRefundAddress 如果可重试交易超时或取消,l2Callvalue将计入此地址的L2余额
 * @param gasLimit 从用户L2余额中扣除的最大gas,用于支付L2执行。不应设置为1(用于触发RetryableData错误的魔法值)
 * @param maxFeePerGas L2执行的价格出价。不应设置为1(用于触发RetryableData错误的魔法值)
 * @param data L2消息的ABI编码数据
 * @return 可重试交易的唯一消息编号
 */
function createRetryableTicket(
    address to,
    uint256 l2CallValue,
    uint256 maxSubmissionCost,
    address excessFeeRefundAddress,
    address callValueRefundAddress,
    uint256 gasLimit,
    uint256 maxFeePerGas,
    bytes calldata data
) external payable returns (uint256);

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必须手动重新执行。交易可以创建多次,这意味着在第一个交易B重新执行之前,可以提交第二组交易A和B。如果使用Arbitrum rollup系统的协议开发人员没有考虑到协议可能在交易B成功之前收到第二个交易A的情况,协议可能无法正确处理这种情况。

图5:只有交易A包含在最终状态中。

乱序执行漏洞

鉴于这些场景,开发人员应考虑交易可能乱序执行。例如,如果队列中的第二个交易依赖于第一个交易的完成,但由于gas不足失败,它在第一个交易执行之前执行,可能会回退或无法正常工作。重要的是,rollup上的被调用者或消息接收者能够稳健地处理诸如以不同于创建顺序接收交易以及由于失败而接收较小交易子集的情况。如果协议没有预料到可重试票据的重新排序和失败情况,协议可能会崩溃或被黑客攻击。

让我们考虑以下L2合约,用户可以通过调用它来基于一些质押的代币领取奖励。当他们决定取消质押他们的代币时,任何他们尚未领取的奖励都将丢失:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function claim_rewards(address user) public onlyFromL1 {
    // 奖励基于余额和质押期计算
    uint unclaimed_rewards = _compute_and_update_rewards(user);
    token.safeTransfer(user, unclaimed_rewards);
}

// 在取消质押之前调用claim_rewards,否则你将失去奖励
function unstake(address user) public onlyFromL1 {
    _free_rewards(user); // 清理奖励相关变量
    balance = balance[user];
    balance[user] = 0;
    staked_token.safeTransfer(user, balance);
}

用户可以使用L1处理程序中的以下逻辑为此类操作提交可重试票据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 可重试A
IInbox(inbox).createRetryableTicket({
    to: l2contract,
    l2CallValue: 0,
    maxSubmissionCost: maxSubmissionCost,
    excessFeeRefundAddress: msg.sender,
    callValueRefundAddress: msg.sender,
    gasLimit: gasLimit,
    maxFeePerGas: maxFeePerGas,
    data: abi.encodeCall(l2contract.claim_rewards, (msg.sender))
});
// 可重试B
IInbox(inbox).createRetryableTicket({
    to: l2contract,
    l2CallValue: 0,
    maxSubmissionCost: maxSubmissionCost,
    excessFeeRefundAddress: msg.sender,
    callValueRefundAddress: msg.sender,
    gasLimit: gasLimit,
    maxFeePerGas: maxFeePerGas,
    data: abi.encodeCall(l2contract.unstake, (msg.sender))
});

这里预期claim_rewards将在unstake之前被调用。然而,正如我们所看到的,claim_rewards交易并不保证在unstake交易之前执行。如场景1所述并在图3中显示,如果两个交易都失败,攻击者可以使unstakeclaim_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提供以下诊断:

1
2
3
4
5
Multiple retryable tickets created in the same function:
         -IInbox(inbox).createRetryableTicket({to:address(l2contract),l2CallValue:0,maxSubmissionCost:maxSubmissionCost,excessFeeRefundAddress:msg.sender,callValueRefundAddress:msg.sender,gasLimit:gasLimit,maxFeePerGas:maxFeePerGas,data:abi.encodeCall(l2contract.claim_rewards,(msg.sender))}) (out_of_order_retryable.sol#25-34)
         -IInbox(inbox).createRetryableTicket({to:address(l2contract),l2CallValue:0,maxSubmissionCost:maxSubmissionCost,excessFeeRefundAddress:msg.sender,callValueRefundAddress:msg.sender,gasLimit:gasLimit,maxFeePerGas:maxFeePerGas,data:abi.encodeCall(l2contract.unstake,(msg.sender))}) (out_of_order_retryable.sol#36-45)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#out-of-order-retryable-transactions
INFO:Slither:out_of_order_retryable.sol analyzed (3 contracts with 1 detectors), 1 result(s) found

结论

如果你正在开发使用可重试票据的协议,请确保你的协议能够处理我们在这里概述的场景。具体来说,可重试票据的使用不应依赖于它们的顺序或成功执行。你可以使用我们的新Slither检测器发现潜在的乱序执行漏洞!

如果你的应用程序与Arbitrum Nitro组件交互,或者你正在构建具有rollup-基础链通信功能的软件,请联系我们,了解我们如何提供帮助。

如果你喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

页面内容
近期帖子
使用Deptective调查你的依赖项
系好安全带,Buttercup,AIxCC的评分回合正在进行中!
使你的智能合约超越私钥风险成熟
Go解析器中意想不到的安全隐患
我们从审查Silence Laboratories的首批DKLs23库中学到了什么
© 2025 Trail of Bits。
使用Hugo和Mainroad主题生成。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计