Aave可升级合约漏洞分析:如何通过初始化函数实现任意代码执行

本文详细分析了Aave借贷协议v1和v2版本中存在的严重安全漏洞,攻击者可通过直接调用逻辑合约的初始化函数并结合任意delegatecall实现合约自毁,影响多个外部DeFi合约的资金安全。

打破Aave的可升级性 - Trail of Bits博客

Josselin Feist
2020年12月16日
blockchain, exploits, vulnerability-disclosure

12月3日,Aave部署了其代码库的第2版。虽然我们未被雇佣审查该代码,但我们在第二天对其进行了简要审查。我们很快发现了一个影响在线合约v1和v2版本的漏洞,并报告了该问题。在将分析发送给Aave一小时内,他们的团队就在已部署合约中缓解了该漏洞。如果被利用,该问题将破坏Aave,并影响外部DeFi合约中的资金。

五家不同的安全公司审查了Aave代码库,包括一些使用形式化验证的公司;然而,这个漏洞未被注意到。本文描述了该问题、漏洞如何逃过检测以及其他经验教训。我们还将开源一个新的Slither检测器来识别此漏洞,以提高更大以太坊社区的安全性。

漏洞详情

Aave使用我们过去在博客文章中详细讨论过的delegatecall代理模式。在高层次上,每个组件被拆分为两个合约:1)包含实现的逻辑合约,和2)包含数据并使用delegatecall与逻辑合约交互的代理。用户与代理交互,而代码在逻辑合约上执行。以下是delegatecall代理模式的简化表示:

在Aave中,LendingPool(LendingPool.sol)是一个使用delegatecall代理的可升级组件。

我们发现的漏洞依赖于这些合约中的两个特性:

  • 逻辑合约上的函数可以直接调用,包括初始化函数
  • 借贷池具有自己的delegatecall能力

初始化可升级合约

这种可升级模式的一个限制是代理不能依赖逻辑合约的构造函数进行初始化。因此,状态变量和初始设置必须在公共初始化函数中执行,这些函数不受构造函数保护措施的好处。

在LendingPool中,initialize函数设置提供者地址(_addressesProvider):

1
2
3
function initialize(ILendingPoolAddressesProvider provider) public initializer {
    _addressesProvider = provider;
}

LendingPool.sol#L90-L92

initializer修饰符防止initialize被多次调用。它要求以下条件为真:

1
2
3
4
require(
    initializing || isConstructor() || revision > lastInitializedRevision,
    'Contract instance has already been initialized'
);

VersionedInitializable.sol#L32-L50

其中:

  • initializing允许在同一交易中多次调用修饰符(因此有多个初始化函数)
  • isConstructor()是代理执行代码所需的
  • revision > lastInitializedRevision允许在合约升级时再次调用初始化函数

