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

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

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

2020年12月3日,Aave部署了其代码库的V2版本。虽然我们未被雇佣审查代码,但在次日我们进行了简要检查,并迅速发现了一个影响线上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信用委托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 设计