AAVE 和 Compound 分叉:空池攻击

  • mixbytes
  • 发布于 2025-02-02 17:25
  • 阅读 24

本文探讨了在 DeFi 生态系统中为了提高安全性的重要性,分析了 Compound 和 AAVE 协议的共享代币(interest-bearing tokens)在被分叉时可能面临的安全风险和漏洞,特别是空池攻击及其详细的攻击步骤。文章还提出了针对这些攻击的防范措施,包括确保池子流动性和ROUNDING优先处理机制。

image.png

DeFi 中的许可证允许生态系统更快地发展。与此同时,在创建一个主要协议的分叉时,考虑已知风险和局限性是重要的。否则,重复同样的错误的危险是存在的。 Compound 和 AAVE 协议已经存在多年,并证明了它们的安全性和可靠性。版本 Compound V2AAVE V2 在 BSD/AGPL 许可证下提供,因此其他一些协议是基于它们的代码库构建的。从概念上讲,Compound 和 AAVE 是相似的,主要区别在于产生利息的代币的技术实现。

  • 在 Compound 的情况下,代币以不断增加的速率兑换为基础代币。
  • 在 AAVE 的情况下,利息发生在重新基准化过程中。

尽管代码库不同,但两个协议在用作分叉的基础时面临相同的问题。如果协议中出现空池,恶意参与者可以进行类似于 通货膨胀攻击 的攻击,但复杂性稍高。概念上的区别是,受害的不是第一个存款者,而是协议整体。

所有的通货膨胀攻击都围绕舍入错误展开。在 EVM 中,使用整数数学,舍入默认选用较低的值。例如,199 / 100 = 1。因此,在计算每个份额的成本时,它的结果总是比总余额除以份额数量小一些。如果份额的成本很小,舍入错误的值几乎可以忽略不计。然而,如果份额价格被人为提升,舍入错误就会变得显著。

对两个协议的攻击可以总结如下步骤(为了一致性,我们将对两个协议使用术语 份额(share)):

  1. 确保产生利息的代币的总供应量对应 1–2 股。
  2. 以某种方式人为抬高一股的价格。在这里,攻击在协议之间会有所不同。
  3. 使用 1–2 股的抵押品从另一个池借款。两个协议中的份额价格是通过攻击者的资金抬高的。因此,在此阶段,协议尚未遭受损失。
  4. 以巧妙的方式销毁抵押份额。由于舍入错误,可以提取的基础代币数量将超过对应销毁的份额数量,从而耗尽协议的资金。

让我们检查这两个协议的脆弱性确切在何处。我们将直接关注代码,并尝试识别克服这个问题的方法。

Compound Forks (Hundred Finance, Midas Capital, Onyx…)

我们从 Compound 开始——这个案例更简单,因为这里的通货膨胀攻击以更经典的方式发生。

cToken 在概念上类似于一个金库。

  • cToken.totalSupply() 表示份额的总数。
  • exchange_rate 是可以兑换一个 cToken 单位的基础代币数量,也就是份额价格。

该协议在计算汇率时使用 1e18 的精度乘子。然而,这并未防止舍入错误,而舍入错误恰恰是这个黑客攻击的关键。为了简单起见,除了第 5 步外,我们将省略精度乘子。

用于攻击的空池将称为 The Pool(这对应于 cToken 合约)。

在交易开始时,攻击者从外部协议获得大量闪电贷。然后,他们执行以下步骤:

  1. 将相对较小的初始存款存入 The Pool。

攻击者的 cToken 份额数量由初始汇率 initial_rate 确定。它在代码中被定义为 CToken.initialExchangeRateMantissa,并在 The Pool 初始化时设定。

因此,黑客获得了 initial_rate * initial_deposit 的 cToken 份额。

  1. 提取 initial_rate * initial_deposit - 2 股。

在此之后,攻击者的余额中仅剩 2 股。

这个步骤序列(1–2)是必要的,因为 initial_rate 相当高,即使是 1 wei 的基础代币存款也会导致超过 2 股的余额。

  1. 将闪电贷资金的剩余部分转移到 cToken 合约余额中。

cToken 的汇率由合约的基础代币余额决定:

CToken.exchangeRateStoredInternal():

uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;

CErc20.getCashPrior():

function getCashPrior() virtual override internal view returns (uint) {
    EIP20Interface token = EIP20Interface(underlying);
    return token.balanceOf(address(this));
}

cToken 合约的基础代币余额越大(保持 totalSupply 不变),份额价格就越高。

因此,攻击者通过直接将资金转移到合约中抬高份额价格。

由于攻击是在一个空池上进行,所有转移的资金都属于攻击者的份额。这意味着在此步骤中他们没有损失资金。

  1. 从另一个池借入代币,使得 1 股抬升的抵押物足以保持头寸健康。

