本文探讨了在 DeFi 生态系统中为了提高安全性的重要性,分析了 Compound 和 AAVE 协议的共享代币(interest-bearing tokens)在被分叉时可能面临的安全风险和漏洞,特别是空池攻击及其详细的攻击步骤。文章还提出了针对这些攻击的防范措施,包括确保池子流动性和ROUNDING优先处理机制。
DeFi 中的许可证允许生态系统更快地发展。与此同时,在创建一个主要协议的分叉时,考虑已知风险和局限性是重要的。否则,重复同样的错误的危险是存在的。 Compound 和 AAVE 协议已经存在多年,并证明了它们的安全性和可靠性。版本 Compound V2 和 AAVE V2 在 BSD/AGPL 许可证下提供,因此其他一些协议是基于它们的代码库构建的。从概念上讲,Compound 和 AAVE 是相似的,主要区别在于产生利息的代币的技术实现。
尽管代码库不同,但两个协议在用作分叉的基础时面临相同的问题。如果协议中出现空池,恶意参与者可以进行类似于 通货膨胀攻击 的攻击,但复杂性稍高。概念上的区别是,受害的不是第一个存款者,而是协议整体。
所有的通货膨胀攻击都围绕舍入错误展开。在 EVM 中,使用整数数学,舍入默认选用较低的值。例如,199 / 100 = 1。因此,在计算每个份额的成本时,它的结果总是比总余额除以份额数量小一些。如果份额的成本很小,舍入错误的值几乎可以忽略不计。然而,如果份额价格被人为提升,舍入错误就会变得显著。
对两个协议的攻击可以总结如下步骤(为了一致性,我们将对两个协议使用术语 份额(share)):
让我们检查这两个协议的脆弱性确切在何处。我们将直接关注代码,并尝试识别克服这个问题的方法。
我们从 Compound 开始——这个案例更简单,因为这里的通货膨胀攻击以更经典的方式发生。
cToken 在概念上类似于一个金库。
该协议在计算汇率时使用 1e18 的精度乘子。然而,这并未防止舍入错误,而舍入错误恰恰是这个黑客攻击的关键。为了简单起见,除了第 5 步外,我们将省略精度乘子。
用于攻击的空池将称为 The Pool(这对应于 cToken 合约)。
在交易开始时,攻击者从外部协议获得大量闪电贷。然后,他们执行以下步骤:
攻击者的 cToken 份额数量由初始汇率 initial_rate 确定。它在代码中被定义为 CToken.initialExchangeRateMantissa,并在 The Pool 初始化时设定。
因此,黑客获得了 initial_rate * initial_deposit 的 cToken 份额。
在此之后,攻击者的余额中仅剩 2 股。
这个步骤序列(1–2)是必要的,因为 initial_rate 相当高,即使是 1 wei 的基础代币存款也会导致超过 2 股的余额。
cToken 的汇率由合约的基础代币余额决定:
CToken.exchangeRateStoredInternal():
uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;
function getCashPrior() virtual override internal view returns (uint) {
EIP20Interface token = EIP20Interface(underlying);
return token.balanceOf(address(this));
}
cToken 合约的基础代币余额越大(保持 totalSupply 不变),份额价格就越高。
因此,攻击者通过直接将资金转移到合约中抬高份额价格。
由于攻击是在一个空池上进行,所有转移的资金都属于攻击者的份额。这意味着在此步骤中他们没有损失资金。
在第 3 步之后,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 分叉的攻击则要复杂得多,因为产生利息代币的不同结构。在 AAVE 中,aToken 是可重新基准化的代币,这意味着所有持有者的余额会随着利息的累计而增加。要确定任意时刻的余额,需要将存储变量 scaledBalance 乘以当前的 liquidityIndex。合约的基础代币余额不会直接影响 aToken 的余额或总供应量。因此,直接转账不会帮助抬高份额价格。
新代币的 liquidityIndex 最初设为 1(更确切地说,等于 RAY == 1e27,这是用于计算精度的常量)。每当累计利息时,liquidityIndex 会增加。
对于首次存入 aToken 的用户,用户获得的 scaledBalance 等于存入的抵押品余额。然后,随着 liquidityIndex 的增长,每单位基础代币发行的 scaledBalance 数量减少。
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,同时保持份额数量不变,份额价格就会上升。
该攻击在一个空池上执行,这使得操纵其参数成为可能。在交易开始时,攻击者从外部协议获得价值数百万美元的闪电贷。后续步骤可以分为两个阶段。
在某个时刻,份额价格高到攻击者能够将其作为抵押品来借用另一协定池中的所有资金。
在此时,协议仍未遭受任何损失,因为在第 3 步中,黑客已经直接将首个闪电贷转移到了 aToken 合约余额中。
在此阶段,舍入错误开始发挥作用。
用于抵押品提取的函数 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!