突破Aave可升级性 - Trail of Bits博客
2020年12月3日,Aave部署了其代码库的第2版。虽然我们没有被雇佣来审查代码,但我们在第二天对其进行了简要审查。我们很快发现了一个影响实时合约版本1和2的漏洞,并报告了该问题。在将分析发送给Aave后一小时内,他们的团队就在已部署合约中缓解了该漏洞。如果被利用,该问题将破坏Aave,并影响外部DeFi合约中的资金。
五家不同的安全公司审查了Aave代码库,包括一些使用形式化验证的公司;然而,这个漏洞未被注意到。本文描述了该问题、漏洞如何逃过检测以及其他经验教训。我们还将开源一个新的Slither检测器,用于识别此漏洞,以提高更大的以太坊社区的安全性。
漏洞详情
Aave使用了我们在过去博客文章中详细讨论过的delegatecall代理模式。在高层次上,每个组件被分成两个合约:1)包含实现的逻辑合约;2)包含数据并使用delegatecall与逻辑合约交互的代理合约。用户与代理交互,而代码在逻辑合约上执行。以下是delegatecall代理模式的简化表示:
在Aave中,LendingPool(LendingPool.sol)是一个使用delegatecall代理的可升级组件。
我们发现的漏洞依赖于这些合约中的两个特性:
- 逻辑合约上的函数可以直接调用,包括初始化函数
- 借贷池具有自己的delegatecall功能
初始化可升级合约
这种可升级性模式的一个限制是代理不能依赖逻辑合约的构造函数进行初始化。因此,状态变量和初始设置必须在公共初始化函数中执行,这些函数无法受益于构造函数的保护措施。
在LendingPool中,initialize函数设置提供者地址(_addressesProvider):
|
|
initializer修饰符防止initialize被多次调用。它要求以下条件为真:
|
|
其中:
- initializing允许在同一交易中对修饰符进行多次调用(因此有多个初始化函数)
- isConstructor()是代理执行代码所需的
- revision > lastInitializedRevision允许在合约升级时再次调用初始化函数
虽然通过代理可以按预期工作,但第三个条件也允许任何人直接在逻辑合约本身上调用initialize。一旦逻辑合约部署:
- revision将为0x2
- lastInitializedRevision将为0x0
漏洞:任何人都可以在LendingPool逻辑合约上设置_addressesProvider。
任意delegatecall
LendingPool.liquidationCall直接delegatecall到_addressProvider返回的地址:
|
|
这允许任何人初始化LendingPool逻辑合约,设置受控的地址提供者,并执行任意代码,包括selfdestruct。
利用场景:任何人都可以销毁借贷池逻辑合约。 以下是简化的可视化表示:
缺乏存在性检查
就其本身而言,这个问题已经很严重,因为任何人都可以销毁逻辑合约并阻止代理执行借贷池代码(为Parity默哀)。
然而,使用OpenZeppelin作为代理合约加剧了此问题的严重性。我们2018年的博客文章强调,对没有代码的合约进行delegatecall将返回成功而不执行任何代码。尽管我们最初发出了警告,但OpenZeppelin没有修复其代理合约中的fallback函数:
|
|
如果代理delegatecall到已销毁的借贷池逻辑合约,代理将返回成功,而没有执行任何代码。
这种利用不会是持久的,因为Aave可以更新代理以指向另一个逻辑合约。但在问题可能被利用的时间范围内,任何调用借贷池的第三方合约都会表现得好像执行了某些代码,而实际上没有。这将破坏许多外部合约的基本逻辑。
受影响的合约
所有AToken(Aave代币):AToken.redeem调用pool.redeemUnderlying。由于调用什么都不做,用户将销毁他们的AToken而无法收回基础代币。
WETHGateway:存款将存储在网关中,允许任何人窃取存入的资产。
任何基于Aave信用委托v2的代码库
如果我们发现的漏洞被利用,Aave之外的许多合约都会以各种方式受到影响。确定完整列表很困难,我们没有尝试这样做。这一事件凸显了DeFi可组合性的潜在风险。以下是我们发现的一些:
- DefiSaver v1
- DefiSaver v2
- PieDao - pie oven
修复和建议
幸运的是,在我们报告之前没有人滥用此问题。Aave调用了借贷池两个版本的initialize函数,从而保护了合约:
LendingPool V1:2020年12月4日19:34:26 UTC修复 LendingPool V2:2020年12月4日19:53:00 UTC修复
长期来看,合约所有者应该:
- 在所有逻辑合约中添加构造函数以禁用initialize函数
- 在delegatecall代理fallback函数中检查合约是否存在
- 仔细审查delegatecall陷阱并使用slither-check-upgradeability
形式化验证的合约并非无懈可击
Aave的代码库是"形式化验证的"。区块链领域的一个趋势是认为安全属性是安全的圣杯。用户可能会尝试根据此类属性的存在或不存在来对各个合约的安全性进行排名。我们认为这是危险的,可能导致错误的安全感。
Aave形式化验证报告列出了LendingPool视图函数的属性(例如,它们没有副作用)和池操作(例如,操作成功时返回true且不回退)。例如,其中一个已验证的属性是:
然而,如果逻辑合约被销毁,此属性可能会被破坏。那么这是如何被验证的呢?虽然我们无法访问定理证明器或使用的设置,但很可能证明没有考虑可升级性,或者证明器不支持复杂的合约交互。
这对于代码验证来说很常见。您可以在对整体行为做出假设的情况下证明目标组件中的行为。但是在多合约设置中证明属性具有挑战性且耗时,因此必须做出权衡。
形式化技术很棒,但用户必须意识到它们覆盖的范围很小,可能会错过攻击向量。另一方面,自动化工具和人工审查可以帮助开发人员以更少的资源在代码库中获得更高的整体信心。了解每种解决方案的好处和限制对开发人员和用户都至关重要。当前的问题就是一个很好的例子。Slither可以在几秒钟内找到此问题,经过培训的专家可能很快指出它,但使用安全属性检测需要大量努力。
结论
Aave反应积极,一旦意识到问题就迅速修复了错误。危机得以避免。但最近黑客攻击的其他受害者没有那么幸运。在部署代码并将其暴露在对抗性环境之前,我们建议开发人员:
- 查看我们的清单和构建安全合约的培训
- 将Slither添加到持续集成管道中并调查其所有报告
- 给安全公司适当的时间来审查您的系统
- 谨慎处理可升级性。至少,请查看合约升级反模式、合约迁移工作原理以及使用OpenZeppelin进行升级
我们希望通过分享这篇文章和相关的Slither检测器来防止类似的错误。然而,安全是一个永无止境的过程,开发人员应在启动项目之前联系我们进行安全审查。