当重试、重试、再重试导致乱序执行漏洞
重试交易票据
在Arbitrum Nitro中,重试交易票据(retryable tickets)促进了以太坊主网(Layer 1,L1)与Arbitrum Nitro汇总链(Layer 2,L2)之间的通信。用户可以通过调用Arbitrum汇总链L1收件箱合约中的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将计入此地址
* @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);
|
假设发送方覆盖了gas成本且未发生故障,交易将按顺序执行,最终状态是交易A之后立即应用交易B的结果。
图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汇总系统的协议开发人员没有考虑到协议可能在交易B成功之前收到第二次交易A的可能性,协议可能无法正确处理这种情况。
图5:只有交易A包含在最终状态中。
乱序执行漏洞
鉴于这些场景,开发人员应考虑交易可能乱序执行。例如,如果队列中的第二个交易依赖于第一个交易的完成,但由于gas不足故障而在第一个交易执行之前执行,它可能会回退或无法正常工作。重要的是,汇总上的被调用方或消息接收方能够稳健地处理诸如以不同于创建顺序接收交易以及由于故障而接收较小交易子集的情况。如果协议没有预料到重试交易票据的重新排序和故障情况,协议可能会崩溃或被黑客攻击。
让我们考虑以下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中所示,如果两个交易都失败,攻击者可以使unstake在claim_rewards之前执行,导致用户丢失奖励。也可能只有第二个交易unstake成功,如场景2所示。
为了减轻此类风险,必须设计协议,使重试交易票据具有独立的顺序,其中每个交易的成功不依赖于其他交易的顺序或结果。独立顺序的实现方式取决于协议和给定的操作。在此示例中,可以在unstake内部调用claim_rewards。
Slither来救援
作为安全研究人员,我们始终尝试找到自动发现此类问题并在开发周期早期(例如代码审查期间)标记它们的方法。为此,我们编写了一个Slither检测器,它将标记通过Arbitrum Nitro收件箱合约创建多个重试交易票据的函数,以提醒开发人员注意此陷阱。发布后,您可以通过安装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组件交互,或者您正在构建具有汇总-基础链通信功能的软件,请与我们联系以了解我们如何提供帮助。