深入解析Aave可升级性漏洞:代理模式下的致命缺陷

本文详细分析了Aave v1/v2合约中存在的可升级性漏洞,攻击者可通过直接调用初始化函数和任意delegatecall销毁逻辑合约,影响外部DeFi合约资金安全,同时探讨了形式化验证的局限性及修复方案。

突破Aave可升级性防护 - Trail of Bits博客分析

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

受影响合约

  • 所有ATokens(Aave代币):AToken.redeem调用pool.redeemUnderlying (AToken.sol#L255-L260)。由于调用不执行任何操作,用户将销毁他们的ATokens而无法收回基础代币。
  • 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 +UTC
  • LendingPool V2: 0x987115c38fd9fd2aa2c6f1718451d167c13a3186 修复时间:2020年12月4日 19:53:00 +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 设计