DeFi AMM Accounting Bugs & Virtual Balance Cache Exploitation

Tip

Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Support HackTricks

Overview

Yearn Finance’s yETH pool (Nov 2025) exposed how gas-saving caches inside complex AMMs can be weaponized when they are not reconciled during boundary-state transitions. The weighted stableswap pool tracks up to 32 liquid staking derivatives (LSDs), converts them to ETH-equivalent virtual balances (vb_i = balance_i × rate_i / PRECISION), and stores those values in a packed storage array packed_vbs[]. When all LP tokens are burned, totalSupply correctly drops to zero but the cached packed_vbs[i] slots retained huge historic values. The subsequent depositor was treated as the “first” liquidity provider even though the cache still held phantom liquidity, letting an attacker mint ~235 septillion yETH for only 16 wei before draining ≈USD 9M in LSD collateral.

Key ingredients:

  • Derived-state caching: expensive oracle lookups are avoided by persisting virtual balances and incrementally updating them.
  • Missing reset when supply == 0: remove_liquidity() proportional decrements left non-zero residues in packed_vbs[] after each withdrawal cycle.
  • Initialization branch trusts the cache: add_liquidity() calls _calc_vb_prod_sum() and simply reads packed_vbs[] when prev_supply == 0, assuming the cache is also zeroed.
  • Flash-loan financed state poisoning: deposit/withdraw loops amplified rounding residues with no capital lockup, enabling a catastrophic over-mint in the “first deposit” path.

Cache design & missing boundary handling

The vulnerable flow is simplified below:

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 – Borrow wstETH, rETH, cbETH, ETHx, WETH, etc. from Balancer/Aave to avoid tying up capital while manipulating the pool.
  2. Poison packed_vbs[] – Loop deposits and withdrawals across eight LSD assets. Each partial withdrawal truncates packed_vbs[i] − vb_share, leaving >0 residues per token. Repeating the loop inflates phantom ETH-equivalent balances without raising suspicion because real balances roughly net out.
  3. Force supply == 0 – Burn every remaining LP token so the pool believes it is empty. Implementation oversight leaves the poisoned packed_vbs[] untouched.
  4. Dust-size “first deposit” – Send a total of 16 wei divided across the supported LSD slots. add_liquidity() sees prev_supply == 0, runs _calc_vb_prod_sum(), and reads the stale cache instead of recomputing from actual balances. The mint calculation therefore acts as if trillions of USD entered, emitting ~2.35×10^26 yETH.
  5. Drain & repay – Redeem the inflated LP position for all vaulted LSDs, swap yETH→WETH on Balancer, convert to ETH via Uniswap v3, repay flash loans/fees, and launder the profit (e.g., through Tornado Cash). Net profit ≈USD 9M while only 16 wei of own funds ever touched the pool.

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

Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Support HackTricks