Back to Blog

#5 Yearn Finance-Vorfall: Unsichere Arithmetik im Invariant Solver macht seinem Namen alle Ehre

Code Auditing
February 11, 2026
24 min read

Am 30. November 2025 wurde der yETH Weighted Stable Pool von Yearn Finance mit einem Schaden von über 9 Millionen USD angegriffen [1]. Die Hauptursachen waren unsichere Arithmetik im Invarianten-Solver _calc_supply() sowie ein nicht deaktivierter Bootstrap-Pfad, der einen erneuten Einstieg in die Initialisierungslogik ermöglichte. Der offizielle Nachbericht (Post-Mortem) [2] listet fünf Punkte als Grundursachen auf; wir klassifizieren diese neu als zwei Defekte (die oben genannten Schwachstellen) und zwei architektonische Voraussetzungen, die erst durch das Vorhandensein dieser Defekte ausnutzbar wurden. Andere verfügbare Analysen konzentrieren sich auf die schrittweisen Details der Angriffstransaktion. Zwischen allgemeinen Zusammenfassungen und Transaktionsdetails klafft eine Lücke: Warum und wie funktionierte der Angriff tatsächlich? Dieser Beitrag schließt diese Lücke und nutzt Foundry- sowie Python-Simulationen, um die Entwicklung der Schlüsselwerte Schritt für Schritt nachzuvollziehen und aufzuzeigen, wo die Berechnungen fehlschlagen.

Diese Analyse liefert hauptsächlich die folgenden drei Beiträge:

  1. Schadensaufschlüsselung nach Schwachstellen. Die beiden Schwachstellen sind nicht voneinander abhängig: Die unsichere Arithmetik allein verursachte Verluste von ca. 8,1 Mio. USD (90 % der Gesamtsumme), während der Bootstrap-Pfad einen zusätzlichen Betrag von ca. 0,9 Mio. USD ermöglichte. Dies verdeutlicht, welche Schwachstelle die primäre war.
  2. Neuklassifizierung der Grundursachen. Die fünf Grundursachen aus dem offiziellen Bericht lassen sich besser als zwei Implementierungsfehler (durch Zusammenfassung von drei der fünf Punkte) sowie zwei architektonische Voraussetzungen verstehen, die erst in Kombination mit den Fehlern ausnutzbar wurden.
  3. Korrektur technischer Missverständnisse. Die Behauptung, dass „ein Underflow in der zweiten Iteration den Produktterm auf Null setzt“, ist nicht haltbar: Unsere Simulationen zeigen, dass das Produkt durch Rundung bei der Division auf Null gesetzt wird, nicht durch einen Underflow, und dass der gewinnbringende Underflow in einer völlig anderen Phase auftritt.

Der Rest dieses Beitrags ist wie folgt gegliedert: Abschnitt 0x1 gibt einen Hintergrund zum Weighted Stable Pool von yETH und seinem Invarianten-Solver. Abschnitt 0x2 analysiert die beiden Grundursachen und ihre Fehlermodi. Abschnitt 0x3 verfolgt den dreiphasigen Angriff im Detail. Abschnitt 0x4 korrigiert zwei häufige Missverständnisse anhand von Simulationsbeweisen. Abschnitt 0x5 schließt mit Empfehlungen.

TL;DR

Grundursachen: Es wurden zwei Schwachstellen ausgenutzt, jedoch mit asymmetrischen Auswirkungen:

  1. Unsichere Arithmetik in _calc_supply() (primär, ca. 8,1 Mio. USD). Die Funktion, die das yETH-Angebot basierend auf dem Pool-Status neu berechnet, weist zwei arithmetische Fehler auf: Das Abrunden in unsafe_div() kann den internen Produktterm auf Null setzen, und ein Underflow in unsafe_sub() kann einen Zwischenwert in eine enorme positive Ganzzahl umwandeln. Diese Schwachstelle allein reichte aus, um den yETH Weighted Stableswap Pool leerzuräumen.
  2. Nicht deaktivierter Bootstrap-Pfad (sekundär, ca. 0,9 Mio. USD). Der Initialisierungszweig prev_supply == 0 wurde nach der Bereitstellung nie dauerhaft gesperrt. Nachdem die erste Schwachstelle das Angebot auf Null reduziert hatte, wurde dieser Pfad erreichbar, was zusätzliche Gewinne aus dem yETH/WETH Curve Pool ermöglichte.

Innerhalb der Schwachstelle „unsichere Arithmetik“ wurde nur der Fehler durch Abrunden (Fehlermodus A) in Phase 2 verwendet; der Underflow-Fehler (Fehlermodus B) ist vom Bootstrap-Pfad abhängig, und zusammen ermöglichten sie Phase 3.

Der Angreifer führte eine dreiphasige Sequenz aus:

  1. Vorbereitung: Verzerrung der Vermögensverteilung des Pools durch wiederholte Zyklen des Hinzufügens/Entfernens, wodurch ein extremes Ungleichgewicht bei den virtuellen Beständen entstand.
  2. Angebotmanipulation: Ausnutzen des Rundungsfehlers in _calc_supply(), um den Produktterm auf Null zu reduzieren, und dann das Gesamtangebot durch eine Reihe von Mint/Burn-Operationen auf Null zu entleeren. Alle LSTs des Pools wurden abgezogen und anschließend in WETH getauscht, was zu Verlusten von ca. 8,1 Mio. USD führte.
  3. Gewinnentnahme: Auslösen des Bootstrap-Pfads (prev_supply == 0) mit Kleinstbeträgen, Ausnutzen des Underflows in _calc_supply(), um ca. 2,35×10⁵⁶ yETH zu minten, welche zum Leer-Räumen des yETH/WETH Curve Pools verwendet wurden, was zu Verlusten von ca. 0,9 Mio. USD führte.

Zwei häufige Missverständnisse korrigiert:

  • „Die Invariante bricht, weil pow_up() und pow_down() unterschiedlich runden.“ Wir haben dies überprüft, indem wir in einer Foundry-Simulation pow_up() durch pow_down() ersetzt haben: Der Exploit funktioniert weiterhin. Rundungsabweichungen sind keine Grundursache.
  • „Ein Underflow in der zweiten Iteration lässt einen Zwischenterm auf Null kollabieren.“ Unsere Foundry- und Python-Simulationen zeigen, dass in der zweiten Iteration kein Underflow auftritt. Der tatsächliche Wert liegt bei ca. 1,91e19 (nicht bei ca. 1,94e18 wie behauptet) und ist ein legitimes Ergebnis einer korrekten Subtraktion. Was das Produkt auf Null setzt, ist die nachfolgende Abrundung bei der Division, kein Underflow.