在第 3 步之后,1 股现在的价值是直接存款资金的一半(闪电贷的剩余部分)。这使得攻击者能够借款另一个池的全部基础代币余额。

值得注意的是,在此步骤之前,黑客并没有给协议造成经济损失。

实际上,他们在此步骤中存入的资金数量超过了借入的数量的两倍。

  1. 在 The Pool 中调用 CErc20.redeemUnderlying(uint redeemAmount)。攻击者传递 redeemAmount = underlying_token.balanceOf(cToken) - 1。

函数 CErc20.redeemUnderlying() 输入用户希望接收的基础代币数量。随后,销毁份额的数量 计算 如下:

redeemTokens = div_(redeemAmountIn, exchangeRate);

如前所述,Compound 案例描述中的所有与 exchange_rate 相关的计算都使用高精度数学。在 redeemTokens 计算中,exchangeRate 已经乘以 1e18。

由于池中仅有 2 股,汇率 = underlying_token.balanceOf(cToken) / 2 (\* 1e18)。因此,

redeemTokens = (underlying_token.balanceOf(cToken) - 1) * 1e18 / (underlying_token.balanceOf(cToken) / 2 * 1e18) = 1.

如我们所见,高精度数学在这里并没有防止舍入错误。

协议 仅检查 被销毁的份额数量以确定提款是否被允许:

uint allowed = comptroller.redeemAllowed(address(this), redeemer, redeemTokens);
if (allowed != 0) {
    revert RedeemComptrollerRejection(allowed);
}

由于在第 4 步中,攻击者借入的金额仅由 1 股抬升覆盖,而 1 股仍然存在,因此协议认为仍有足够的份额。因此,这一赎回是被允许的。

此时,攻击可以被视为完成。攻击者已经清空了协议的一个池,并且也退回了在 The Pool 投资的所有资金。

要在另一池中重复攻击,黑客可以通过从不同地址清算将 The Pool 重置为其原始空状态。

在攻击结束时,黑客连同溢价一起归还了闪电贷。

AAVE Forks (HopeLend, Radiant Finance…)

对 AAVE 分叉的攻击则要复杂得多,因为产生利息代币的不同结构。在 AAVE 中,aToken 是可重新基准化的代币,这意味着所有持有者的余额会随着利息的累计而增加。要确定任意时刻的余额,需要将存储变量 scaledBalance 乘以当前的 liquidityIndex。合约的基础代币余额不会直接影响 aToken 的余额或总供应量。因此,直接转账不会帮助抬高份额价格。

新代币的 liquidityIndex 最初设为 1(更确切地说,等于 RAY == 1e27,这是用于计算精度的常量)。每当累计利息时,liquidityIndex 会增加。

对于首次存入 aToken 的用户,用户获得的 scaledBalance 等于存入的抵押品余额。然后,随着 liquidityIndex 的增长,每单位基础代币发行的 scaledBalance 数量减少。

AToken.balanceOf():

function balanceOf(address user)
    public
    view
    override(IncentivizedERC20, IERC20)
    returns (uint256)
{
    return super.balanceOf(user).rayMul(_pool.getReserveNormalizedIncome(_underlyingAsset));
}

super.balanceOf(user) 是 scaledBalance。

_pool.getReserveNormalizedIncome(_underlyingAsset) 计算当前的 liquidityIndex。

这绝对不是一个普通的金库。但让我们看看 liquidityIndex 是如何随着利息累计而增加的。

来自闪电贷的手续费提高了 liquidityIndex。 下面 是 LendingPool.flashloan() 的相关片段:

_reserves[vars.currentAsset].cumulateToLiquidityIndex(
    IERC20(vars.currentATokenAddress).totalSupply(),
    vars.currentPremium
);

ReserveLogic.cumulateToLiquidityIndex():

function cumulateToLiquidityIndex(
    DataTypes.ReserveData storage reserve,
    uint256 totalLiquidity,
    uint256 amount
) internal {
    uint256 amountToLiquidityRatio = amount.wadToRay().rayDiv(totalLiquidity.wadToRay());

    uint256 result = amountToLiquidityRatio.add(WadRayMath.ray());

    result = result.rayMul(reserve.liquidityIndex);
    require(result <= type(uint128).max, Errors.RL_LIQUIDITY_INDEX_OVERFLOW);

    reserve.liquidityIndex = uint128(result);
}

因此,liquidityIndex 反映了每个 scaledBalance 赚取的收益。这意味着 scaledBalance 可以被视为金库份额,而 aToken.scaledTotalSupply() * liquidityIndex 代表总金库余额。

因此,如果某种程度上增加 liquidityIndex,同时保持份额数量不变,份额价格就会上升。

该攻击在一个空池上执行,这使得操纵其参数成为可能。在交易开始时,攻击者从外部协议获得价值数百万美元的闪电贷。后续步骤可以分为两个阶段。