虽然通过代理按预期工作,但3也允许任何人直接在逻辑合约本身上调用initialize。一旦逻辑合约部署:

  • revision将为0x2(LendingPool.sol#L56)
  • lastInitializedRevision将为0x0

漏洞:任何人都可以在LendingPool逻辑合约上设置_addressesProvider。

任意delegatecall

LendingPool.liquidationCall直接delegatecall到_addressProvider返回的地址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
address collateralManager = _addressesProvider.getLendingPoolCollateralManager();

//solium-disable-next-line
(bool success, bytes memory result) =
    collateralManager.delegatecall(
        abi.encodeWithSignature(
            'liquidationCall(address,address,address,uint256,bool)',
            collateralAsset,
            debtAsset,
            user,
            debtToCover,
            receiveAToken
        )
    );

LendingPool.sol#L424-L450

这允许任何人启动LendingPool逻辑合约,设置受控的地址提供者,并执行任意代码,包括selfdestruct。

利用场景:任何人都可以销毁借贷池逻辑合约。 以下是简化的可视化表示:

存在性检查缺失

就其本身而言,这个问题已经很严重,因为任何人都可以销毁逻辑合约并阻止代理执行借贷池代码(为Parity默哀)。

然而,这个问题的严重性因使用OpenZeppelin作为代理合约而放大。我们2018年的博客文章强调,对没有代码的合约进行delegatecall将返回成功而不执行任何代码。尽管我们最初发出警告,OpenZeppelin没有修复其代理合约中的fallback函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function _delegate(address implementation) internal {
    //solium-disable-next-line
    assembly {
        // Copy msg.data. We take full control of memory in this inline assembly
        // block because it will not return to Solidity code. We overwrite the
        // Solidity scratch pad at memory position 0.
        calldatacopy(0, 0, calldatasize)

        // Call the implementation.
        // out and outsize are 0 because we don't know the size yet.
        let result := delegatecall(gas, implementation, 0, calldatasize, 0, 0)

Proxy.sol#L30-L54

如果代理delegatecall到已销毁的借贷池逻辑合约,代理将返回成功,而没有执行任何代码。

这种利用不会持久,因为Aave可以更新代理指向另一个逻辑合约。但在问题可能被利用的时间范围内,任何调用借贷池的第三方合约都会表现得好像执行了某些代码,而实际上没有。这将破坏许多外部合约的基本逻辑。

受影响的合约

  • 所有AToken(Aave代币):AToken.redeem调用pool.redeemUnderlying(AToken.sol#L255-L260)。由于调用什么都不做,用户将销毁他们的AToken而无法收回基础代币。
  • WETHGateway(WETHGateway.sol#L103-L111):存款将存储在网关中,允许任何人窃取存入的资产。
  • 任何基于Aave的Credit Delegation v2的代码库(MyV2CreditDelegation.sol)

如果我们发现的漏洞被利用,Aave之外的许多合约将以各种方式受到影响。确定完整列表很困难,我们也没有尝试这样做。这一事件凸显了DeFi可组合性的潜在风险。以下是我们发现的一些:

  • DefiSaver v1(AaveSaverProxy.sol)
  • DefiSaver v2(AaveSaverProxyV2.sol)
  • PieDao – pie oven(InterestingRecipe.sol#L66)

修复和建议

幸运的是,在我们报告之前没有人滥用这个问题。Aave调用了借贷池两个版本的initialize函数,从而保护了合约:

  • LendingPool V1: 0x017788dded30fdd859d295b90d4e41a19393f423 修复时间:2020年12月4日 19:34:26 PM +UTC
  • LendingPool V2: 0x987115c38fd9fd2aa2c6f1718451d167c13a3186 修复时间:2020年12月4日 19:53:00 PM +UTC

长期来看,合约所有者应该:

  • 在所有逻辑合约中添加构造函数以禁用initialize函数
  • 在delegatecall代理fallback函数中检查合约存在性
  • 仔细审查delegatecall陷阱并使用slither-check-upgradeability

形式化验证的合约并非无懈可击

Aave的代码库是"形式化验证的"。区块链领域的一个趋势是认为安全属性是安全的圣杯。用户可能会尝试根据这些属性的存在或不存在来排名各种合约的安全性。我们认为这是危险的,可能导致错误的安全感。

Aave形式化验证报告列出了LendingPool视图函数(例如,它们没有副作用)和池操作(例如,操作成功时返回true且不回退)的属性。例如,其中一个已验证的属性是:

然而,如果逻辑合约被销毁,这个属性可能会被破坏。那么这是如何被验证的呢?虽然我们无法访问定理证明器或使用的设置,但很可能证明没有考虑可升级性,或者证明器不支持复杂的合约交互。

这对于代码验证来说很常见。您可以在关于整体行为的假设下证明目标组件中的行为。但是在多合约设置中证明属性具有挑战性且耗时,因此必须做出权衡。

形式化技术很好,但用户必须意识到它们覆盖的范围很小,可能会错过攻击向量。另一方面,自动化工具和人工审查可以帮助开发人员以更少的资源在代码库中获得更高的整体信心。理解每种解决方案的好处和限制对开发人员和用户都至关重要。当前的问题就是一个很好的例子。Slither可以在几秒钟内找到这个问题,经过培训的专家可能会很快指出它,但用安全属性检测需要大量努力。

结论

Aave反应积极,一旦意识到问题就迅速修复了错误。危机得以避免。但最近黑客攻击的其他受害者没有那么幸运。在部署代码并将其暴露于对抗性环境之前,我们建议开发人员:

  • 查看我们的清单和来自building-secure-contracts的培训
  • 将Slither添加到持续集成管道中并调查所有报告
  • 给安全公司适当的时间来审查您的系统
  • 谨慎处理可升级性。至少,审查合约升级反模式、合约迁移工作原理以及使用OpenZeppelin的可升级性。

我们希望通过分享这篇文章和相关的Slither检测器来防止类似的错误。然而,安全是一个永无止境的过程,开发人员应在启动项目之前联系我们进行安全审查。

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