0x1 Hintergrund

Zwei Pools haben bei diesem Vorfall Vermögenswerte verloren: der yETH weighted stableswap pool (ein Yearn-Pool mit LSTs, ca. 8,1 Mio. USD Verlust) und der yETH/WETH Curve pool (ein Curve Stableswap-Pool, ca. 0,9 Mio. USD Verlust). Die Kernschwachstelle liegt im yETH weighted stableswap pool. Dieser Abschnitt vermittelt den Hintergrund, der zum Verständnis der Schwachstelle und des Angriffs erforderlich ist.

0x1.1 Virtuelle Bestände und die Invariante

Das yETH-Protokoll ist ein Automated Market Maker (AMM) für Ethereum Liquid Staking Tokens (LSTs) [3]. Der betroffene yETH weighted stableswap pool fasst mehrere LSTs in einem einzigen Pool zusammen: Nutzer hinterlegen LSTs und erhalten yETH als Pool-Anteil-Tokens.

Da jeder LST gestaktes ETH darstellt, das im Laufe der Zeit Belohnungen ansammelt, ändert sich sein Wechselkurs im Verhältnis zu Basis-ETH. Um die Buchhaltung zu vereinheitlichen, definiert der Pool für jedes Asset einen virtuellen Bestand xix_i: On-Chain-Bestand × Wechselkurs. Dies normiert alle Assets auf Einheiten der Beacon-Chain-ETH. Die Summe aller virtuellen Bestände wird als σ=xi\sigma = \sum x_i bezeichnet.

Der Pool enthält 8 Assets (indiziert 0–7), jedes mit einem festgelegten Gewicht wiw_i:

Index Asset Index Asset
0 sfrxETH 4 rETH
1 wstETH 5 apxETH
2 ETHx 6 WOETH
3 cbETH 7 mETH

Der Zustand des Pools wird durch eine auf Gewichtungen basierende StableSwap-Invariante gesteuert [4]:

Afn  σ+D=Afn  D+Dπ(1)\mathit{Af}^{\,n}\;\sigma + D = \mathit{Af}^{\,n}\;D + D \cdot \pi \tag{1}

wobei:

  • DD der Invarianten-Skalierungsfaktor ist, der direkt dem gesamten yETH-Angebot dieses Pools entspricht. Wenn der Pool perfekt ausbalanciert ist, gilt D=σD = \sigma.
  • π\pi der gewichtete Produktterm ist, definiert als π=Dni(wixi)vi\pi = D^n \prod_{i} \left(\frac{w_i}{x_i}\right)^{v_i}, wobei wiw_i das Gewicht des Assets i und vi=winv_i = w_i \cdot n ist.
  • Af\mathit{Af} der Verstärkungsfaktor ist, ein einzelner Protokollparameter (nicht A×fA \times f). Afn\mathit{Af}^{\,n} bezeichnet diesen Faktor zur Potenz nn, wobei nn die Anzahl der Assets ist (8 in diesem Pool). Er steuert die Form der Kurve zwischen Konstant-Summe (nahe Gleichgewicht) und Konstant-Produkt (an den Extremen).

Die Schlüsseleigenschaft: DD hat keine geschlossene Lösung. Es muss numerisch gelöst werden. Dieser Solver, _calc_supply(), ist der Ort, an dem die arithmetische Schwachstelle liegt.

0x1.2 Der Invarianten-Solver

Das Protokoll berechnet DD durch eine Fixpunktiteration, die auf 256 Runden begrenzt ist. Dieser Algorithmus ist als _calc_supply() im Code implementiert (detailliert in Abschnitt 0x2.1). Jede Runde führt drei Schritte aus:

Schritt 1: Aktualisierung der Angebotsschätzung.

Dm+1=AfnσDmπmAfn1(2)D_{m+1} = \frac{\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m}{\mathit{Af}^{\,n} - 1} \tag{2}

Schritt 2: Aktualisierung des Produktterms zur Anpassung an das neue Angebot.

πm+1=πm(Dm+1Dm)n(3)\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n \tag{3}

Schritt 3: Überprüfung auf Konvergenz.

Wenn Dm+1Dm<ϵ|D_{m+1} - D_{m}| < \epsilon, wird DmD_{m} zurückgegeben; andernfalls wird ab Schritt 1 wiederholt.

Die Anfangswerte D0D_0, π0\pi_0 und σ\sigma beeinflussen die frühen Iterationen; obwohl sie theoretisch für die endgültige Konvergenz irrelevant sind, beeinflussen sie die Ergebnisse in der Praxis aufgrund der endlichen Anzahl von Iterationen und der Festkommaarithmetik.

Die Implementierung verwendet Festkomma-Integer-Operationen: Division rundet ab, und die Subtraktion schützt nicht vor Underflow. Unter normalen Poolbedingungen bleiben die Zwischenwerte innerhalb sicherer Bereiche. Unter extremen Poolzuständen tun sie dies nicht. Abschnitt 0x2.1 analysiert diese Fehlermodi im Detail.

0x1.3 Die drei Schnittstellen und der Invarianten-Solver

Das Protokoll bietet drei Einstiegspunkte, die den Poolstatus beeinflussen, indem sie den gewichteten Produktterm π\pi aktualisieren (im Code als vb_prod gespeichert):

Schnittstelle Funktion Löst _calc_supply() aus?
add_liquidity() Hinterlegt Assets in beliebigen Anteilen Ja
update_rates() Aktualisiert externe Wechselkurse Ja
remove_liquidity() Zieht Assets proportional zum Gewicht ab Nein (verwendet proportionale Skalierung)

Die Asymmetrie ist von Bedeutung: add_liquidity() erlaubt Einzahlungen in beliebigen Anteilen (es kann den Pool massiv verzerren), während remove_liquidity() immer proportional abzieht. Wiederholte Zyklen aus Hinzufügen/Entfernen können den Pool daher in zunehmend unausgewogene Zustände drängen.

