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) は、複雑な AMM 内でのガス節約用キャッシュが境界状態遷移時に整合されないときにどのように悪用されうるかを露呈しました。weighted stableswap pool は最大32 の liquid staking derivatives (LSDs) を追跡し、それらを ETH 相当の virtual balances (vb_i = balance_i × rate_i / PRECISION) に変換して、packed storage 配列 packed_vbs[] に格納します。すべての LP トークンがバーンされたとき、totalSupply は正しくゼロになりますが、キャッシュされた packed_vbs[i] スロットは大量の過去値を保持したままでした。次の預金者はキャッシュに幽霊流動性が残っているにもかかわらず「最初の」流動性提供者として扱われ、攻撃者はわずか 16 wei で約 2.35×10^26 yETH をミントし、約 9M USD 相当の LSD 担保を奪うことができました。

主要因:

  • Derived-state caching: 高コストな oracle 参照を避けるために virtual balances を永続化し、インクリメンタルに更新する。
  • 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: deposit/withdraw ループが端数の残留を増幅し、資本のロックアップなしに「最初の預金」経路で破滅的な過剰ミントを可能にした。

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
}
}

Because remove_liquidity() only applied proportional decrements, every loop left fixed-point rounding dust. After ≳10 deposit/withdraw cycles those residues accumulated into extremely large phantom virtual balances while the on-chain token balances were almost empty. Burning the final LP shares set totalSupply to zero yet caches stayed populated, priming the protocol for a malformed initialization.

Exploit playbook (yETH case study)

  1. Flash-loan working capital – プールを操作する間に自己資本を拘束しないよう、Balancer/Aave から wstETH, rETH, cbETH, ETHx, WETH などを借りる。
  2. Poison packed_vbs[] – 8つのLSD資産で入出金をループする。各部分的な引き出しは packed_vbs[i] − vb_share を切り捨て、各トークンに >0 の残留を残す。ループを繰り返すことで、実際の残高は概ね相殺されているため疑いを招かずに、ETH相当の幻の残高が膨張する。
  3. Force supply == 0 – 残りのすべてのLPトークンをバーンしてプールが空だと認識するようにする。実装の見落としにより、毒されている packed_vbs[] はそのまま残る。
  4. Dust-size “first deposit” – サポートされるLSDスロットに分配して合計16 weiを送る。add_liquidity()prev_supply == 0 を検出し _calc_vb_prod_sum() を呼び、実際の残高から再計算する代わりに古いキャッシュを読み込む。そのためミント計算は数兆ドルが入金されたかのように振る舞い、~2.35×10^26 yETH を発行する。
  5. Drain & repay – 膨らませたLPポジションをすべての保管されたLSDに対して償還し、Balancerで yETH→WETH にスワップ、Uniswap v3でETHに変換、フラッシュローン/手数料を返済し、利益をマネーロンダリング(例:Tornado Cash)する。自己資金は合計16 weiしかプールに触れておらず、純利益は約USD 9M。

Generalized exploitation conditions

You can abuse similar AMMs when all of the following hold:

  • Cached derivatives of balances (virtual balances, TWAP snapshots, invariant helpers) persist between transactions for gas savings.
  • Partial updates truncate results (floor division, fixed-point rounding), letting an attacker accumulate stateful residues via symmetric deposit/withdraw cycles.
  • Boundary conditions reuse caches instead of ground-truth recomputation, especially when totalSupply == 0, totalLiquidity == 0, or pool composition resets.
  • Minting logic lacks ratio sanity checks (e.g., absence of expected_value/actual_value bounds) so a dust deposit can mint essentially the entire historic supply.
  • Cheap capital is available (flash loans or internal credit) to run dozens of state-adjusting operations inside one transaction or tightly choreographed bundle.

Defensive engineering checklist

  • Explicit resets when supply/lpShares hit zero:
if (totalSupply == 0) {
for (uint256 i; i < tokens.length; ++i) packed_vbs[i] = 0;
}

Apply the same treatment to every cached accumulator derived from balances or oracle data.

  • Recompute on initialization branches – When prev_supply == 0, ignore caches entirely and rebuild virtual balances from actual token balances + live oracle rates.
  • Minting sanity bounds – Revert if lpToMint > depositValue × MAX_INIT_RATIO or if a single transaction mints >X% of historic supply while total deposits are below a minimal threshold.
  • Rounding-residue drains – Aggregate per-token dust into a sink (treasury/burn) so repeated proportional adjustments do not drift caches away from real balances.
  • Differential tests – For every state transition (add/remove/swap), recompute the same invariant off-chain with high-precision math and assert equality within a tight epsilon even after full liquidity drains.

Monitoring & response

  • Multi-transaction detection – Track sequences of near-symmetric deposit/withdraw events that leave the pool with low balances but high cached state, followed by supply == 0. Single-transaction anomaly detectors miss these poisoning campaigns.
  • Runtime simulations – Before executing add_liquidity(), recompute virtual balances from scratch and compare with cached sums; revert or pause if deltas exceed a basis-point threshold.
  • Flash-loan aware alerts – Flag transactions that combine large flash loans, exhaustive pool withdrawals, and a dust-sized final deposit; block or require manual approval.

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をサポートする