|
| 1 | +# DeFi AMM Accounting Bugs & Virtual Balance Cache Exploitation |
| 2 | + |
| 3 | +{{#include ../../banners/hacktricks-training.md}} |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +Key ingredients: |
| 10 | + |
| 11 | +- **Derived-state caching**: expensive oracle lookups are avoided by persisting virtual balances and incrementally updating them. |
| 12 | +- **Missing reset when `supply == 0`**: `remove_liquidity()` proportional decrements left non-zero residues in `packed_vbs[]` after each withdrawal cycle. |
| 13 | +- **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. |
| 14 | +- **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. |
| 15 | + |
| 16 | +## Cache design & missing boundary handling |
| 17 | + |
| 18 | +The vulnerable flow is simplified below: |
| 19 | + |
| 20 | +```solidity |
| 21 | +function remove_liquidity(uint256 burnAmount) external { |
| 22 | + uint256 supplyBefore = totalSupply(); |
| 23 | + _burn(msg.sender, burnAmount); |
| 24 | +
|
| 25 | + for (uint256 i; i < tokens.length; ++i) { |
| 26 | + packed_vbs[i] -= packed_vbs[i] * burnAmount / supplyBefore; // truncates to floor |
| 27 | + } |
| 28 | +
|
| 29 | + // BUG: packed_vbs not cleared when supply hits zero |
| 30 | +} |
| 31 | +
|
| 32 | +function add_liquidity(Amounts calldata amountsIn) external { |
| 33 | + uint256 prevSupply = totalSupply(); |
| 34 | + uint256 sumVb = prevSupply == 0 ? _calc_vb_prod_sum() : _calc_adjusted_vb(amountsIn); |
| 35 | + uint256 lpToMint = pricingInvariant(sumVb, prevSupply, amountsIn); |
| 36 | + _mint(msg.sender, lpToMint); |
| 37 | +} |
| 38 | +
|
| 39 | +function _calc_vb_prod_sum() internal view returns (uint256 sum) { |
| 40 | + for (uint256 i; i < tokens.length; ++i) { |
| 41 | + sum += packed_vbs[i]; // assumes cache == 0 for a pristine pool |
| 42 | + } |
| 43 | +} |
| 44 | +``` |
| 45 | + |
| 46 | +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. |
| 47 | + |
| 48 | +## Exploit playbook (yETH case study) |
| 49 | + |
| 50 | +1. **Flash-loan working capital** – Borrow wstETH, rETH, cbETH, ETHx, WETH, etc. from Balancer/Aave to avoid tying up capital while manipulating the pool. |
| 51 | +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. |
| 52 | +3. **Force `supply == 0`** – Burn every remaining LP token so the pool believes it is empty. Implementation oversight leaves the poisoned `packed_vbs[]` untouched. |
| 53 | +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**. |
| 54 | +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. |
| 55 | + |
| 56 | +## Generalized exploitation conditions |
| 57 | + |
| 58 | +You can abuse similar AMMs when all of the following hold: |
| 59 | + |
| 60 | +- **Cached derivatives of balances** (virtual balances, TWAP snapshots, invariant helpers) persist between transactions for gas savings. |
| 61 | +- **Partial updates truncate** results (floor division, fixed-point rounding), letting an attacker accumulate stateful residues via symmetric deposit/withdraw cycles. |
| 62 | +- **Boundary conditions reuse caches** instead of ground-truth recomputation, especially when `totalSupply == 0`, `totalLiquidity == 0`, or pool composition resets. |
| 63 | +- **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. |
| 64 | +- **Cheap capital is available** (flash loans or internal credit) to run dozens of state-adjusting operations inside one transaction or tightly choreographed bundle. |
| 65 | + |
| 66 | +## Defensive engineering checklist |
| 67 | + |
| 68 | +- **Explicit resets when supply/lpShares hit zero**: |
| 69 | + ```solidity |
| 70 | + if (totalSupply == 0) { |
| 71 | + for (uint256 i; i < tokens.length; ++i) packed_vbs[i] = 0; |
| 72 | + } |
| 73 | + ``` |
| 74 | + Apply the same treatment to every cached accumulator derived from balances or oracle data. |
| 75 | +- **Recompute on initialization branches** – When `prev_supply == 0`, ignore caches entirely and rebuild virtual balances from actual token balances + live oracle rates. |
| 76 | +- **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. |
| 77 | +- **Rounding-residue drains** – Aggregate per-token dust into a sink (treasury/burn) so repeated proportional adjustments do not drift caches away from real balances. |
| 78 | +- **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. |
| 79 | + |
| 80 | +## Monitoring & response |
| 81 | + |
| 82 | +- **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. |
| 83 | +- **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. |
| 84 | +- **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. |
| 85 | + |
| 86 | +## References |
| 87 | + |
| 88 | +- [Check Point Research – The $9M yETH Exploit: How 16 Wei Became Infinite Tokens](https://research.checkpoint.com/2025/16-wei/) |
| 89 | + |
| 90 | +{{#include ../../banners/hacktricks-training.md}} |
0 commit comments