DeFi安全漏洞剖析:BoringBatchable库中的msg.value重用攻击

本文详细记录了作者发现并协助修复SushiSwap MISO平台中一个高风险漏洞的全过程。该漏洞源于BoringBatchable库中`msg.value`在循环委托调用中的重复使用,可能造成3.5亿美元资金风险。

两个正确的组件可能铸成大错

在构建软件时,一个常见的误解是:如果系统中的每个组件都经过单独验证是安全的,那么系统本身也是安全的。这种信念在DeFi领域体现得最为明显,可组合性对开发者来说是第二天性。不幸的是,虽然组合两个组件在大多数情况下可能是安全的,但只要有一个漏洞,就可能导致成百上千无辜用户遭受严重的财务损失。今天,我想告诉您我是如何发现并帮助修补一个危及超过10.9万ETH(按当时汇率约合3.5亿美元)的漏洞的。

初遇漏洞

上午9:42

我在Telegram上随意浏览LobsterDAO群组时,注意到@ivangbi_和@bantg之间关于SushiSwap MISO平台上一轮新融资的讨论。我通常尽量避免公开参与戏剧性事件,但我忍不住快速谷歌搜索了一下,想了解具体情况。我得到的结果对我来说并不特别有趣,但我继续深入,直觉告诉我,只要继续寻找,这里就有有趣的东西可挖。

MISO平台运营着两种类型的拍卖:荷兰式拍卖和批量拍卖。在这种情况下,融资采用的是荷兰式拍卖。自然地,我做的第一件事就是在Etherscan上打开了合约。

上午9:44

我快速浏览了荷兰式拍卖合约的参与协议,并检查了每个有趣的函数。提交函数(commitEthcommitTokenscommitTokensFrom)看起来都正确实现了。拍卖管理函数(setDocumentsetList等)也有适当的访问控制。然而,在接近底部的地方,我注意到initMarket函数没有任何访问控制,这非常令人担忧。此外,它调用的initAuction函数也不包含任何访问控制检查。

不过,我并不真的认为这是一个漏洞,因为我不认为Sushi团队会犯如此明显的错误。果然,initAccessControls函数验证了合约尚未初始化。

然而,这让我有了另一个发现。在滚动浏览所有文件时,我注意到了SafeTransfer和BoringBatchable库。我对这两个都很熟悉,并且立刻意识到BoringBatchable库引入的潜在风险。

对于不熟悉的人来说,BoringBatchable是一个混入库,旨在轻松地为导入它的任何合约引入批量调用功能。它通过为输入中提供的每个调用数据在当前合约上执行delegatecall来实现这一点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function batch(bytes[] calldata calls, bool revertOnFail) external payable returns (bool[] memory successes, bytes[] memory results) {
    successes = new bool[](calls.length);
    results = new bytes[](calls.length);
    for (uint256 i = 0; i < calls.length; i++) {
        (bool success, bytes memory result) = address(this).delegatecall(calls[i]);
        require(success || !revertOnFail, _getRevertMsg(result));
        successes[i] = success;
        results[i] = result;
    }
}

查看这个函数,它似乎实现正确。然而,我脑海深处有些不安。那时我才意识到,我过去见过非常类似的东西。

发现漏洞

上午9:47

大约一年多前的今天,我与Opyn团队进行了一次Zoom通话,试图弄清楚在一次毁灭性黑客攻击后如何恢复和保护用户资金。那次攻击本身简单但巧妙:它利用单笔ETH支付执行了多个期权,因为Opyn合约在循环中使用了msg.value变量。虽然处理代币支付需要在每次循环迭代中进行单独的transferFrom调用,但处理ETH支付只需检查msg.value是否足够。这使得攻击者可以多次重复使用同一笔ETH。

我意识到,我看到的正是同一漏洞的不同形式。在delegatecall内部,msg.sendermsg.value是持久的。这意味着我应该能够批量调用commitEth,并在每次承诺中重复使用我的msg.value,从而允许我免费参与拍卖出价。

上午9:52

我的直觉告诉我这是真的,但不确定之前我必须验证它。我快速打开Remix并写了一个概念验证。令我沮丧的是,我的主网分叉环境完全坏了。我肯定是在伦敦硬分叉期间不小心把它弄坏了。然而,考虑到面临风险的巨额资金,我没有时间可以浪费。我快速在命令行上拼凑了一个简易的主网分叉并测试了我的攻击。它成功了。

深入挖掘

上午10:13

在报告之前,我联系了我的同事Georgios Konstantopoulos,请他帮忙再看看。在我等待回复时,我回到合约中寻找提升漏洞严重性的方法。能够免费参与拍卖是一回事,但能够窃取所有其他出价则是另一回事。

我在初步扫描时注意到有一些退款逻辑,但当时没多想。现在,它是从合约中提取ETH的一种方式。我快速查看了满足什么条件才能让合约给我退款。

令我惊讶(和恐惧)的是,我发现对于超过拍卖硬顶所发送的任何ETH,都会发放退款。这甚至适用于硬顶达到之后,这意味着合约不会直接拒绝交易,而是简单地退还您所有的ETH。

突然之间,我的小漏洞变得更大了。我面对的不仅仅是一个让您出价高于其他参与者的错误。我面对的是一个3.5亿美元的漏洞。

