揭秘Aave可升级合约漏洞:代理模式下的致命缺陷

本文详细分析Aave v1/v2智能合约中存在的关键安全漏洞,攻击者可通过直接调用初始化函数结合任意delegatecall实现逻辑合约自毁,揭示形式化验证的局限性及升级模式风险。

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

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

12月3日,Aave部署了其代码库的第二个版本。虽然我们未被雇佣审计该代码,但在次日我们对其进行了简要审查。我们迅速发现了一个影响线上合约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 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检测器来防止类似错误。然而,安全是一个永无止境的过程,开发人员应在启动项目之前联系我们进行安全审查。

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

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