两个正确可能铸成一个错误
在构建软件时,一个常见的误解是:如果系统中的每个组件都经过单独验证是安全的,那么系统本身也是安全的。这种信念在DeFi领域表现得最为明显,可组合性对开发者来说是第二天性。不幸的是,虽然组合两个组件在大多数情况下可能是安全的,但只需要一个漏洞就能给成百上千的无辜用户造成严重的财务损失。
今天,我想告诉大家我是如何发现并帮助修补一个危及超过109k ETH(按当前汇率计算约3.5亿美元)的漏洞的。
遭遇
上午9:42
我在Telegram上的LobsterDAO群组中随意浏览时,注意到@ivangbi_和@bantg之间关于SushiSwap MISO平台上新募资活动的讨论。我通常尽量避免公开参与戏剧性事件,但忍不住快速搜索了一下,想了解具体情况。返回的结果对我来说并不特别有趣,但我继续深入,感觉如果继续寻找,这里可能有什么有趣的东西。
MISO平台运营两种类型的拍卖:荷兰式拍卖和批量拍卖。在这种情况下,募资是通过荷兰式拍卖进行的。自然,我做的第一件事就是在Etherscan上打开合约。
上午9:44
我快速浏览了参与协议中的DutchAuction合约,并检查了每个有趣的函数。提交函数(commitEth、commitTokens和commitTokensFrom)似乎都正确实现了。拍卖管理函数(setDocument、setList等)也具有适当的访问控制。然而,在接近底部时,我注意到initMarket函数没有访问控制,这非常令人担忧。此外,它调用的initAuction函数也不包含任何访问控制检查。
不过,我并不真的期望这是一个漏洞,因为我不认为Sushi团队会犯如此明显的错误。果然,initAccessControls函数验证了合约尚未初始化。
然而,这让我有了另一个发现。在滚动浏览所有文件时,我注意到了SafeTransfer和BoringBatchable库。我对这两个库都很熟悉,立即意识到BoringBatchable库引入的潜在风险。
对于那些不熟悉的人来说,BoringBatchable是一个mixin库,旨在轻松为任何导入它的合约引入批量调用。它通过对输入中提供的每个调用数据在当前合约上执行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在此过程中的帮助,包括审阅此文章。