Der Mechanismus zur Aktualisierung von Kursen

Wie oben diskutiert, werden virtuelle Bestände (xix_i) basierend auf den Wechselkursen der LSTs berechnet. Daher ist es wichtig, die Art der Kursaktualisierung zu verstehen.

Insbesondere können die Funktionen add_liquidity() und update_rates() Kurse über die interne Funktion _update_rates() aktualisieren, während die Funktion remove_liquidity() keine Kurs-Synchronisierung durchführt.

  • add_liquidity() ruft _update_rates() auf, bevor kritische Operationen ausgeführt werden, um sicherzustellen, dass die Wechselkurse der Assets auf den neuesten Stand synchronisiert sind.
  • update_rates() erlaubt manuelle Kurs-Aktualisierungen.

Die Funktion _update_rates() prüft, ob die im Kontrakt aufgezeichneten Wechselkurse mit den externen Kursen übereinstimmen. Wenn eine Diskrepanz festgestellt wird, löst sie eine Neuberechnung der virtuellen Bestände aus und aktualisiert anschließend die Invariante; andernfalls wird der Aktualisierungsprozess übersprungen.

Wie jede Schnittstelle mit π umgeht

Basierend darauf, wie sie die Invariante beeinflussen, können diese drei Funktionen in zwei Kategorien eingeteilt werden. Insbesondere erlauben add_liquidity() und update_rates() nicht-proportionale Änderungen bei den virtuellen Beständen und erfordern daher eine iterative Neuberechnung des Angebots DD und des Produkts π\pi. Im Gegensatz dazu zieht remove_liquidity() Liquidität proportional ab und erfordert keine iterative Berechnung.

Die Basisformel für die Berechnung des Produkts von Grund auf lautet:

π=i(Dwixi)nwi(4)\pi = \prod_{i} \left(\frac{D \cdot w_i}{x_i}\right)^{n \cdot w_i} \tag{4}

