DeFi AMM Accounting Bugs & Virtual Balance Cache Exploitation

Tip

学习和实践 AWS 黑客技术:HackTricks Training AWS Red Team Expert (ARTE)
学习和实践 GCP 黑客技术:HackTricks Training GCP Red Team Expert (GRTE) 学习和实践 Azure 黑客技术:HackTricks Training Azure Red Team Expert (AzRTE)

支持 HackTricks

Overview

Yearn Finance’s yETH pool (Nov 2025) 揭示了当这些为节省 gas 而在复杂 AMMs 内部设置的缓存在边界状态转换期间未被对账/重置时,如何被用于攻击。该 weighted stableswap pool 跟踪多达 32 个 liquid staking derivatives (LSDs),将它们转换为 ETH 等价的 virtual balances (vb_i = balance_i × rate_i / PRECISION),并将这些值存储在打包的存储数组 packed_vbs[] 中。当所有 LP tokens 都被销毁时,totalSupply 正确降为零,但缓存的 packed_vbs[i] 插槽保留了巨大的历史值。随后的存款者被视为“第一”个流动性提供者,尽管缓存仍持有虚假的流动性,这使得攻击者能够仅用 16 wei 铸造约 235 septillion yETH,随后抽取约 USD 9M 的 LSD 抵押品。

Key ingredients:

  • Derived-state caching: 通过持久化 virtual balances 并增量更新来避免昂贵的 oracle 查询。
  • Missing reset when supply == 0: remove_liquidity() 的比例递减在每次提款周期后在 packed_vbs[] 中留下非零残留。
  • Initialization branch trusts the cache: add_liquidity() 调用 _calc_vb_prod_sum() 并在 prev_supply == 0 时简单地 读取 packed_vbs[],假设缓存也已被清零。
  • Flash-loan financed state poisoning: 存/取循环在没有资产锁定的情况下放大了舍入残差,从而在 “first deposit” 路径中导致灾难性的过度铸造。

Cache design & missing boundary handling

下面简化展示了易受攻击的流程:

function remove_liquidity(uint256 burnAmount) external {
uint256 supplyBefore = totalSupply();
_burn(msg.sender, burnAmount);

for (uint256 i; i < tokens.length; ++i) {
packed_vbs[i] -= packed_vbs[i] * burnAmount / supplyBefore; // truncates to floor
}

// BUG: packed_vbs not cleared when supply hits zero
}

function add_liquidity(Amounts calldata amountsIn) external {
uint256 prevSupply = totalSupply();
uint256 sumVb = prevSupply == 0 ? _calc_vb_prod_sum() : _calc_adjusted_vb(amountsIn);
uint256 lpToMint = pricingInvariant(sumVb, prevSupply, amountsIn);
_mint(msg.sender, lpToMint);
}

function _calc_vb_prod_sum() internal view returns (uint256 sum) {
for (uint256 i; i < tokens.length; ++i) {
sum += packed_vbs[i]; // assumes cache == 0 for a pristine pool
}
}

由于 remove_liquidity() 仅执行按比例的减少,每次循环都会留下 固定点舍入残留(fixed-point rounding dust)。经过 ≳10 次存/取款循环后,这些残留在虚拟余额中累积成极大的幻影余额,而链上代币余额几乎为空。燃烧最后的 LP 份额将 totalSupply 设为零,但缓存仍然保留,导致协议易被错误初始化。

漏洞利用流程(yETH 案例研究)

  1. 闪电贷周转资金 – 从 Balancer/Aave 借入 wstETH、rETH、cbETH、ETHx、WETH 等,以便在操纵池子时不占用自有资金。
  2. 污染 packed_vbs[] – 在八个 LSD 资产上循环存入与取出。每次部分取出都会截断 packed_vbs[i] − vb_share,为每种代币留下 >0 的残余。重复该循环会在不引人注意的情况下膨胀ETH等价的幻影余额,因为真实余额大致相抵消。
  3. 强制 supply == 0 – 燃烧所有剩余的 LP 代币,使池子认为它已空。实现上的疏忽会使被污染的 packed_vbs[] 保持不变。
  4. 尘埃级别的“首次存款” – 在支持的 LSD 槽位上分配发送总计 16 wei。add_liquidity() 看到 prev_supply == 0 后运行 _calc_vb_prod_sum(),并从陈旧缓存读取而不是从实际余额重算。因此铸币计算表现得好像有数万亿美元进入,铸造出 ~2.35×10^26 yETH
  5. 抽取并偿还 – 赎回被膨胀的 LP 头寸换取所有保管的 LSD,在 Balancer 上将 yETH→WETH 兑换,通过 Uniswap v3 转换为 ETH,偿还闪电贷/费用,并洗钱获利(例如通过 Tornado Cash)。净利润约为 USD 900 万,而攻击者自有资金仅有 16 wei 实际触及池子。

泛化的可利用条件

当满足下列所有条件时,可以滥用类似的 AMM:

  • 对余额的缓存导数(虚拟余额、TWAP 快照、不变式辅助值)在事务之间保留以节省 gas。
  • 部分更新会截断 结果(向下取整、定点数舍入),允许攻击者通过对称的存/取款循环累积有状态残余。
  • 边界条件重用缓存而不是从真实数据重算,尤其是在 totalSupply == 0totalLiquidity == 0 或池子组成重置时。
  • 铸币逻辑缺乏比例合理性检查(例如缺少 expected_value/actual_value 范围约束),因此一次尘埃级别的存款即可铸造几乎全部历史供应。
  • 廉价资金可用(闪电贷或内部信用)以在单个交易内或紧密编排的捆绑中运行数十次状态调整操作。

防御工程检查表

  • 当 supply/lpShares 为零时显式重置
if (totalSupply == 0) {
for (uint256 i; i < tokens.length; ++i) packed_vbs[i] = 0;
}

对所有从余额或预言机数据派生的缓存累加器采取相同处理。

  • 在初始化分支重算 – 当 prev_supply == 0 时,完全忽略缓存并从实际代币余额 + 实时预言机汇率重建虚拟余额。
  • 铸币合理性界限 – 若 lpToMint > depositValue × MAX_INIT_RATIO 或在总存款低于最小阈值时单个交易铸造 >X% 的历史供应则回滚。
  • 舍入残余清理 – 将每个代币的残留尘埃聚合到沉淀池(treasury/burn),以防止反复的按比例调整使缓存偏离真实余额。
  • 差分测试 – 对每次状态转换(添加/移除/交换),在链下用高精度数学重算相同不变式,并在全流动性抽干后也在严格的 epsilon 范围内断言相等。

监控与响应

  • 多交易检测 – 跟踪近似对称的存/取事件序列,这些事件使池子留下低余额但高缓存状态,随后发生 supply == 0。单笔交易的异常检测器会错过这些污染活动。
  • 运行时模拟 – 在执行 add_liquidity() 之前,从头重算虚拟余额并与缓存和进行比较;若差异超出基点阈值则回滚或暂停。
  • 闪电贷感知警报 – 标记同时包含大额闪电贷、彻底提取池子和尘埃级别最终存款的交易;阻断或要求人工审批。

References

Tip

学习和实践 AWS 黑客技术:HackTricks Training AWS Red Team Expert (ARTE)
学习和实践 GCP 黑客技术:HackTricks Training GCP Red Team Expert (GRTE) 学习和实践 Azure 黑客技术:HackTricks Training Azure Red Team Expert (AzRTE)

支持 HackTricks