当重试、重试、再重试导致乱序执行漏洞 - 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 不应将Gas限制和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);
|
createRetryableTicket
函数接口
假设发送者覆盖了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 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
23
|
// 可重试A
IInbox(inbox).createRetryableTicket({
to: l2contract,
l2CallValue: 0,
maxSubmissionCost: maxSubscriptionCost,
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,
maxSubscriptionCost: maxSubscriptionCost,
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所示。
为了减轻此类风险,必须设计协议,使可重试票据具有独立的顺序,其中每个交易的成功不依赖于其他交易的顺序或结果。如何实现独立顺序取决于协议和给定的操作。在这个例子中,claim_rewards
可以在unstake
内部调用。
Slither来救援
作为安全研究人员,我们总是尝试找到方法来自动发现这类问题,并在开发周期早期(例如代码审查期间)标记它们。为此,我们编写了一个Slither检测器,它将标记通过Arbitrum Nitro Inbox合约创建多个可重试票据的函数,以提醒开发人员注意这个陷阱。发布后,你可以通过安装Slither并在Solidity项目的根目录中运行以下命令来使用此检测器:python3 -m pip install slither-analyzer==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-基础链通信功能的软件,请联系我们,了解我们如何提供帮助。