wobei DD das Angebot, wiw_i das Gewicht des Assets ii, xix_i sein virtueller Bestand (im Code als vb[i] gespeichert) und nn die Anzahl der Assets ist. Diese Form ist algebraisch äquivalent zur Definition in Abschnitt 0x1.1, wobei DnD^n in das Produkt verteilt ist.

  1. add_liquidity() hat zwei Pfade (Code in Abschnitt 0x2.2):
  • Bootstrap-Pfad (wenn prev_supply == 0): Berechnet vb_prod von Grund auf mit Gleichung (4). Dass dieser Pfad nach der Bereitstellung zugänglich blieb, ist die Schwachstelle im Zustandsmanagement, die in Abschnitt 0x2.2 diskutiert wird.
  • Normaler Pfad (wenn prev_supply > 0): Der Berechnungsprozess ist in zwei Schritte unterteilt:
    • a) Verwendet eine inkrementelle Aktualisierung basierend auf dem Verhältnis alter zu neuer virtueller Bestände:

      πestimated=πi=0n1(xixi)win(5)\pi_{\text{estimated}} = \pi \cdot \prod_{i=0}^{n-1} \left(\frac{x_i}{x_i'}\right)^{w_i \cdot n} \tag{5}

      wobei xix_i und xix_i' die virtuellen Bestände vor und nach der Einzahlung sind.

    • b) Iterative Kalibrierung des präzisen Wertes durch Aufruf von _calc_supply() mit dieser Schätzung als Eingabe, wobei die Invariante DD und der exakte Wert von π\pi neu berechnet werden.

  1. update_rates() wird ausgelöst, wenn sich Wechselkurse ändern, was dazu führt, dass die virtuellen Bestände der entsprechenden Assets aktualisiert werden. Ihr nachfolgender Berechnungsfluss folgt dem normalen Pfad von add_liquidity(), d. h. die Invariante wird iterativ neu berechnet. Zusätzlich mintet oder verbrennt der Kontrakt basierend auf dem neu berechneten Angebot yETH, um sicherzustellen, dass das Liquiditätsangebot mit dem aktuellen Zustand der virtuellen Bestände übereinstimmt.

  2. remove_liquidity() berechnet vb_prod immer von Grund auf mit Gleichung (4), nachdem jeder virtuelle Bestand proportional reduziert wurde.


0x2 Analyse der Grundursache

Es wurden zwei Schwachstellen ausgenutzt, mit unterschiedlichen Rollen und Auswirkungen. Die primäre Grundursache war ein Berechnungsfehler im Invarianten-Solver _calc_supply(), der zwei Fehlermodi hatte: (A) Abrunden konnte den Produktterm auf Null setzen, wodurch die Invariante zu einem Konstant-Summen-Modell degenerierte und zu übermäßigem LP-Minting (Angebotsschwemme) führte; und (B) ein Underflow-Zustand konnte das Angebot ebenfalls aufblähen. Nur Fehlermodus A wurde in Phase 2 verwendet (ca. 8,1 Mio. USD). Fehlermodus B war von der sekundären Schwachstelle abhängig.

Die sekundäre Grundursache war ein Defekt im Zustandsmanagement: Der Initialisierungszweig des Pools blieb erreichbar. Nachdem Phase 2 das Angebot auf Null getrieben hatte, kombinierte sich Fehlermodus B mit dem Bootstrap-Pfad, um zusätzliche Verluste von ca. 0,9 Mio. USD zu ermöglichen (Phase 3).

0x2.1 Unsichere Arithmetik in _calc_supply() (Primär)

Abbildung 2 ordnet die _calc_supply()-Implementierung dem mathematischen Verfahren aus Abschnitt 0x1.2 zu und merkt die zwei arithmetischen Fehlerstellen an, die unten analysiert werden:

Die Code-Variablen lassen sich mathematischen Begriffen wie folgt zuordnen:

Code-Variable Mathematische Rolle
s Aktuelle Angebotsschätzung DmD_m
r Produktterm πm\pi_m
sp Nächste Angebotsschätzung Dm+1D_{m+1}
l Zählerkonstante: Afnσ\mathit{Af}^{\,n} \cdot \sigma
d Nennerkonstante: Afn1\mathit{Af}^{\,n} - 1

Die kritischen Ausdrücke sind:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # Schritt 1: D[m+1]
r  = unsafe_div(unsafe_mul(r, sp), s)                 # Schritt 2: π-Aktualisierung (pro Asset)

Innerhalb dieser Funktion existieren zwei arithmetische Fehlermodi, die auf unterschiedliche Zeilen abzielen und unterschiedliche Effekte haben. Beide erfordern, dass sich der Pool in einem extremen Zustand befindet, um ausgelöst zu werden.

Unter normalen Bedingungen verhält sich die Iteration korrekt: l - s * r ist ein moderat positiver Wert, und die Iteration konvergiert in wenigen Runden.

1. Fehlermodus A: Abrunden setzt das Produkt auf Null

In Schritt 2 wird das Produkt pro Asset aktualisiert als:

r = unsafe_div(unsafe_mul(r, sp), s)   # r = r * sp / s

Da unsafe_div() eine Integer-Division durchführt, rundet sie immer ab. Wenn der Pool stark im Ungleichgewicht ist und sp sehr viel kleiner ist als s (wie es nach einer manipulierten großen Einzahlung der Fall ist), kann der Zähler r * sp kleiner werden als der Nenner s. Die Integer-Division ergibt dann r = 0.

Sobald r Null ist, bleibt es für alle nachfolgenden Iterationen Null. Der Produktterm π\pi ist dauerhaft kollabiert.

Eine häufige Fehlattribution behauptet, dieser Fehler entspringe einem Rundungs-Mismatch zwischen pow_up() und pow_down(). Abschnitt 0x4 präsentiert Beweise dafür, dass dies inkorrekt ist.

2. Fehlermodus B: Underflow bläht das Angebot auf

In Schritt 1 wird die neue Angebotsschätzung berechnet als:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # sp = (l - s*r) / d

Die Subtraktion l - s*r ist AfnσDmπm\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m in Gleichung 2. Unter normalen Bedingungen ist dies positiv. Wenn der Pool jedoch einen degenerierten Zustand mit Null-Angebot erreicht, berechnet der Initialisierungszweig in add_liquidity() (detailliert in Abschnitt 0x2.2) den Produktterm von Grund auf neu, und die relativen Größenordnungen können sich umkehren.

Insbesondere wenn add_liquidity() bei einem Pool mit Null-Angebot und Kleinstbeträgen aufgerufen wird, berechnet der Initialisierungszweig _calc_vb_prod_sum(), um frische Werte mit Gleichung (4) (Abschnitt 0x1.3) zu berechnen. Bei winzigen Einzahlungen ist vb_sum verschwindend gering (z. B. 16), aber die Division durch Bestände nahe Null und die Hochpotenzierung vergrößern das Produkt auf einen unverhältnismäßig großen Wert (z. B. ~9.13e20). Wenn s * r den Wert l übersteigt, liefert die Subtraktion ein negatives mathematisches Ergebnis.

Da unsafe_sub() Subtraktion in ungeprüfter uint256-Arithmetik durchführt, wird ein negatives Ergebnis zu einer enormen positiven Ganzzahl (nahe 22562^{256}) umgewandelt (Wrap-around). Dieser umgewandelte Wert pflanzt sich durch die Division und nachfolgende Iterationen fort, was eine absurd große Angebotsschätzung erzeugt, die das Protokoll dann als echte yETH-Tokens mintet.

Oft wird behauptet, dass ein solcher Underflow in der zweiten Iteration eines bestimmten Manipulationsschritts auftritt. Abschnitt 0x4 zeigt, dass diese Behauptung inkorrekt ist: Der tatsächliche Underflow, der das Angebot aufbläht, tritt in einem völlig anderen Kontext auf (Phase 3 des Angriffs).

3. Wie diese Fehler den Angriff ermöglichen

Diese beiden Fehlermodi arbeiten in verschiedenen Phasen des Exploits, mit unterschiedlichen Gewinnbeiträgen:

  • Fehlermodus A (Phase 2, ca. 8,1 Mio. USD): Wenn der Angreifer in einen stark unausgewogenen Pool einzahlt, kollabiert der Produktterm auf Null, was dazu führt, dass _calc_supply() ein aufgeblähtes Angebot zurückgibt. Das Protokoll über-mintet yETH an den Angreifer. Dieser Fehlermodus allein, ohne Beteiligung des Bootstrap-Pfads, ermöglichte es dem Angreifer, den yETH Weighted Stableswap Pool um seine LST-Vermögenswerte zu erleichtern.

  • Fehlermodus B (Phase 3, ca. 0,9 Mio. USD): Nachdem das Angebot auf Null reduziert wurde, berechnet der Bootstrap-Pfad aus Kleinst-Einzahlungen einen großen Produktterm neu, was dazu führt, dass die Subtraktion einen Underflow verursacht. Das Protokoll mintet eine astronomisch große Menge an yETH, die der Angreifer verwendet, um den separaten yETH/WETH Curve Pool zu leeren.

Die Abhängigkeit ist einseitig: Fehlermodus A ist unabhängig ausnutzbar und verursachte 90 % der Verluste, während Fehlermodus B erfordert, dass Fehlermodus A das Angebot zuerst auf Null treibt.

0x2.2 Nicht deaktivierter Bootstrap-Pfad (Sekundär)

Die Funktion add_liquidity() enthält einen Zweig für die erste Einzahlung in den Pool:

Die Logik lässt sich wie folgt abstrahieren:

if prev_supply == 0:
    # Bootstrap-Pfad — berechne vb_prod und vb_sum von Grund auf
    vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
    supply = vb_sum
else:
    # Normaler Pfad — verwende gespeichertes vb_prod, führe inkrementelle Prüfungen durch
    ...

# Wird nach beiden Zweigen aufgerufen, wobei prev_supply == 0 als Flag dient
supply, vb_prod = _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, prev_supply == 0)

Wenn prev_supply == 0, umgeht die Funktion den gespeicherten Zustand und berechnet vb_prod und vb_sum von Grund auf via _calc_vb_prod_sum(), unter Verwendung von Gleichung (4) (Abschnitt 0x1.3). Dieser Bootstrap-Zweig war nur für die einmalige Nutzung während der Initialisierung des Pools gedacht, wurde jedoch nach der ersten Einzahlung nie dauerhaft gesperrt.

