DeFi AMM Accounting Bugs & Virtual Balance Cache Exploitation

Tip

Impara e pratica il hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Impara e pratica il hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Impara e pratica il hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporta HackTricks

Panoramica

Il pool yETH di Yearn Finance (Nov 2025) ha mostrato come le cache pensate per risparmiare gas all’interno di AMM complessi possano essere usate come arma quando non vengono riconciliate durante le transizioni di stato al bordo. Il weighted stableswap pool traccia fino a 32 liquid staking derivatives (LSD), li converte in equivalenti ETH come virtual balances (vb_i = balance_i × rate_i / PRECISION) e memorizza quei valori in un array packed storage packed_vbs[]. Quando tutti gli LP tokens vengono bruciati, totalSupply scende correttamente a zero ma le slot cache packed_vbs[i] conservano grandi valori storici. Il depositante successivo è stato trattato come il “primo” liquidity provider anche se la cache conteneva ancora liquidità fantasma, permettendo a un attaccante di mintare ~235 settillioni di yETH per soli 16 wei prima di drenare ≈USD 9M in collateral LSD.

Elementi chiave:

  • Derived-state caching: le costose interrogazioni a oracoli vengono evitate persistendo i virtual balances e aggiornandoli in modo incrementale.
  • Mancato reset quando supply == 0: i decrementi proporzionali in remove_liquidity() lasciavano residui non-nulli in packed_vbs[] dopo ogni ciclo di prelievo.
  • Il ramo di inizializzazione si fida della cache: add_liquidity() chiama _calc_vb_prod_sum() e semplicemente legge packed_vbs[] quando prev_supply == 0, assumendo che anche la cache sia azzerata.
  • State poisoning finanziato con flash-loan: i cicli deposit/withdraw amplificavano i residui di arrotondamento senza lockup di capitale, permettendo un over-mint catastrofico nella path del “primo deposito”.

Progettazione della cache e gestione mancante dei boundary

Il flow vulnerabile è semplificato qui sotto:

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

Poiché remove_liquidity() applicava solo decrementi proporzionali, ogni ciclo lasciava residui di arrotondamento in virgola fissa (fixed-point rounding dust). Dopo ≳10 cicli di deposit/withdraw quelle residue si accumulavano in bilanci virtuali fantasma estremamente grandi mentre i saldi on-chain dei token erano quasi vuoti. Bruciare le ultime LP shares impostava totalSupply a zero, ma le cache restavano popolate, predisponendo il protocollo per una inizializzazione malformata.

Exploit playbook (yETH case study)

  1. Flash-loan working capital – Borrow wstETH, rETH, cbETH, ETHx, WETH, etc. da Balancer/Aave per evitare di immobilizzare capitale mentre si manipola il pool.
  2. Poison packed_vbs[] – Eseguire loop di deposit e withdraw attraverso otto asset LSD. Ogni withdrawal parziale tronca packed_vbs[i] − vb_share, lasciando residui >0 per token. Ripetendo il loop si gonfiano bilanci fantasma equivalenti in ETH senza destare sospetti perché i saldi reali si compensano grossomodo.
  3. Force supply == 0 – Burnare tutti gli LP token rimanenti in modo che il pool creda di essere vuoto. Un errore di implementazione lascia intatto il packed_vbs[] avvelenato.
  4. Dust-size “first deposit” – Inviare un totale di 16 wei suddivisi tra gli slot LSD supportati. add_liquidity() vede prev_supply == 0, esegue _calc_vb_prod_sum() e legge la cache obsoleta invece di ricomputare dai saldi effettivi. Il calcolo del mint agisce quindi come se fossero entrati trilioni di USD, emettendo ~2.35×10^26 yETH.
  5. Drain & repay – Redeem della posizione LP gonfiata per tutti gli LSD vaultati, swap yETH→WETH su Balancer, conversione in ETH via Uniswap v3, rimborso dei flash loan/fee e riciclaggio del profitto (es. tramite Tornado Cash). Profitto netto ≈USD 9M mentre solo 16 wei di fondi propri hanno mai toccato il pool.

Generalized exploitation conditions

Simili AMM sono abusabili quando si verificano tutte le seguenti condizioni:

  • Cached derivatives of balances (virtual balances, TWAP snapshots, invariant helpers) persistono tra transazioni per risparmiare gas.
  • Partial updates truncate i risultati (floor division, fixed-point rounding), permettendo a un attaccante di accumulare residui di stato tramite cicli simmetrici di deposit/withdraw.
  • Boundary conditions reuse caches invece di ricomputare la verità fondamentale, specialmente quando totalSupply == 0, totalLiquidity == 0 o la composizione del pool si resetta.
  • Minting logic lacks ratio sanity checks (es. assenza di limiti expected_value/actual_value) per cui un deposito di dust può mintare praticamente l’intera supply storica.
  • Cheap capital is available (flash loans o credito interno) per eseguire dozzine di operazioni di aggiustamento di stato all’interno di una singola transazione o di un bundle strettamente coreografato.

Defensive engineering checklist

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

Applicare lo stesso trattamento a ogni accumulatore cached derivato da saldi o dati oracle.

  • Recompute on initialization branches – Quando prev_supply == 0, ignorare completamente le cache e ricostruire le virtual balances dai saldi effettivi dei token + i rate oracle live.
  • Minting sanity bounds – Revert se lpToMint > depositValue × MAX_INIT_RATIO o se una singola transazione mint >X% della supply storica mentre i deposit totali sono sotto una soglia minima.
  • Rounding-residue drains – Aggregare la dust per token in un sink (treasury/burn) così che ripetute regolazioni proporzionali non facciano derivare le cache dai saldi reali.
  • Differential tests – Per ogni transizione di stato (add/remove/swap), ricomputare lo stesso invariant off-chain con math ad alta precisione e asserire uguaglianza entro un epsilon stretto anche dopo drain completi di liquidità.

Monitoring & response

  • Multi-transaction detection – Tracciare sequenze di eventi deposit/withdraw quasi simmetrici che lasciano il pool con saldi bassi ma cache elevate, seguiti da supply == 0. I rilevatori limitati a singole transazioni perdono queste campagne di poisoning.
  • Runtime simulations – Prima di eseguire add_liquidity(), ricomputare le virtual balances da zero e confrontare con le somme cache; revertare o mettere in pausa se i delta superano una soglia in basis-point.
  • Flash-loan aware alerts – Segnalare transazioni che combinano grandi flash loans, exhaustive pool withdrawals e un deposito finale di tipo dust; bloccare o richiedere approvazione manuale.

References

Tip

Impara e pratica il hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Impara e pratica il hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Impara e pratica il hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporta HackTricks