漏洞披露

上午10:38

与Georgios验证漏洞后,我请他和Dan Robinson尝试联系Sushi的Joseph Delong。几分钟内,Joseph就回复了,我发现自己与Georgios、Joseph、Mudit、Keno和Omakase进行了一次Zoom通话。我快速向参与者通报了漏洞情况,然后他们离开去协调响应。整个通话只持续了几分钟。

上午11:10

Joseph给Georgios和我发了一个Google Meet会议室的链接。我加入时,Georgios正在向Joseph、Mudit、Keno、Omakase以及来自Immunefi的Duncan和Mitchell通报情况。我们快速讨论了下一步行动。

我们有三個選擇:

  1. 保留合约,希望没人注意到
  2. 利用该漏洞转移资金,可能使用Flashbots隐藏交易
  3. 通过购买剩余额度并立即完成拍卖来转移资金,这需要管理员权限

经过一番快速辩论,我们决定选项3是最干净的方法。我们分成不同的小组,以便分别处理通讯和操作。

准备工作

上午11:26

在操作小组内,Mudit、Keno、Georgios和我忙于编写一个简单的救援合约。我们认为最干净的方法是进行闪电贷,购买到硬顶额度,完成拍卖,然后使用拍卖所得偿还闪电贷。这不需要前期资金,非常好。

中午12:54

我们遇到了一个问题。原本应该是一次简单的救援行动,现在却变成了一个无法拆除的定时炸弹,因为还有另一场活跃的拍卖。这是一场批量拍卖,这意味着我们不能直接购买到硬顶,因为根本就没有硬顶。幸运的是,没有硬顶也意味着无法从合约中抽走ETH,因为不提供退款。

我们快速讨论了在第一份合约上执行白帽救援的利弊,最终决定,即使批量拍卖中有800万美元的承诺金额,但这800万美元没有风险,而原始荷兰式拍卖中的3.5亿美元仍然面临巨大风险。即使有人因为我们强制停止荷兰式拍卖而得到提示,并在批量拍卖中发现了这个漏洞,我们仍然可以保住大部分资金。我们决定继续行动。

下午1:36

在我们完成救援合约的工作时,我们讨论了批量拍卖的下一步。Mudit指出,即使在拍卖进行期间,也可以设置一个积分列表,并且在每次ETH承诺时都会调用它。我们立即意识到这可能是我们正在寻找的暂停功能。

我们集思广益,探讨如何利用这个钩子。立即回滚是一个明显的解决方案,但我们想要更好的方法。我考虑添加一个检查,要求每个来源在每个区块只能进行一次承诺,但我们注意到该函数被标记为view,这意味着Solidity编译器会使用静态调用操作码。我们的钩子不允许进行任何状态修改。

经过进一步思考,我意识到我们可以使用积分列表来验证拍卖合约是否有足够的ETH来匹配所做的承诺。换句话说,如果有人试图利用这个漏洞,那么承诺的数量就会超过ETH的数量。我们可以轻松检测到这一点并回滚交易。Mudit和Keno开始编写测试进行验证。

救援行动

下午2:01

通讯分组团队与操作分组团队合并,同步进度。他们已经联系了进行融资的团队,但该团队希望手动完成拍卖。我们讨论了风险,一致认为自动机器人注意到交易或能够采取任何措施的可能性很小。

下午2:44

进行融资的团队完成了拍卖,消除了直接威胁。我们互相祝贺,然后分道扬镳。批量拍卖将在当天晚些时候完成,没有引起什么轰动。除了信任圈内的人,没有人知道刚刚避免了一场多大范围的危机。

反思

下午4:03

过去的几个小时感觉一片模糊,仿佛时间根本没有流逝。我从遇到问题到发现问题用了半个多小时,披露用了20分钟,战情室会议用了30分钟,修复用了三个小时。总而言之,只用了五个小时就保护了3.5亿美元免于落入不法之徒之手。

尽管没有造成金钱损失,但我相信所有相关人员都宁愿一开始就不必经历这个过程。为此,我为您总结两个主要要点。

首先,在复杂系统中使用msg.value是很困难的。这是一个您无法更改的全局变量,并且会在委托调用中持续存在。如果您使用msg.value来检查是否收到付款,那么绝对不可以将该逻辑放在循环中。随着代码库复杂性的增长,很容易忘记这种情况发生在哪里,并意外地在错误的位置循环某些东西。尽管ETH的打包和解包很麻烦,并且引入了额外的步骤,但如果这意味着避免类似的问题,WETH与其他ERC20代币的统一接口可能是非常值得的代价。

其次,安全的组件组合在一起可能会产生不安全的东西。我之前在可组合性和DeFi协议的背景下宣扬过这一点,但这一事件表明,即使是安全的合约级组件,也可能以产生不安全合约级行为的方式混合在一起。这里没有像“检查-效果-交互”那样可以普遍适用的建议,因此您只需要意识到新组件引入了哪些额外的交互。

我要感谢Sushi贡献者Joseph、Mudit、Keno和Omakase对此问题的极其迅速的响应,以及我的同事Georgios、Dan和Jim在此过程中提供的帮助,包括审阅本文。

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