Wenn das Gesamtangebot auf Null getrieben werden kann (durch eine beliebige Kombination von Burns und Entnahmen), wird der Zweig wieder erreichbar. Ein Angreifer, der wieder in diesen Pfad einsteigt, kontrolliert die Anfangsbedingungen, die an _calc_supply() übergeben werden, und löst potenziell die oben beschriebenen arithmetischen Fehler unter Parametern aus, die während eines normalen Poolbetriebs niemals auftreten würden.

Dies ist ein bekanntes Schwachstellenmuster. Beim Balancer V2-Vorfall im August 2023 hing es ähnlich davon ab, das Angebot auf Null zu treiben, um interne Kurse zurückzusetzen, was es dem Angreifer ermöglichte, wieder in die Initialisierungslogik mit künstlich günstigen Parametern einzusteigen [6]. Ob ein eingesetzter Pool in seinen Anfangszustand zurückversetzt werden kann und welche Invarianten dann gelten, ist eine Frage, die Protokolldesigner explizit angehen müssen.


0x3 Analyse des Angriffs

Der Exploit vollzieht sich in einer koordinierten Sequenz der Angriffstransaktion [5], unterteilt in drei Phasen. Jede Phase baut auf dem Status auf, der von der vorherigen etabliert wurde.

0x3.1 Phase 1: Verzerrung des Pools (Vorbereitung)

Ziel: Extremes Ungleichgewicht der virtuellen Bestände über die Assets hinweg erzeugen.

Die folgende Abbildung illustriert den Transaktionsverlauf für diese Phase (der Flash-Loan-Schritt wurde aus Platzgründen weggelassen):

Der Angreifer leiht sich zunächst große Mengen an LST-Assets via Flash Loans von Balancer und Aave, insbesondere 5.500e18 wstETH, 3.100e18 WETH, 1.800e18 rETH, 2.000e18 ETHx und 200e18 cbETH.

Als Nächstes tauscht der Angreifer ca. 800e18 WETH gegen ca. 416e18 yETH im yETH/WETH Curve Pool und verwendet dann die erworbenen yETH, um Liquidität aus dem Pool zu entfernen.

Die Kernmanipulation nutzt die Asymmetrie der Schnittstellen, die in Abschnitt 0x1 (Hintergrund) beschrieben wurde: add_liquidity() erlaubt Einzahlungen in beliebigen Anteilen, wohingegen remove_liquidity() Assets proportional gemäß den Poolgewichten abzieht (hervorgehoben im roten Rechteck in der obigen Abbildung). Durch wiederholte Zyklen von Hinzufügen-Entfernen-Operationen, bei denen nur ausgewählte Assets eingezahlt werden, während alle Assets proportional abgezogen werden, treibt der Angreifer den Pool schrittweise in einen stark unausgewogenen Zustand:

Asset Gewicht Vorher Nachher Änderung
0 (sfrxETH) 20% 628.097.482.908.289.585.170 684.908.495.923.316.419.717 +9,04%
1 (wstETH) 20% 376.569.216.105.249.117.091 684.906.088.027.654.432.883 +81,88%
2 (ETHx) 10% 187.473.530.249.048.974.586 410.441.661.092.336.995.160 +118,93%
3 (cbETH) 10% 267.387.722.745.796.900.349 3.532.430.695.689.175.233 -98,68%
4 (rETH) 10% 201.828.029.369.446.137.136 410.441.659.865.060.509.563 +103,36%
5 (apxETH) 25% 753.792.636.209.697.936.333 549.134.446.963.315.842.411 -27,15%
6 (WOETH) 2,5% 49.640.000.870.620.479.267 655.788.758.768.556.847 -98,68%
7 (mETH) 2,5% 47.667.894.211.903.277.629 629.735.467.970.876.930 -98,68%

Assets 3 (cbETH), 6 (WOETH) und 7 (mETH) wurden um über 98 % dezimiert. Dieses Ungleichgewicht zieht keinen direkten Gewinn ab. Es schafft die numerischen Voraussetzungen für die nächste Phase.

0x3.2 Phase 2: Kollaps des Angebots auf Null (ca. 8,1 Mio. USD)

Ziel: Das Invarianten-Produkt auf Null treiben und dann das yETH-Angebot auf Null leeren. Diese Phase nutzt nur die primäre Schwachstelle (unsichere Arithmetik) aus und verursachte ca. 90 % der Gesamtverluste.

Diese Phase verwendet einen wiederholten Fünf-Schritt-Zyklus, der dreimal ausgeführt wird:

  1. Korrumpieren des Produkts via add_liquidity();
  2. Schaffen der Voraussetzung für die Korrektur via add_liquidity();
  3. Zurücksetzen des Produkts via remove_liquidity() mit 0 yETH;
  4. Korrektur des Angebots via update_rates();
  5. Abheben von Assets via remove_liquidity().

Die folgende Abbildung zeigt den Transaktionsverlauf, bei dem drei Wiederholungen des Fünf-Schritt-Zyklus deutlich sichtbar sind:

1. Korrumpieren des Produkts via add_liquidity()

Der Angreifer hinterlegt große Mengen an hochgewichteten Assets (Indizes 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH), jedes etwa dreimal so hoch wie sein aktueller virtueller Bestand.

add_liquidity() schätzt den neuen Produktterm über die inkrementelle Aktualisierung in Gleichung (5) (Abschnitt 0x1.3). Da xixix_i' \gg x_i für hochgewichtete Assets gilt, sind die Verhältnisse (xi/xi)(x_i / x_i') allesamt Brüche weit unter 1, die mit hohen Potenzen versehen sind. Dies treibt πnew\pi_{\text{new}} von ~42e18 auf ~0,00353e18 nach unten, ein fast bei Null liegendes geschätztes Produkt.

Dieses winzige Produkt gelangt in _calc_supply(). In der Iteration stößt die Produkt-Aktualisierung r = r * sp / s auf das Abrundungs-Phänomen, das in Abschnitt 0x2 (Analyse der Grundursache) beschrieben wurde: Der Zähler fällt unter den Nenner, und die Integer-Division rundet r auf Null ab. Die Funktion gibt ein Null-Produkt und ein aufgeblähtes Angebot (~vb_sum) zurück, was dazu führt, dass das Protokoll yETH über-mintet.

2. Schaffen der Voraussetzung für die Korrektur via add_liquidity()

