两个正确的组件可能铸成大错
在构建软件时,一个常见的误解是:如果系统中的每个组件都经过单独验证是安全的,那么系统本身也是安全的。这种信念在DeFi领域体现得最为明显,可组合性对开发者来说是第二天性。不幸的是,虽然组合两个组件在大多数情况下可能是安全的,但只要有一个漏洞,就可能导致成百上千无辜用户遭受严重的财务损失。今天,我想告诉您我是如何发现并帮助修补一个危及超过10.9万ETH(按当时汇率约合3.5亿美元)的漏洞的。
初遇漏洞
上午9:42
我在Telegram上随意浏览LobsterDAO群组时,注意到@ivangbi_和@bantg之间关于SushiSwap MISO平台上一轮新融资的讨论。我通常尽量避免公开参与戏剧性事件,但我忍不住快速谷歌搜索了一下,想了解具体情况。我得到的结果对我来说并不特别有趣,但我继续深入,直觉告诉我,只要继续寻找,这里就有有趣的东西可挖。
MISO平台运营着两种类型的拍卖:荷兰式拍卖和批量拍卖。在这种情况下,融资采用的是荷兰式拍卖。自然地,我做的第一件事就是在Etherscan上打开了合约。
上午9:44
我快速浏览了荷兰式拍卖合约的参与协议,并检查了每个有趣的函数。提交函数(commitEth、commitTokens和commitTokensFrom)看起来都正确实现了。拍卖管理函数(setDocument、setList等)也有适当的访问控制。然而,在接近底部的地方,我注意到initMarket函数没有任何访问控制,这非常令人担忧。此外,它调用的initAuction函数也不包含任何访问控制检查。
不过,我并不真的认为这是一个漏洞,因为我不认为Sushi团队会犯如此明显的错误。果然,initAccessControls函数验证了合约尚未初始化。
然而,这让我有了另一个发现。在滚动浏览所有文件时,我注意到了SafeTransfer和BoringBatchable库。我对这两个都很熟悉,并且立刻意识到BoringBatchable库引入的潜在风险。
对于不熟悉的人来说,BoringBatchable是一个混入库,旨在轻松地为导入它的任何合约引入批量调用功能。它通过为输入中提供的每个调用数据在当前合约上执行delegatecall来实现这一点。
|
|
查看这个函数,它似乎实现正确。然而,我脑海深处有些不安。那时我才意识到,我过去见过非常类似的东西。
发现漏洞
上午9:47
大约一年多前的今天,我与Opyn团队进行了一次Zoom通话,试图弄清楚在一次毁灭性黑客攻击后如何恢复和保护用户资金。那次攻击本身简单但巧妙:它利用单笔ETH支付执行了多个期权,因为Opyn合约在循环中使用了msg.value变量。虽然处理代币支付需要在每次循环迭代中进行单独的transferFrom调用,但处理ETH支付只需检查msg.value是否足够。这使得攻击者可以多次重复使用同一笔ETH。
我意识到,我看到的正是同一漏洞的不同形式。在delegatecall内部,msg.sender和msg.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通报情况。我们快速讨论了下一步行动。
我们有三個選擇:
- 保留合约,希望没人注意到
- 利用该漏洞转移资金,可能使用Flashbots隐藏交易
- 通过购买剩余额度并立即完成拍卖来转移资金,这需要管理员权限
经过一番快速辩论,我们决定选项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在此过程中提供的帮助,包括审阅本文。