DeFi AMM Cache Exploit Audit
This skill helps you identify and analyze virtual balance cache exploitation vulnerabilities in DeFi AMMs, where gas-saving caches are weaponized during boundary-state transitions.
Vulnerability Pattern
The core issue: Cached derived state is not reset when pool liquidity hits zero, allowing attackers to poison the cache and mint inflated LP tokens on the "first deposit" after a full drain.
Key Indicators
Look for these patterns in AMM code:
- Cached virtual balances stored in packed arrays for gas efficiency
- Proportional updates using floor division that leave rounding residues
- Missing cache reset when
totalSupply == 0ortotalLiquidity == 0 - Initialization branches that read cached values instead of recomputing from actual balances
- No sanity bounds on mint amounts relative to actual deposits
Detection Workflow
Step 1: Identify Cached State
Search for storage patterns that cache derived values:
// Red flags:
uint256[] packed_vbs; // Virtual balances cache
uint256[] cached_rates; // Oracle rate cache
uint256 invariant_helper; // Cached invariant
uint256[] twap_snapshots; // TWAP cache
Step 2: Trace Update Logic
Find where caches are updated. Look for:
// Vulnerable pattern: proportional decrement with truncation
packed_vbs[i] -= packed_vbs[i] * burnAmount / supplyBefore;
// Safe pattern: explicit reset on zero supply
if (totalSupply == 0) {
for (uint256 i; i < tokens.length; ++i) packed_vbs[i] = 0;
}
Step 3: Check Boundary Conditions
Examine initialization paths:
// Vulnerable: trusts cache on first deposit
if (prevSupply == 0) {
sumVb = _calc_vb_prod_sum(); // Reads stale cache!
}
// Safe: recomputes from ground truth
if (prevSupply == 0) {
sumVb = _recompute_from_balances(); // Ignores cache
}
Step 4: Verify Mint Bounds
Check if mint logic has sanity checks:
// Missing: no bounds on mint amount
uint256 lpToMint = pricingInvariant(sumVb, prevSupply, amountsIn);
// Should have: revert if mint exceeds reasonable ratio
require(lpToMint <= depositValue * MAX_INIT_RATIO, "Mint ratio exceeded");
Exploit Simulation
Use the included scripts to test for this vulnerability:
# Analyze Solidity code for cache patterns
python scripts/detect_cache_vulnerabilities.py <contract.sol>
# Simulate the exploit conditions
python scripts/simulate_cache_poison.py --config <config.json>
Remediation Checklist
Immediate Fixes
Reset caches on zero supply:
if (totalSupply == 0) { for (uint256 i; i < tokens.length; ++i) { packed_vbs[i] = 0; } }Recompute on initialization:
if (prevSupply == 0) { // Ignore cache, compute from actual balances sumVb = _compute_from_actual_balances(); }Add mint sanity bounds:
uint256 maxMint = depositValue * 100; // 100x max ratio require(lpToMint <= maxMint, "Mint ratio exceeded");
Long-term Improvements
- Differential testing: Recompute invariants off-chain with high-precision math after every state transition
- Rounding residue drains: Aggregate dust into treasury/burn to prevent cache drift
- Runtime simulations: Before critical operations, recompute from scratch and compare with cached values
- Multi-transaction monitoring: Track sequences of deposit/withdraw cycles that could poison state
Real-World Case Study: yETH (Nov 2025)
Impact: ~$9M drained with only 16 wei of attacker capital
Attack flow:
- Flash-loan multiple LSD assets (wstETH, rETH, cbETH, etc.)
- Loop deposits/withdrawals to accumulate rounding residues in
packed_vbs[] - Burn all LP tokens, setting
totalSupply == 0while cache remains poisoned - Deposit 16 wei total across LSD slots
- Pool reads stale cache, mints ~2.35×10²⁶ yETH
- Redeem LP position, drain vault, repay flash loans
Root cause: remove_liquidity() used proportional decrements with floor division, leaving residues. add_liquidity() trusted the cache on first deposit instead of recomputing.
Testing Strategy
Unit Tests
function testCacheResetOnZeroSupply() public {
// 1. Add liquidity
// 2. Remove liquidity in cycles to accumulate residues
// 3. Burn final LP tokens (supply == 0)
// 4. Assert: all cached values are zero
// 5. Add new liquidity
// 6. Assert: mint amount matches actual deposit value
}
Fuzz Tests
- Fuzz deposit/withdraw cycles with varying amounts
- Check that
cached_value ≈ actual_valuewithin epsilon after each operation - Test boundary:
totalSupply == 0transitions - Test mint ratio:
lpMinted / depositValueshould be bounded