阶段 1:增加 liquidityIndex – 通货膨胀

  1. 攻击者将闪电贷的资金存入空池中。
  2. 在目标协议中,攻击者以存入的基础代币的全部金额进行了第二次闪电贷。此时 aToken 余额为空。然而,aToken.totalSupply() 和 liquidityIndex 仍然保持不变。
  3. 攻击者将基础代币直接转移到 aToken 余额中。这尚未构成通货膨胀攻击;这是为了执行下一步所必要的。
  4. 攻击者使用 withdraw() 提取除了 1 个 aToken 的所有资金。此时,aToken.totalSupply() 变为 1,而 liquidityIndex 仍然保持在 1 RAY。
  5. 攻击者归还从目标协议获得的闪电贷,包括溢价。由于贷款金额数百万美元,积累的溢价是巨大的。考虑到 aToken.totalSupply() == 1,所有的溢价分配给一股,极大地提高了 liquidityIndex,从而抬高了份额价格。
  6. 为了进一步抬高份额价格,攻击者反复地借入并立即偿还与 aToken 的全部基础代币余额相等的闪电贷。每次,积累的利息都提高了 liquidityIndex,进一步抬高了份额价格。

在某个时刻,份额价格高到攻击者能够将其作为抵押品来借用另一协定池中的所有资金。

在此时,协议仍未遭受任何损失,因为在第 3 步中,黑客已经直接将首个闪电贷转移到了 aToken 合约余额中。

阶段 2:提取最后的代币

在此阶段,舍入错误开始发挥作用。

用于抵押品提取的函数 LendingPool.withdraw() 被调用。为了计算烧毁的 aTokens 数量和转移给用户的基础代币数量,withdraw() 调用 AToken.burn():

IAToken(aToken).burn(msg.sender, to, amountToWithdraw, reserve.liquidityIndex);

在 burn() 函数内,烧毁计算和转移 发生:

uint256 amountScaled = amount.rayDiv(index);
require(amountScaled != 0, Errors.CT_INVALID_BURN_AMOUNT);

_burn(user, amountScaled);

IERC20(_underlyingAsset).safeTransfer(receiverOfUnderlying, amount);

为了增加精度,协议使用基于 RAY 的数学。分子、分母以及结果,都是以 RAY 数字(乘以 1e27 的值)表示。让我们看看 WadRayMath.rayDiv() 和函数的注释:

/**
   * @dev Divides two ray, rounding half up to the nearest ray
   * @param a Ray
   * @param b Ray
   * @return The result of a/b, in ray
   **/
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
    require(b != 0, Errors.MATH_DIVISION_BY_ZERO);
    uint256 halfB = b / 2;

    require(a <= (type(uint256).max - halfB) / RAY, Errors.MATH_MULTIPLICATION_OVERFLOW);

    return (a * RAY + halfB) / b;
}

然而,在 AToken.burn() 中,分子(amount)仅仅是正在提取的基础代币数量,而不是 RAY 数字。同样,结果也是被烧毁的份额数量。因而,rayDiv() 并没有在这里增加精度。它只简化了操作,因为 liquidityIndex 是以 RAY 数字存储的。

因此,在烧毁份额时会出现舍入错误。如果计算需要烧毁 1.49 个 aTokens,则实际上只烧毁 1 个代币。然而,转移的数量仍然对应于 1.49 股,因为 _underlyingAsset.transfer() 使用原始金额。

在攻击的最终阶段,攻击者反复存入 1–2 股作为抵押,然后提取 1.49 股,逐步耗尽余额。提取的资金随后被用于归还最初的外部闪电贷,完成攻击。

建议

在分叉任何协议时,分析以往影响其他分叉的安全事件及其缓解方案是至关重要的。基于 Compound V2 或 AAVE V2 的协议如何保护自己免受空池攻击?

一个重要的措施是防止池具有低或零流动性。这可以通过在新池部署后的立即存入最低金额来解决(在部署交易内)。如果这是在单独的交易中进行的,恶意用户可能会在交易之间插入自己并耗尽整个协议。

这个案例表明,不仅代码应该由专业审计公司进行审计,还包括部署脚本、DAO 提案以及 DeFi 协议运营的其他方面。

同时,还需要注意舍入总是应有利于协议。看似微不足道的损失,在特定情况下,可能导致大规模损失和协议的彻底失败。

谁是 MixBytes? MixBytes 是一支专业区块链审计员和安全研究员团队,专门提供综合的智能合约审计和技术咨询服务,服务对象包括 EVM 兼容项目和 Substrate 基于项目。请加入我们,关注 X,保持对最新行业趋势和见解的了解。

  • 原文链接: mixbytes.io/blog/aave-an...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
mixbytes
mixbytes
Empowering Web3 businesses to build hack-resistant projects.