Der Angreifer hinterlegt Liquidität nur für Asset-Index 3 (cbETH, ein dezimiertes Asset mit geringem Gewicht), wobei ca. 6,5x des aktuellen Poolbestands eingezahlt werden. Dies ergibt nur wenige yETH-Tokens, bringt den Pool aber so weit wieder ins Gleichgewicht, dass die nächste Iteration nicht mehr wild oszilliert.

Ohne diesen Schritt würde selbst nach dem Zurücksetzen des Produkts auf einen Nicht-Null-Wert in Schritt 3 die Iteration in Schritt 4 aufgrund heftiger Oszillationen aus dem extremen Ungleichgewicht weiterhin ein Null-Produkt erzeugen. Unsere Foundry-Simulation bestätigt dies: Das Überspringen von Schritt 2 führt dazu, dass die Korrektur in Schritt 4 fehlschlägt.

3. Zurücksetzen des Produkts via remove_liquidity() mit 0 yETH

Der Angreifer ruft remove_liquidity() mit dem Betrag 0 auf. Es werden keine Tokens abgehoben, aber die Funktion berechnet vb_prod aus dem aktuellen Poolzustand mit Gleichung (4) (Abschnitt 0x1.3) neu. Da die virtuellen Bestände ungleich Null sind, erzeugt dies ein Nicht-Null-Produkt (~9,09e19) und überschreibt den korrumpierten Null-Wert.

4. Korrektur des Angebots via update_rates()

Der Angreifer ruft update_rates() für Asset-Index 6 (WOETH) oder 7 (mETH) auf. Wenn sich der Wechselkurs seit der letzten Aktualisierung geändert hat, löst die Funktion _calc_supply() mit dem wiederhergestellten (Nicht-Null) Produkt aus. Diesmal konvergiert die Iteration korrekt und erzeugt einen Angebotswert, der viel niedriger ist als der aktuelle, aufgeblähte Wert. Die Differenz wird aus dem yETH-Staking-Kontrakt verbrannt. Laut dem offiziellen Nachbericht [2] stellt dies protokolleigene Liquidität (POL) dar, was bedeutet, dass die Burns die Position des Protokolls verringern, nicht die Bestände des Angreifers. Diese Asymmetrie ist entscheidend: Jeder Zyklus reduziert das Gesamtangebot, während das yETH-Guthaben des Angreifers unberührt bleibt.

Die Kursdiskrepanz an sich ist keine Gewinnquelle; sie dient rein als Auslösemechanismus. Von den drei Pool-Schnittstellen rufen nur add_liquidity() und update_rates() _calc_supply() auf; remove_liquidity() verwendet proportionale Skalierung und tut dies nicht. Nachdem Schritt 3 ein Nicht-Null-Produkt wiederhergestellt hat, muss der Angreifer _calc_supply() auslösen, ohne zusätzliche Assets einzuzahlen. Das Aufrufen von update_rates() mit einem veralteten Kurs erreicht genau das: Die Kursänderung löst die Angebotsneuberechnung zu null Kosten für den Angreifer aus.

Dies erklärt einen subtilen Aspekt des Angriffs: Während der Vorbereitungsphase (Phase 1) vermied der Angreifer vorsätzlich das Hinzufügen von Liquidität für WOETH und mETH. Wären diese Kurse während add_liquidity() aktualisiert worden, gäbe es keine Kursdiskrepanz und update_rates() würde in diesem Schritt _calc_supply() nicht auslösen.

5. Abheben von Assets via remove_liquidity()

Am Ende jedes Zyklus hebt der Angreifer Assets via remove_liquidity() ab.

Wie Gewinn entnommen wird

Der Gewinnmechanismus funktioniert wie folgt: In Schritt 1 hinterlegt der Angreifer LSTs und erhält über-mintete yETH (aufgrund des korrumpierten Produkts). In Schritt 4, wenn das Angebot korrigiert wird, werden die überschüssigen yETH aus dem POL (Staking-Kontrakt) verbrannt, nicht vom Angreifer. In Schritt 5 hebt der Angreifer LSTs proportional zu seinen yETH-Beständen ab. Da das POL den Burn absorbiert hat, während der yETH-Bestand des Angreifers intakt blieb, hebt der Angreifer am Ende mehr LSTs ab, als er hinterlegt hat. Diese Differenz, die über drei Zyklen extrahiert wurde, summiert sich auf ca. 8,1 Mio. USD.

Zweck des Rebase

Der Transaktionsverlauf (zwischen dem ersten und zweiten Zyklus) zeigt auch einen Aufruf an OETHVaultProxy.rebase(), der ein OETH-Rebase auslöst: Der OETH-Bestand im WOETH-Kontrakt erhöht sich, was den effektiven Wechselkurs von WOETH anhebt. Diese "gespeicherte" Kursdiskrepanz ist das, was Schritt 4 des zweiten Zyklus wieder möglich macht: Wenn irgendwann update_rates() aufgerufen wird, erkennt es die Diskrepanz und löst _calc_supply() aus.

Leeren bis auf Null

Nach dreimaliger Wiederholung dieses Fünf-Schritt-Zyklus hat der Angreifer das Gesamtangebot des Pools unter die Menge an yETH reduziert, die er hält. Ein letzter remove_liquidity()-Aufruf mit dem verbleibenden Angebot leert den Pool auf NULL.

Der Pool besitzt nun Null Angebot, Null Produkt und Null vb_sum. Dieser degenerierte Zustand verletzt die implizite Designannahme, dass ein Pool mit vorherigen Einzahlungen niemals in seinen nicht initialisierten Zustand zurückkehren würde.

0x3.3 Phase 3: Ausnutzen des Null-Angebots für zusätzlichen Gewinn (ca. 0,9 Mio. USD)

Ziel: Eine enorme Menge an yETH aus dem degenerierten Poolstatus minten, dann gegen echte Assets tauschen. Diese Phase nutzt die voneinander abhängige Kombination aus der sekundären Schwachstelle (nicht deaktivierter Bootstrap-Pfad) und Fehlermodus B (Underflow) aus, was zusammen zu 10 % der Gesamtverluste beiträgt.

1. Minten via Underflow

Bei einem Gesamtangebot von Null ruft der Angreifer add_liquidity() mit Kleinstbeträgen auf (Bestand [1, 1, 1, 1, 1, 1, 1, 9]).

