Arbitrum 桥中的消息陷阱
我不太喜欢写这篇文章。并不是说我没有其他选择。我本可以放手,继续前进。
但这对我来说不公平。对你也是。
所以我在这里,由Jaar 后台加入,这个概念验证的第 100 万次运行不会完成。
很不愿意承认消息炸弹可以销毁我的 Arbitrum 中继器中的所有 ETH。
-我怎么到这里了 ?-
以太坊以惊人的速度发展。如何搭桥的知识已经成为古老的智慧。不是吗?说不清道不明,有些朦胧,口耳相传,需要很大的信心。
尽管如此,所有 L2 都找到了在以太坊与他们的域之间建立通信的方法。
桥是一种双向通信通道,允许你在以太坊上发送消息并在 L2 上接收消息,反之亦然。但是,这些路径并不相同;它们有不同的机制、参与者和安全风险。
我发现深入研究每个 L2 项目为正确搭桥所做的假设、优化和妥协是很有趣的。但到底什么是“正确的”?
没有官方手册说明 L1 <> L2 桥必须如何操作。更别说用安全的、可直接投入生产的代码来实现了。这是我们才刚刚开始掌握的一门手艺。
它依赖于实用的直觉、明智的软件工程实践和经验。前两个,没有你希望的那么常见。最后一个,从rekt.news和 Twitter 中获得。
无论如何,问题不在于是否可以搭桥——因为它们可以。我们现在正试图找出它们安全的原因。
在所有 L2 桥中,我们讨论一下Arbitrum。我已经很熟悉Optimism的桥——相互竞争的 optimistic rollup。所以我渴望在 Arbitrum 的代码中发掘一些隐藏的宝藏。
假设 Ethereum 是安全的,而 Arbitrum 不太安全(测试版,任意升级,去中心化程度较低),用户始终可以退出 Arbitrum 并在 Ethereum 中找到避难所是至关重要的。使 L2 到 L1 消息成为可能的基础设施必须是安全的。
这就是我着手探索 Arbitrum 中 L2 到 L1 消息传递的原因。我的目标:确定桥的操作对每个相关方来说有多安全。如果确定了,我准备私下向团队披露任何相关发现。
剧透警报:它来了。虽然不是我预期的方式。
不要着急。首先,我必须介绍 Arbitrum 中 L2-to-L1 消息背后的一些想法。
这应该很快。
Arbitrum 的 L2 到 L1 消息传递在他们的文档中有简要说明。本质上,分为三个阶段:
告诉过你这很快。细节可能不是。
L2 到 L1 的通信流从 L2 上的交易开始。在交易中,签名者声明他们想在 L1 上执行一条消息。将消息视为旨在在 L1 中的帐户上执行的一段调用数据。calldata 和 target 都可以是任何东西——桥足够聪明来处理任意消息。
那么如何创建这个交易呢?
调用预编译sendTxToL1
函数ArbSys
。这是 Arbitrum 中的一个特殊合约,存储在地址0x0000000000000000000000000000000000000064
。
它的字节码是:
cast code --rpc-url $ARBITRUM_RPC 0x0000000000000000000000000000000000000064 0xfe
哈哈,“fe”在西班牙语中是“信仰”的意思。
好的,对不起,预编译。你不能那样阅读它的真实代码。这就像以太坊中的预编译ecrecover
等等。可执行代码保存在 Arbitrum 的节点中。
对于ArbSys
,它在ArbSys.go
文件中(参见SendTxToL1
函数)。但这并不重要。
ArbSys
将预编译视为黑盒更容易。并与之互动,就好像它是一个普通的合约一样。接口在ArbSys.sol
文件中定义。在那里你会找到sendTxToL1
外部函数:
/**
* @notice Send a transaction to L1
* @dev it is not possible to execute on the L1 any L2-to-L1 transaction which contains data
* to a contract address without any code (as enforced by the Bridge contract).
* @param destination recipient address on L1 * @param data (optional) calldata for L1 contract call
* @return a unique identifier for this L2-to-L1 transaction.
*/
function sendTxToL1(address destination, bytes calldata data)
external
payable
returns (uint256);
执行后,此函数将发出L2ToL1Tx
事件。它会记录一些数据,以便将来在 L1 上验证和执行消息。
event L2ToL1Tx(
address caller,
address indexed destination,
uint256 indexed hash,
uint256 indexed position,
uint256 arbBlockNum,
uint256 ethBlockNum,
uint256 timestamp,
uint256 callvalue,
bytes data
);
将这些部分放在一起,很容易构建一个在 Arbitrum 中提交 L2 到 L1 消息的脚本:
const { ethers } = require("hardhat");
async function sendMessageToL1() {
const provider = ethers.getDefaultProvider(process.env.ARBITRUM_RPC);
const arbsys = new ethers.Contract(
"0x0000000000000000000000000000000000000064",
["function sendTxToL1(address target, bytes data) payable returns (uint256)"],
new ethers.Wallet(process.env.PRIVATE_KEY, provider)
);
const target = ""; // address of an account in L1
const data = []; // calldata to execute on target
await arbsys.sendTxToL1(target, data);
}
sendMessageToL1().catch((error) => {
console.error(error);
process.exitCode = 1;
});
到目前为止,一切都很好。设置目标,设置调用数据,L2-to-L1 消息准备就绪。
提交后,您必须等待大约 1 周才能在 L1 上执行消息。这主要是由于 Arbitrum 的争议窗口期。
即使在争议窗口之后,L2 到 L1 的消息也不会在以太坊上自动执行。必须有人(例如受激励的中继)获取消息并执行它。在 L1 上发送交易。
这样我们就到达了第三个也是最后一个阶段。当心,危险在等着你。
等待已经结束。消息已准备好在 L1 上执行。
现在,中继必须设计一个交易,将消息包装在一个特殊的包中。包括桥的 L1 端接收、验证和执行它所需的额外数据。
是这样的:
-显示到目前为止解释的 L2 到 L1 消息传递流程的一部分的示意图。-
正如我们即将看到的,导致 L1 中消息执行的关键步骤发生在两个智能合约中。Outbox
和Bridge
-突出显示发件箱和桥接合同的图表-
公开Outbox
中继的主要入口点以触发消息执行。即外部executeTransaction
函数。
-显示 executeTransaction 函数的代码截图-
第一个操作对所有相关数据进行哈希处理,为消息构建一个唯一标识符。
然后,内部recordOutputAsSpent
函数验证消息是否合法,确保它尚未被执行,并将其标记为已用完。即使该executeTransaction
函数被流氓第三方中继调用,它也不应该弄乱任何参数。这要归功于在 recordOutputAsSpent
实现的验证.
最后,调用了内部executeTransactionImpl
函数。executeBridgeCall
如果你遵循它的逻辑,你最终会到达合约的内部功能Outbox
。这是将实际消息传递给Bridge
合约的地方。
-executeBridgeCall
函数的屏幕截图-
反过来,合约executeCall
的功能Bridge
执行对目标的低级调用。
-executeCall 函数的屏幕截图-
总结这些调用:
-显示桥接 L1 侧的草图,总结了发件箱和桥接合同中的调用。-
我在这里展示的所有代码都是生产中的。你可以在以太坊主网上亲眼看到。使用任何交易跟踪器,我鼓励您逐步执行来自 Arbitrum 的 L2 到 L1 消息。阅读这些合约的每一行代码。到目前为止,你可以重现我的所有声明。
并得出与我相同的结论。此代码不安全。为什么 ?
在Bridge
合约中看到对目标的外部调用,我并不感到惊讶。它必须在那里。尽管如此,还是有些不对劲。直到它点击。
我意识到 Arbitrum 中的 L2-to-L1 消息具有三个特点。尽管它们有些交织在一起,但,让我试着把它们分开。
随着我的进行,我会将它们与 Optimism 的桥进行比较。因为它的行为恰恰相反。
我想弄清楚一些事情。一个携带并执行消息的交易肯定和消息本身不一样。我们早些时候看到了这一点。执行消息只是中继交易中的许多步骤之一。
这种分离,至少对我来说,是根本性的。消息的行为及其成功或失败都不能危及转发者的工作,更不用说妥协了。
这在 Arbitrum 中并非如此。
看Bridge
合约的executeCall
函数。如果对目标的调用失败(出于任何原因),则success
标志设置为false
,并且整个交易回退。
-显示成功标志的代码屏幕截图-
Optimism 的桥恰恰相反。查看OptimismPortal
合约中的相关调用。可以在下面看到代码如何不对success
标志的值作用。它只是记录它。
-在 Optimism 中显示成功标志的代码屏幕截图-
不知道 Arbitrum 中的这种行为,尝试完成其工作的中继可能会尝试再次执行失败的 L2-to-L1 消息。然后再次。然后再次。由目标决定何时可以成功执行中继消息的交易。
这意味着 Arbitrum 中的 L2-to-L1 消息是可重试的消息。这很奇怪,因为在 Arbitrum 中只有 L1-to-L2 消息被记录为retryable。我想 L2-to-L1 消息也应该这样称呼。
我不会说这种行为本身就让我担心。虽然它确实闻起来很臭。所以我一直在挖掘。
在 L2 提交期间,发起消息的用户从不指定 L1 执行的gas限制。记住ArbSys
的sendTxToL1
函数的签名:
function sendTxToL1(address destination, bytes calldata data)
external
payable
returns (uint256);
因此,在 L1 上执行消息并不受固定gas量的明确限制。查看从Bridge
到目标的调用:
-显示没有gas限制的代码截图-
Optimism 做了什么?恰好相反。检查OptimismPortal
合约中的相关调用:
-屏幕截图 od 代码显示 Optimism 中存在gas限制-
中继受 Arbitrum 桥中缺乏明确的gas限制影响最大。
因为目标可以根据需要花费尽可能多的 gas。我知道,你可以很快反驳这一点。说明中继控制着交易的gas限制。因此,决定要花费多少gas的将是他们,而不是恶意目标。
我的反驳论点是,在实践中,广义中继可能会盲目地信任工具来设置执行交易所需的任何gas限制(例如,使用eth_estimateGas
)。特别是如果他们受到经济激励以成功执行这些交易。尤其是在没有文件警告他们存在风险的情况下。
我找不到可用于生产的中继的开源代码来完全支持这一说法。然而,我可以争辩说,如果中继使用 Arbitrum 的官方 SDK,默认情况下他们没有合理地设置 gas 限制。
在这种情况下,目标将是那些控制交易 gas 限额的人。
恶意目标可以对中继器进行破坏性攻击。通过对 L2 到 L1 的消息运行耗油量大的操作,他们可能会耗尽中继者的资金。
明智的防御措施是在执行前估算 gas。我同意,前提是来自 MEV 的经验没有提出其他建议。你如何确定合约不能识别其执行环境并改变其在模拟中的行为?如果可以,eth_estimateGas
或者eth_call
可能不是模拟任意消息传递的最安全的选择。
安全机制必须放置在桥本身中。从桥调用目标时一个固定的gasLimit
是更有效的对策。就像Optimism 一样。中继可以读取消息的参数,然后对任何 L2 到 L1 消息的gas成本建立可靠、更可预测的估计。
包括gasLimit
覆盖攻击面的重要部分。它减轻了任何类型的破坏性攻击,这些攻击试图在目标环境中消耗过多的gas。就像一个冗长的循环和其他恶作剧。然而,虽然有必要,但内部调用的固定gas限制是不够的。
攻击者的袖子下还有一个诡计。一种可以使gasLimit
修复毫无意义的方法。
Arbitrum 的桥在执行目标代码时复制返回的数据。该数据被传回Outbox
合约。根据消息的成功,数据要么被记录,要么包含在错误消息中。
-显示 Arbitrum 网桥复制返回数据的位置的代码屏幕截图-
目标控制的数据被复制到 EVM 内存。这发在 bridge 的上下文中,而不是目标。通过控制数据的大小,目标仍然可以控制消耗的gas量。即使在其代码运行完毕之后。尽管在调用时设置了任何固定的gas限制。
Optimism 的桥知道这一点。因此,它的作用恰恰相反。Arbitrum 复制所有返回的数据,而 Optimism 则不复制。
下面看看 Optimism 桥是如何使用SafeCall
库的call
函数,它执行一个低级调用而不将返回的数据复制到内存中。
-显示 Optimism 桥如何复制数据的代码屏幕截图-
这是SafeCall::call
代码:
-显示 safecall 库的代码截图-
Optimism代码中的注释一目了然。通过不复制返回数据,他们可以防止“返回炸弹”。这是什么攻击?
ExcessivelySafeCall 存储库对其进行了最好的解释:
当字节从返回数据复制到内存时,内存扩展成本被支付。这意味着当使用标准的 solidity 调用时,被调用者可以“returnbomb”调用者,强加任意的 gas 成本。
Arbitrum 的桥无法防御返回炸弹。攻击者可以诱骗中继为一条消息支付高得离谱的 gas 费用。
向调用者投放动态返回炸弹的目标可能如下所示:
pragma solidity ^0.8.0;
contract L1Target {
address private immutable root = msg.sender;
uint256 private size;
constructor(uint256 _size) {
size = _size;
}
function setSize(uint256 _size) external {
require(msg.sender == root);
size = _size;
}
fallback() external {
assembly { return(0, sload(size.slot)) }
}
}
鉴于工具中缺乏文档、意识和不安全的默认设置,发现中继面临这种威胁也就不足为奇了。这是PoC。
桥的作用是什么,不就是允许消息随意通过吗?消息不过是对用户掌握的目标的召唤,数据也由用户传递。可恨的
to.call(data)
岂止变得不可避免。---莎士比亚。
桥是否注定要承受来自外部调用的所有攻击?所有的希望都将永远消失吗?
经过数千页的思考,卡拉丁会说不。我们总是可以在软弱之前选择力量。
我们可以减轻这些威胁,放置每一个可以想象的防御机制来减少攻击面和漏洞利用影响的可能性。
桥的安全有很多方面。确保中继能够在安全的环境中可靠运行就是其中之一。至少在这一点上,Arbitrum 桥可以提供更强的安全保障。
L2 到 L1 消息的目标可以尝试对中继施加任意执行成本。中继有责任防止这种未记录的威胁。消息缺乏固定和明确的gas限制可能无法让他们安全地估计交易成本。即使有这样的安全措施,也是不够的。由于返回炸弹。
使用返回炸弹,恶意目标可以绕过内部调用的gas限制。他们可以在桥梁本身的背景下控制gas消耗。目标可以随时改变它们的行为,甚至可以尝试在执行 L2-to-L1 消息之前预先运行中继交易来设计击。
最重要的是,中继交易的成功取决于消息的成功。他们可以使中继 L2-to-L1 消息的交易由于 gas 异常而恢复。中继者仍将为这些失败的交易支付费用。不成熟的中继甚至可能会多次尝试中继失败的消息,从而导致更高的 gas 成本。
这些是我与Arbitrum分享的加强桥的具体措施:
gasLimit
参数集,然后在低级 L1 调用中使用。大约一个月前,我通过他们的 Immunefi 赏金计划与 Arbitrum 分享了这个。我将报告命名为“桥接调用中未处理故障的无限gas消耗可能会导致恶意攻击并阻止 L2 到 L1 消息传递”。首先强调返回炸弹,然后扩展到可能的攻击向量的整个范围。
他们的团队很快回复了我的说法。在短暂的来回之后,报告被忽略了。我所描述的只是“预期行为”。我猜这是……好吗?
不管怎样,至少现在我们都知道 Arbitrum 桥中的预期消息陷阱。
原文链接:https://www.notonlyowner.com/research/message-traps-in-the-arbitrum-bridge
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!