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

本文详细分析了Aave借贷协议中存在的严重安全漏洞,该漏洞允许攻击者通过直接调用逻辑合约初始化函数并利用代理合约的delegatecall特性来破坏整个系统,影响范围波及多个外部DeFi合约。

Breaking Aave Upgradeability

2020年12月3日,Aave部署了其代码库的V2版本。虽然我们未被雇佣进行代码审计,但在次日我们对其进行了简要检查。我们迅速发现了一个同时影响V1和V2线上合约的漏洞并立即报告。Aave团队在收到分析报告后一小时内就修复了该漏洞。若被利用,该漏洞将摧毁Aave系统并影响外部DeFi合约中的资金。

尽管有五家安全公司(包括使用形式化验证的机构)审计过Aave代码库,但此漏洞仍被遗漏。本文将详述该漏洞原理、为何未被发现以及经验教训。同时我们开源了新的Slither检测器以提升以太坊生态安全性。

漏洞原理

Aave使用了我们此前多次讨论的delegatecall代理模式。该模式将组件分为:1)包含实现逻辑的逻辑合约;2)包含数据并通过delegatecall与逻辑合约交互的代理合约。用户与代理合约交互,而代码在逻辑合约执行。以下是简化示意图:

在Aave中,LendingPool(LendingPool.sol)是采用delegatecall代理的可升级组件。该漏洞依赖两个关键特性:

  1. 逻辑合约的函数(包括初始化函数)可直接调用
  2. 借贷池自身具有delegatecall能力

可升级合约初始化

这种升级模式的限制在于代理合约无法依赖逻辑合约的构造函数进行初始化。因此状态变量和初始设置必须在公开的初始化函数中完成,这些函数无法享受构造函数的保护机制。

在LendingPool中,initialize函数设置provider地址(_addressesProvider):

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

initializer修饰符防止多次调用initialize,要求满足以下条件:

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

其中:

  • initializing允许同一交易内多次调用
  • isConstructor()供代理合约执行代码所需
  • revision > lastInitializedRevision允许合约升级时再次调用初始化函数

通过代理调用时工作正常,但第三条也允许任何人直接调用逻辑合约的initialize函数。当逻辑合约部署后:

  • revision为0x2
  • lastInitializedRevision为0x0

漏洞本质:任何人都能设置LendingPool逻辑合约的_addressesProvider。

任意delegatecall

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
address collateralManager = _addressesProvider.getLendingPoolCollateralManager();
(bool success, bytes memory result) = collateralManager.delegatecall(
    abi.encodeWithSignature(
        'liquidationCall(address,address,address,uint256,bool)',
        collateralAsset,
        debtAsset,
        user,
        debtToCover,
        receiveAToken
    )
);

这使得攻击者可:

  1. 初始化LendingPool逻辑合约
  2. 设置受控的addresses provider
  3. 执行任意代码(包括selfdestruct)

攻击场景:任何人都能销毁借贷池逻辑合约。简化示意图如下:

存在性检查缺失

该漏洞本身已足够严重(可类比Parity事件)。但OpenZeppelin代理合约的使用放大了危害。我们2018年的博文曾指出:对空合约的delegatecall会返回success但不会执行任何代码。尽管有此警告,OpenZeppelin仍未修复其代理合约的回退函数:

1
2
3
4
5
6
7
function _delegate(address implementation) internal {
    assembly {
        calldatacopy(0, 0, calldatasize)
        let result := delegatecall(gas, implementation, 0, calldatasize, 0, 0)
        ...
    }
}

若代理合约delegatecall到已被销毁的逻辑合约,代理将返回success但实际未执行任何代码。虽然Aave可通过更新代理指向新逻辑合约来修复,但在漏洞存在期间,任何第三方合约调用借贷池时都会误认为代码已执行。

受影响合约

所有AToken:用户燃烧AToken但无法取回底层资产 WETHGateway:存款会存储在网关中导致资产被盗 基于Aave Credit Delegation v2的所有代码库 其他受影响的外部合约包括:

  • DefiSaver v1/v2
  • PieDao等

这凸显了DeFi可组合性的潜在风险。

修复建议

幸运的是漏洞在被利用前就被修复。Aave调用了两个版本借贷池的initialize函数进行加固:

  • V1修复时间:2020-12-04 19:34:26 UTC
  • V2修复时间:2020-12-04 19:53:00 UTC

长期建议:

  1. 在所有逻辑合约中添加构造函数禁用initialize函数
  2. 在代理合约的回退函数中添加合约存在性检查
  3. 仔细审查delegatecall陷阱并使用slither-check-upgradeability

形式化验证非万能

Aave代码库经过"形式化验证",但该漏洞证明安全属性并非绝对。验证报告中的属性(如操作成功时返回true不回滚)在逻辑合约被销毁时就会失效。可能原因包括:

  • 验证未考虑可升级性
  • 验证工具不支持复杂合约交互

形式化技术虽好,但需注意其覆盖范围有限。相比之下,自动化工具(如Slither)和人工审计能以较少资源实现更高安全保证。

结论

Aave反应迅速值得肯定,但其他项目就没这么幸运。我们建议开发者在部署前:

  1. 参考我们的安全清单和培训材料
  2. 将Slither加入CI流程并检查所有报告
  3. 给予安全团队充足的审计时间
  4. 谨慎处理可升级性,至少阅读相关反模式文档

我们通过公开此漏洞和Slither检测器希望预防类似错误。安全是持续过程,建议项目启动前联系我们进行安全审计。

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