Da prev_supply == 0, geht der Code in den Bootstrap-Pfad über, der in Abschnitt 0x2 (Analyse der Grundursache) beschrieben wurde: Er umgeht den gespeicherten Zustand und berechnet vb_prod und vb_sum von Grund auf via _calc_vb_prod_sum(), dann übergibt er diese an _calc_supply(). Dies ist die zweite Schwachstelle in Aktion: Der Angreifer hat den Pool zurück in seinen nicht initialisierten Zustand getrieben und erlangt Kontrolle über die Anfangsbedingungen, die an den Solver übergeben werden.

Mit allen virtuellen Beständen auf Kleinstniveau (Wechselkurse nahe 1e18) sind die berechneten Werte:

  • vb_sum = 16
  • vb_prod ≈ 9,13e20
  • _supply = vb_sum = 16

Innerhalb von _calc_supply() werden die Variablen initialisiert auf:

  • l = _amplification * _vb_sum ≈ 4,5e20 × 16 ≈ 7,2e21
  • d = _amplification - PRECISION4,49e20
  • s = _supply = 16
  • r = _vb_prod9,13e20

Nun die Subtraktion l - s * r:

7,2×102116×9,13×1020=7,2×10211,46×10227,4×10217,2 \times 10^{21} - 16 \times 9,13 \times 10^{20} = 7,2 \times 10^{21} - 1,46 \times 10^{22} \approx -7,4 \times 10^{21}

Dies ist negativ. In ungeprüfter uint256-Arithmetik führt unsafe_sub dies auf ca. 22567,4×10212^{256} - 7,4 \times 10^{21} zurück, einen astronomisch großen Wert. Nach der Division durch d (~4,49e20) ist die resultierende Angebotsschätzung ~2,35e56, und das Protokoll mintet diesen gesamten Betrag an den Angreifer. Dieser Underflow ist nur möglich, weil das Gesamtangebot in Phase 2 auf Null getrieben wurde; unter jedem nicht-degenerierten Poolzustand gilt l > s * r und die Subtraktion ist sicher.

2. Tausch gegen echte Assets

Der Angreifer tauscht einen Teil der über-minteten yETH gegen ca. 1.097e18 WETH im yETH–WETH Curve Pool, der dessen WETH-Reserven leert. Nach Berücksichtigung der 800e18 WETH, die in Phase 1 ausgegeben wurden, betrug der Nettogewinn ca. 0,9 Mio. USD.

Zusammen mit den ca. 8,1 Mio. USD an LST-Assets, die während Phase 2 extrahiert wurden, erzielte der Angreifer nach Rückzahlung der Flash Loans einen Gesamtgewinn von ca. 9 Millionen USD.

Eine detaillierte Analyse der Geldflüsse, einschließlich Herkunft und Zieladressen, wurde in anderen veröffentlichten Analysen (z. B. [2]) abgedeckt und liegt außerhalb des Rahmens dieses Artikels.


0x4 Korrektur von Missverständnissen

Die meisten veröffentlichten Analysen dieses Vorfalls konzentrieren sich auf die arithmetischen Symptome, ohne die Voraussetzungen, die der Angreifer schafft, vollständig zu erklären. Zwei spezifische Behauptungen verdienen eine Korrektur.

0x4.1 Behauptung: "Rundungs-Mismatch zwischen pow_up() und pow_down() korrumpiert die Invariante"

Eine verbreitete Interpretation führt die Grundursache auf die Verwendung von pow_up() in einigen Codepfaden und pow_down() in anderen zurück und argumentiert, dass die richtungsabhängige Diskrepanz ausnutzbare Inkonsistenzen einführt.

Wir haben dies direkt getestet: Wir haben den Kontrakt so modifiziert, dass er einheitlich pow_down() verwendet (alle pow_up()-Aufrufe wurden ersetzt) und haben die vollständige Angriffssimulation in Foundry erneut ausgeführt. Der Exploit war identisch erfolgreich. Das Produkt kollabiert weiterhin auf Null, das Angebot leert sich weiterhin, und der Underflow produziert weiterhin das aufgeblähte Minting.

Das Runden, welches den Zustand des Null-Produkts ermöglicht, ist die Abrundung bei der Division (Floor Division) in r = unsafe_div(unsafe_mul(r, sp), s) innerhalb der Iterationsschleife, nicht die Richtung des Rundens in den Potenzfunktionen, die zur Schätzung der anfänglichen Produktwerte verwendet werden.

0x4.2 Behauptung: "Underflow in der zweiten Iteration setzt den Zwischenterm auf Null"

Eine weit verbreitete Erklärung besagt, dass während der zweiten Iteration von _calc_supply() ein Underflow in unsafe_sub den Wert sp ≈ 1,94e18 produziert, was dann dazu führt, dass r auf Null abgerundet wird.

Wir haben die exakten Zwischenwerte sowohl mit Foundry (On-Chain-Replay) als auch mit Python (mathematische Verifizierung) reproduziert. Die Foundry-Simulation verfolgt _calc_supply() Iteration für Iteration:

======= _calc_supply Iteration 0 =======
  l = 4905875511098192451202650000000000000000
  s = 2514373972590845290489        ← anfängliches Angebot
  r = 3538247433646816              ← anfängliches Produkt (sehr klein)
  d = 4490000000000000000000

  sp = (l - s*r) / d ≈ 1.093e22     ← neues Angebot springt ~4x
  neues r ≈ 4.49e22                 ← Produkt bläht sich dramatisch auf

======= _calc_supply Iteration 1 =======
  s = 10926206313726454855296        ← aus vorherigem sp
  r = 44892226765713223838396        ← aus vorheriger innerer Schleife

  sp = 19113493328251743069          ← ≈ 1.91e19, legitimer kleiner Wert
  neues r = 0                        ← rundet auf Null ab!

Die entscheidende Beobachtung: In Iteration 1 evaluiert sp zu ca. 1,91e19. Dies ist ein legitimer kleiner positiver Wert, kein Underflow-Artefakt. Die Subtraktion l - s*r produziert ein kleines positives Ergebnis, weil die verstärkungsgewichtete Summe l und der Angebots-Produkt-Term s*r in dieser Iteration in der Größenordnung nahe beieinander liegen.

Was das Produkt auf Null setzt, ist das, was danach passiert: Die innere Schleife berechnet r = r * sp / s, wobei sp (~1,91e19) weit kleiner ist als s (~1,09e22). Der Zähler r * sp fällt unter den Nenner s, und die Integer-Division rundet das Ergebnis auf Null ab.

Wir haben dies unabhängig in Python verifiziert, indem wir dieselben Werte mit Ganzzahlen beliebiger Präzision berechnet und bestätigt haben, dass die Subtraktion keinen Underflow aufweist:

Das Produkt kollabiert durch Rundung bei der Division, nicht durch Underflow bei der Subtraktion. Der unsafe_sub-Underflow, der das Angebot aufbläht, tritt in einem völlig anderen Kontext auf: Phase 3 des Angriffs, wenn Liquidität in einen Pool hinzugefügt wird, der auf ein Null-Angebot geleert wurde.


0x5 Fazit

Der yETH-Exploit beinhaltete zwei Schwachstellen mit asymmetrischen Auswirkungen. Unsichere Arithmetik in _calc_supply() war die primäre Grundursache: Ihr Abrundungs-Fehler (Fehlermodus A) ermöglichte unabhängig ca. 8,1 Mio. USD an Verlusten allein durch Phase 2. Der nicht deaktivierte Bootstrap-Pfad war eine sekundäre Schwachstelle; in Kombination mit dem Underflow-Fehler (Fehlermodus B) ermöglichte er zusätzliche ca. 0,9 Mio. USD in Phase 3, jedoch erst, nachdem Phase 2 das Angebot bereits auf Null geleert hatte. Diese Schadensaufschlüsselung unterscheidet die vorliegende Analyse von anderen veröffentlichten Berichten, die die Gewinne von Phase 2 und Phase 3 nicht trennen.

Der offizielle Nachbericht [2] identifiziert fünf Grundursachen. Wir klassifizieren diese als zwei Defekte (unsichere Arithmetik durch Zusammenfassung der offiziellen Punkte #1 und #5; nicht deaktivierter Bootstrap-Pfad als #4) und zwei architektonische Voraussetzungen (#2 asymmetrische Π-Handhabung; #3 POL-aktivierter Null-Angebotszustand). Der Unterschied: Defekte sind Implementierungsfehler, die die Designabsicht verletzen (der Solver sollte keine Null-Produkte oder Underflows produzieren), während Voraussetzungen Designentscheidungen sind, die wie beabsichtigt funktionieren, aber eine ausnutzbare Angriffsfläche schaffen, wenn sie mit Defekten kombiniert werden.

Empfehlungen

  • Geprüfte Arithmetik in Invarianten-Solvern. Verwenden Sie safe_div und safe_sub mit explizitem Revert bei Underflow/Overflow, selbst auf Kosten der Gas-Effizienz. Der Solver läuft maximal 256 Iterationen, und der Gas-Overhead ist im Vergleich zum Sicherheitsrisiko vernachlässigbar.
  • Bereichsprüfungen für Zwischenwerte. Validieren Sie, dass sich der Produktterm zwischen Iterationen in einem vernünftigen Bereich bewegt. Ein Produkt, das auf Null sinkt, oder eine Angebotsschätzung, die sich zwischen Iterationen um Größenordnungen ändert, signalisiert einen degenerierten Zustand.
  • Limits für Ungleichgewichte. Erzwingen Sie maximale Abweichungen zwischen dem virtuellen Bestand eines Assets und seinem zielgewichteten Anteil. Dies würde verhindern, dass Phase 1 die Voraussetzungen schaffen kann.
  • Prüfungen auf Monotonie der Invarianten. Überprüfen Sie nach der Rückgabe von _calc_supply(), ob das neue Angebot mit der Richtung der Änderung konsistent ist (Liquiditätshinzufügung sollte niemals das Angebot reduzieren, Kursaktualisierungen sollten keine 10-fachen Änderungen bewirken usw.).
  • Dauerhafte Deaktivierung von Initialisierungspfaden. Sperren Sie nach der ersten Einzahlung den Bootstrap-Zweig prev_supply == 0, damit er nicht erneut eingegeben werden kann. Dies würde Phase 3 vollständig verhindern.
  • Vermeidung von Null-Angebotszuständen. Stellen Sie sicher, dass protokollweite Burns (aus POL oder Staking-Kontrakten) das Gesamtangebot nicht auf Null reduzieren können, während der Pool Bestände ungleich Null hält. Ein Mindestangebot würde den Übergang zum degenerierten Zustand blockieren, der den Bootstrap-Wiedereinstieg ermöglicht.
  • Echtzeit-Anomalieerkennung. Überwachen Sie anomale Zustandsänderungen (wie Produktterme, die auf Null sinken, sich sprunghaft ändernde Angebote oder wiederholte Hinzufügen/Entfernen-Zyklen in kurzen Zeiträumen) und lösen Sie Alarme oder Schutzmechanismen aus, bevor sich die Verluste erhöhen.

Referenzen

  1. Vorfallankündigung Yearn Finance
  2. Yearn Security Post-Mortem
  3. yETH Dokumentation
  4. yETH Whitepaper: Invarianten-Herleitung
  5. Angriffstransaktion auf BlockSec Explorer
  6. BlockSec: Analyse des Balancer-Boosted-Pool-Vorfalls (August 2023)

Über BlockSec

BlockSec ist ein Full-Stack-Blockchain-Sicherheits- und Crypto-Compliance-Anbieter. Wir entwickeln Produkte und Dienstleistungen, die Kunden dabei helfen, Code-Audits durchzuführen (einschließlich Smart Contracts, Blockchain und Wallets), Angriffe in Echtzeit abzufangen, Vorfälle zu analysieren, unerlaubte Gelder zu verfolgen und AML/CFT-Verpflichtungen über den gesamten Lebenszyklus von Protokollen und Plattformen hinweg zu erfüllen.

BlockSec hat mehrere Blockchain-Sicherheitspapiere auf angesehenen Konferenzen veröffentlicht, über mehrere Zero-Day-Angriffe auf DeFi-Anwendungen berichtet, mehrere Hacks blockiert, um mehr als 20 Millionen Dollar zu retten, und Milliarden an Kryptowährungen abgesichert.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit