Skip to content

Instantly share code, notes, and snippets.

@banteg
Last active April 30, 2026 16:12
Show Gist options
  • Select an option

  • Save banteg/c39d79d2e40130d582c9bd7049611265 to your computer and use it in GitHub Desktop.

Select an option

Save banteg/c39d79d2e40130d582c9bd7049611265 to your computer and use it in GitHub Desktop.
Codex transcript 019ddf0c yETH exploit analysis

Codex transcript: 019ddf0c-93e5-71b3-9529-1d7a0781b402

Single linear branch only. User messages and assistant final messages.

User

can you find a fatal vulnerability in this codebase? don't search the web and don't search outside this repo. ideally work through only static understanding.

Assistant

Found one serious static issue: balanced withdrawals can bypass rate synchronization.

In contracts/Pool.vy, remove_liquidity() burns LP and sends each asset using the stored prev_vb and stored rate, but it never calls _update_rates(). By contrast, swaps, deposits, and single-asset withdrawals all sync rates first. The spec explicitly says any deposit or withdrawal should be preceded by rate synchronization, and rate sync mints/burns the supply delta to staking.

Impact: if an LSD rate has increased in the rate provider but the pool has not synced it yet, a yETH holder can call balanced remove_liquidity() first and withdraw their pro-rata token balances at stale accounting. Economically, they capture the unsynced LSD appreciation, while the staking contract only receives minted yETH for whatever liquidity remains after the withdrawal. That directly steals yield that the design says belongs to st-yETH stakers.

Relevant code:

Fix direction: in normal operation, remove_liquidity() should sync all asset rates before reading prev_supply and computing withdrawals. Because paused/killed mode intentionally allows balanced exits while blocking rate updates, this likely needs a conditional path: sync all rates when not paused, keep existing stale-rate balanced exit behavior only while paused/killed. I did not search the web or outside this repo, and I kept this to static analysis.

User

that's part of it

Assistant

Yes, the bigger fatal shape is:

_update_supply() tries to realize negative rate moves by directly burning yETH from self.staking. If the staking contract does not currently hold enough yETH, the burn reverts, so the lower rate can never be recorded.

Relevant path:

  • rate sync detects the lower rate and recomputes supply: Pool.vy
  • supply decrease burns from staking: Pool.vy
  • Token.burn() underflows/reverts if staking lacks enough yETH: Token.vy

That means any slash/depeg larger than the yETH sitting in staking bricks every path that calls _update_rates() (swap, swap_exact_out, add_liquidity, remove_liquidity_single, update_rates). Stakers can also make this worse by redeeming before the negative rate update lands, because staking withdrawals just transfer yETH out at the current staking accounting: Staking.vy, Staking.vy.

The stale balanced withdrawal issue is the escape/run component: Pool.vy does not sync rates, so once the negative update is unprocessable, yETH holders can still unwind through stale accounting while the system cannot apply the intended slashing.

So the fatal finding is not just “balanced withdrawals miss rate sync”; it is:

Negative rate updates are only payable by immediately burning staking’s current yETH balance. If that burn cannot be satisfied, the pool cannot sync the slash, most operations freeze, and stale balanced exits remain open.

User

what about invariants

Assistant

The invariant angle is the crux: the pool preserves the stored invariant while violating the economic invariant.

The stored invariant is roughly:

pool.supply == D(packed_vbs, stored_rates, weights, amplification)

Balanced remove_liquidity() scales supply and every stored virtual balance down by the same fraction, then recomputes vb_prod. That preserves the invariant over cached state, which is why round-trip invariant tests pass. But it never checks:

packed_vbs[i] == ERC20(asset[i]).balanceOf(pool) * current_rate_provider.rate(asset[i]) / 1e18

So after a rate move, the pool can remain internally “invariant-correct” against stale rates while being wrong against real asset backing.

For negative rate moves, the invariant transition is not total. _update_rates() tries to move from old D to new lower D, and _update_supply() enforces that by burning the entire D decrease from staking. If staking cannot pay that burn, the transition reverts. There is no debt/slashing accumulator that lets the invariant state advance while charging stakers over the vault accounting. So the protocol can get stuck at an old invariant that it knows is stale but cannot update.

There’s also a smaller invariant-domain bug: a full balanced withdrawal cannot represent the empty pool. If _lp_amount == prev_supply, then supply == 0 and every vb == 0, but line 561 computes supply * weight / vb, i.e. 0 / 0, and reverts. In killed mode, where balanced withdrawals are the only allowed exit, this means the last full exit path is structurally broken unless users leave dust.

So I’d frame the invariant failure as:

  1. The invariant is only over cached virtual balances, not live balances and current rates.
  2. Rate-sync is the only bridge from live truth back into invariant state.
  3. A negative bridge can revert because it requires burning liquid yETH from staking.
  4. Balanced withdrawal preserves the cached invariant and bypasses the bridge entirely.
  5. The empty-pool boundary is outside the invariant math’s implemented domain.

User

what about invariant recalc

Assistant

Invariant recalc does not save this design as written, because the recalc helpers only rebuild from cached virtual balances.

_calc_vb_prod_sum() does:

s += self.packed_vbs[asset] & VB_MASK
p = self._calc_vb_prod(s)

It never reads token balances and never calls rate providers. So it can rebuild a clean vb_prod for the current cached packed_vbs, but it cannot discover that cached virtual balances are stale versus live asset backing. In other words, it repairs math drift, not oracle/accounting drift.

The only bridge from live rate truth to invariant state is _update_rates(), and that bridge is coupled to _update_supply() settlement. If settlement reverts because staking cannot burn enough yETH, the invariant state cannot be advanced to the real lower-asset-value state.

So there are two different recalcs:

  1. Cached invariant recalc D(packed_vbs, stored_rates) can be recomputed. This preserves internal consistency but may still be economically false.

  2. Live invariant recalc Should be D(balanceOf(pool) * current_rate_provider.rate(asset)). The code does not have this as a standalone recovery path.

A robust fix needs live recalc to be separated from staking settlement. Conceptually:

  • read current rates and actual pool balances;
  • rebuild all packed_vbs, vb_sum, and vb_prod from live balances;
  • compute new D;
  • set pool supply/invariant state to that new D;
  • handle old_D - new_D as a staking loss via vault accounting or debt, not by requiring an immediate yETH burn that can revert.

Otherwise, the invariant remains hostage to whether the staking contract currently has enough liquid yETH to burn.

User

the math can break in a fatal way, study more carefully, it's both math and evm related

Assistant

Yes. The invariant recalculation itself has a fatal EVM-domain failure.

The dangerous line is in _calc_supply():

sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)

with:

l = amplification * vb_sum
s = previous supply
r = vb_prod
d = amplification - PRECISION

Mathematically this is the Newton step:

D_next = (A * sigma - D * pi) / (A - 1)

That only makes sense in unsigned EVM arithmetic if:

A * sigma >= D * pi

But during a sufficiently large rate decrease, or after a weight/rate state that makes pi large, that numerator can go negative in real arithmetic. The code uses unsafe_sub, so the EVM does not revert; it wraps modulo 2**256 and feeds a gigantic fake positive value into the Newton iteration.

That is both a math bug and an EVM bug:

  • In real math, the Newton step has left the assumed positive domain.
  • In EVM arithmetic, unsafe_sub turns the negative value into a huge uint256.
  • The next loop then updates r with unsafe_mul(r, sp), so the product term can also wrap.
  • The solver can then fail to converge, revert later on packing, or compute nonsense state.

This sits exactly on the rate-sync path:

  • _update_rates() adjusts vb_prod when a rate changes: Pool.vy
  • then calls _update_supply(): Pool.vy
  • _update_supply() calls _calc_supply(): Pool.vy
  • _calc_supply() performs the wrapped Newton step: Pool.vy

So the invariant recalc is not robust under the exact condition where it matters most: a sharp slash/depeg. Instead of cleanly reducing D, socializing loss, or entering a controlled killed-mode accounting path, it can fall out of the real-number domain and into uint256 wraparound.

The fix is not just “check before unsafe_sub”. The solver needs a domain guard and a fallback strategy. At minimum:

assert l >= s * r  # with overflow-safe structure

but a production fix likely needs a bounded/bracketed invariant solver or explicit depeg/slashing path that can set the new invariant state without relying on this Newton step being valid for every rate move.

User

what steps could result in hitting this failure mode?

Assistant

A concrete path:

  1. Pool starts in a normal balanced state. Example: 4 assets, equal weights, supply ~= vb_sum, vb_prod ~= 1e18.

  2. One asset’s rate provider reports a sharp decrease. Downward rate moves have no cap in _update_rates(). The 10% cap only applies upward.

  3. Someone triggers a rate-syncing path: update_rates([asset]), swap, swap_exact_out, add_liquidity, or remove_liquidity_single.

  4. _update_rates() updates the affected virtual balance downward:

    vb = prev_vb * rate / prev_rate
    vb_sum = vb_sum + vb - prev_vb

    and increases vb_prod by factoring in the rate ratio:

    vb_prod = vb_prod * pow_up(prev_rate / rate, wn)
  5. _update_supply() calls _calc_supply() to solve the new D.

  6. _calc_supply() computes:

    sp = (amplification * vb_sum - supply * vb_prod) / (amplification - 1e18)

    If the rate drop is large enough, supply * vb_prod exceeds amplification * vb_sum.

  7. Because the code uses unsafe_sub, the negative numerator wraps to a huge uint256 instead of reverting cleanly.

For a rough equal-weight 4-asset example, with the repo’s common amplification = calc_w_prod(weights) * 10, a single asset dropping to around 0.01% of its old rate is enough to make the numerator negative. The exact threshold depends on weights and amplification.

The important operational trigger is simple: a large downward rate update followed by any operation that syncs that asset. It does not require an exotic user action; a normal public update_rates([i]) call can hit it once the oracle/provider reports the bad rate.

User

what if we don't consider asset providers

Assistant

If we exclude asset providers, the fatal path shifts from “bad rate update” to “bad invariant recalc under changed weights/amplification”.

The clean no-provider sequence is:

  1. Pool has existing imbalance in cached VBs.
  2. Management schedules or performs a ramp with lower amplification, extreme target weights, or a target weight that makes an already-underbalanced asset “more important”.
  3. Anyone calls update_weights() or any path that reaches _update_weights().
  4. _update_weights() rewrites cached weights/amplification and recomputes vb_prod from the same balances under the new weights.
  5. _update_supply() then calls _calc_supply(...).
  6. If the new parameterization makes:
supply * vb_prod > amplification * vb_sum

then Pool.vy:1361 executes:

unsafe_sub(l, unsafe_mul(s, r))

where the real value is negative. Because it is unsafe_sub, the EVM wraps it modulo 2**256 instead of reverting cleanly. The Newton step gets a huge bogus sp, subsequent unsafe_muls can wrap too, and the invariant recalc can fail by non-convergence, packing overflow, or nonsense supply.

The important thing is that no oracle/provider is needed. A weight/amplification update alone can move the cached invariant into a domain where the solver’s real-number assumptions are false.

There is also a simpler no-provider footgun: set_ramp() only checks _amplification > 0, not _amplification > PRECISION or any safe lower bound. Later _calc_supply() computes:

d: uint256 = _amplification - PRECISION

at Pool.vy:1354. So a ramp to A <= 1e18 can make invariant recalculation revert outright. That is governance-triggered, but once scheduled, ordinary public calls that update weights can hit it.

For pure permissionless users with no provider and no management change, I don’t currently see the same unsafe_sub(l - s*r) path as clearly reachable through normal swaps/deposits alone, because those paths generally solve/check before committing state. But there is still a separate public math boundary bug: full balanced withdrawal hits 0 / 0 at Pool.vy:561, so burning the entire LP supply through remove_liquidity() reverts instead of emptying the pool.

User

if we don't consider management, just an outside attacker with normal user privileges

Assistant

For an already-initialized pool, with no provider changes and no management/ramp changes, I don’t see a normal outside user path that poisons global state into the _calc_supply wrap. Swaps and single-sided withdrawals solve via _calc_vb before committing; deposits increase vb_sum and reduce the product term, so they move away from the bad supply * vb_prod > amplification * vb_sum condition.

The public-only path is the empty-pool boundary:

  1. Pool has supply == 0, either fresh deployment or after a full exit.
  2. Attacker calls add_liquidity() with every asset non-zero, but highly imbalanced.
  3. Initial deposit skips band checks because prev_supply == 0.
  4. It computes vb_prod = _calc_vb_prod(vb_sum).
  5. Then _calc_supply(..., _supply = vb_sum, _vb_prod = vb_prod, _vb_sum = vb_sum) hits:
l - s*r = vb_sum * (amplification - vb_prod)
  1. If the imbalance makes vb_prod > amplification, Pool.vy:1361 wraps through unsafe_sub.

That can either revert/non-converge or converge to nonsense under modulo arithmetic. The rough threshold is not exotic: with test weights 10/20/30/40 and amplification around 167e18, making the 40% asset less than about 4% of its target virtual balance is enough for vb_prod > amplification.

So: against a live initialized pool, public-only reachability looks weak. Against an empty or re-empty pool, a normal user can hit the fatal math path through first initialization, with no providers or management involved.

User

think more how evm div behaves

Assistant

Yes. The key EVM detail changes the public-only story:

unsafe_div(x, 0) compiles down to EVM DIV, and EVM DIV by zero returns 0.

So full balanced withdrawal does not revert at the 0 / 0 boundary. In Pool.vy:557-561, if _lp_amount == prev_supply:

supply = 0
dvb = prev_vb
vb = 0
unsafe_div(supply * weight, vb) = DIV(0, 0) = 0
_pow_down(0, weight*n) = 0
vb_prod = 0
vb_sum = 0

The pool is successfully reset to supply = 0, vb_prod = 0, vb_sum = 0, with all asset VBs zeroed. That means the next caller enters the prev_supply == 0 initialization path in add_liquidity().

Then the outside-attacker path is:

  1. Pool is empty, either fresh or after the last LP does full remove_liquidity().
  2. Attacker calls add_liquidity() with all assets nonzero but extremely imbalanced.
  3. Initial deposit skips band checks because prev_supply == 0.
  4. _calc_vb_prod_sum() computes a huge product term from the attacker-chosen ratios.
  5. If vb_prod > amplification, _calc_supply() reaches Pool.vy:1361:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)

Mathematically l - s*r is negative. On EVM it wraps to a huge uint256, then DIV produces a huge Newton step instead of reverting.

That can mint an absurd LP supply to the attacker for a dust-skewed initialization. Later honest deposits are priced against that poisoned supply/invariant, so the attacker can own most of the pool after contributing little balanced value.

So the public-only failure mode is not a live-pool swap from normal state. It is:

empty pool or final full balanced exit
→ attacker reinitializes with pathological nonzero ratios
→ unsafe math wraps instead of failing
→ attacker receives bogus oversized LP supply
→ later liquidity is diluted/drainable

User

now read /Users/banteg/dev/banteg/yeth-exploit/report.tex in full and rank how close you were

Assistant

I’d rank my final position at 7/10 close, but my path there was uneven.

I eventually identified the core public-only failure: empty pool reinitialization, skewed dust add_liquidity, _calc_supply, negative Newton numerator, unsafe_sub wrap, absurd LP mint. That matches the report’s root cause in report.tex.

Where I was close:

  • Found the right vulnerable formula in _calc_supply: A * Sigma - D * Pi.
  • Identified the unchecked underflow/wrap as the fatal arithmetic failure.
  • Eventually corrected the public-only reachability: full balanced exit resets supply, vb_sum, and vb_prod to zero, enabling first-deposit logic.
  • Recognized that a skewed first deposit can make Pi > A and trigger the bad Newton step.

Where I missed or was late:

  • I initially got EVM division wrong by treating unsafe_div(0, 0) as a revert; the report’s path depends on raw EVM DIV returning 0.
  • I did not identify remove_liquidity(0) as a key prelude primitive. The report says it recomputes Pi from scratch without reconciling supply, which matters a lot.
  • I missed the rounding drift mechanism: incremental _pow_up updates versus recomputation with _pow_down.
  • I did not reconstruct the actual on-chain sequence: add/remove cycles, remove_liquidity(0), rate updates, OETH rebase, final full exit, dust add.
  • I did not distinguish clearly enough between Pi_input used by the solver and Pi_stored after the solver. The report shows that distinction is essential.
  • I only gave approximate toy thresholds, not the actual 8-asset vector [1,1,1,1,1,1,1,9], Sigma = 16, Pi_input ~= 9.13e20, and mint ~2.3544e56.

So: root-cause math: 8/10, public-only final trigger: 8/10 after correction, full exploit/reachability sequence: 4/10, overall: 7/10. I found the fatal shape, but not the real exploit choreography until you pushed on EVM division.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment