Am 30. November 2025 wurde der yETH Weighted Stable Pool von Yearn Finance ausgenutzt, wobei über 9 Millionen US-Dollar entwendet wurden [1]. Die Grundursachen waren unsichere Arithmetik im Invarianten-Solver _calc_supply() und ein nicht deaktivierter Bootstrap-Pfad, der einen erneuten Zugriff auf die Initialisierungslogik ermöglichte. Der offizielle Post-Mortem-Bericht [2] listet fünf Punkte als Grundursachen auf; wir klassifizieren diese neu als zwei Fehler (die oben genannten Schwachstellen) und zwei architektonische Voraussetzungen, die erst in Gegenwart dieser Fehler ausnutzbar wurden. Andere verfügbare Analysen konzentrieren sich auf schrittweise Details der Angriffstransaktionen. Zwischen High-Level-Zusammenfassungen und Details auf Transaktionsebene bleibt eine Lücke: warum und wie hat der Angriff tatsächlich funktioniert? Dieser Beitrag schließt diese Lücke, indem er mithilfe von Foundry- und Python-Simulationen nachvollzieht, wie sich Schlüsselwerte Schritt für Schritt entwickeln und wo die Berechnungen versagen.
Diese Analyse leistet vor allem die folgenden drei Beiträge:
- Verlustaufteilung nach Schwachstelle. Die beiden Schwachstellen sind nicht voneinander abhängig: Die unsichere Arithmetik allein verursachte Verluste von ~8,1 Mio. USD (90 % der Gesamtsumme), während der Bootstrap-Pfad zusätzliche ~0,9 Mio. USD ermöglichte. Dies verdeutlicht, welche Schwachstelle die primäre war.
- Neuklassifizierung der Grundursachen. Die fünf Grundursachen des offiziellen Berichts lassen sich besser als zwei Implementierungsfehler (die drei der fünf Punkte zusammenfassen) plus zwei architektonische Voraussetzungen verstehen, die erst in Kombination mit den Fehlern ausnutzbar wurden.
- Korrektur technischer Missverständnisse. Die Behauptung, dass „ein Underflow in der zweiten Iteration den Produktterm auf null setzt“, trifft nicht zu: Unsere Simulationen zeigen, dass das Produkt durch Rundung bei der Division auf null gesetzt wird, nicht durch einen Underflow, und der gewinnbringende Underflow tritt in einer völlig anderen Phase auf.
Der weitere Aufbau dieses Beitrags gliedert sich wie folgt. Abschnitt 0x1 liefert Hintergrundinformationen zu yETHs Weighted Stable Pool 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 verbreitete Missverständnisse anhand von Simulationsnachweisen. Abschnitt 0x5 schließt mit Empfehlungen ab.
Kurzfassung
Grundursachen: Zwei Schwachstellen wurden ausgenutzt, jedoch mit asymmetrischer Auswirkung:
- Unsichere Arithmetik in
_calc_supply()(primär, ~8,1 Mio. USD). Die Funktion, die die yETH-Gesamtmenge aus dem Pool-Zustand neu berechnet, enthält zwei arithmetische Fehler: Das Abrunden inunsafe_div()kann den internen Produktterm auf null setzen, und ein Underflow inunsafe_sub()kann einen Zwischenwert zu einer enorm großen positiven Ganzzahl überlaufen lassen. Diese Schwachstelle allein reichte aus, um den yETH Weighted Stableswap Pool zu leeren. - Nicht deaktivierter Bootstrap-Pfad (sekundär, ~0,9 Mio. USD). Der Initialisierungszweig
prev_supply == 0wurde nach dem Deployment nie dauerhaft gesperrt. Nachdem die erste Schwachstelle die Gesamtmenge auf null reduziert hatte, wurde dieser Pfad wieder zugänglich und ermöglichte zusätzlichen Gewinn aus dem yETH/WETH Curve Pool.
Innerhalb der Schwachstelle der unsicheren Arithmetik wurde in Phase 2 nur der Rundungsfehler (Fehlermodus A) genutzt; der Underflow-Fehler (Fehlermodus B) ist mit dem Bootstrap-Pfad wechselseitig abhängig, und zusammen ermöglichten sie Phase 3.
Der Angreifer führte eine dreiphasige Abfolge aus:
- Vorbereitung: Verzerrung der Vermögensverteilung des Pools durch wiederholte Add/Remove-Zyklen, wodurch ein extremes Ungleichgewicht in den virtuellen Salden entsteht.
- Manipulation der Gesamtmenge: Ausnutzung der Abrundung in
_calc_supply(), um den Produktterm auf null zu bringen, und anschließende Reduzierung der Gesamtmenge auf null durch eine Reihe von Mint-/Burn-Operationen. Alle LSTs des Pools wurden danach abgehoben und in WETH umgetauscht, was zu Verlusten von ~8,1 Mio. USD führte. - Gewinnentnahme: Auslösen des Bootstrap-Pfads (
prev_supply == 0) mit Dust-Einlagen, wobei der Underflow in_calc_supply()ausgenutzt wird, um ~2,35×10⁵⁶ yETH zu minten, die verwendet wurden, um den yETH/WETH Curve Pool zu leeren, was zu Verlusten von ~0,9 Mio. USD führte.
Zwei verbreitete Missverständnisse korrigiert:
- „Die Invariante bricht zusammen, weil
pow_up()undpow_down()unterschiedlich runden.“ Wir haben dies überprüft, indem wir in einer Foundry-Simulationpow_up()durchpow_down()ersetzt haben: Der Exploit funktioniert weiterhin. Eine Rundungsabweichung ist keine Grundursache. - „Ein Underflow in der zweiten Iteration lässt einen Zwischenterm auf null zusammenbrechen.“ Unsere Foundry- und Python-Simulationen zeigen, dass in der zweiten Iteration kein Underflow auftritt. Der tatsächliche Wert liegt bei ~1,91e19 (nicht bei ~1,94e18 wie behauptet), ein legitimes Ergebnis einer korrekten Subtraktion. Was das Produkt auf null setzt, ist die anschließende Abrundung bei der Division, nicht ein Underflow.
0x1 Hintergrund
Bei diesem Vorfall gingen aus zwei Pools Vermögenswerte verloren: dem yETH Weighted Stableswap Pool (ein Yearn-Pool mit LSTs, ~8,1 Mio. USD verloren) und dem yETH/WETH Curve Pool (ein Curve-Stableswap-Pool, ~0,9 Mio. USD verloren). Im yETH Weighted Stableswap Pool liegt die Kernschwachstelle. Dieser Abschnitt liefert die notwendigen Hintergrundinformationen, um die Schwachstelle und den Exploit zu verstehen.
0x1.1 Virtuelle Salden 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 bündelt mehrere LSTs in einem einzigen Pool: Nutzer zahlen LSTs ein und erhalten yETH als Pool-Anteil-Token.
Da jedes LST gestaktes ETH repräsentiert, das im Laufe der Zeit Erträge ansammelt, ändert sich sein Wechselkurs gegenüber Basis-ETH. Um die Buchführung zu vereinheitlichen, definiert der Pool für jeden Vermögenswert einen virtuellen Saldo : On-Chain-Saldo × Wechselkurs. Dies normalisiert alle Vermögenswerte in Beacon-Chain-ETH-Einheiten. Die Summe aller virtuellen Salden wird als bezeichnet.
Der Pool enthält 8 Vermögenswerte (indiziert 0–7), jeweils mit einem zugewiesenen Gewicht :
Der Zustand des Pools wird durch eine gewichtete StableSwap-artige Invariante bestimmt [4]:
wobei:
- die Invarianten-Skala ist, die direkt der Gesamtmenge des yETH dieses Pools entspricht. Wenn der Pool perfekt ausgeglichen ist, gilt .
- der gewichtete Produktterm ist, definiert als , wobei das Gewicht des Vermögenswerts i und ist.
- der Amplifikationsfaktor ist, ein einzelner Protokollparameter (nicht ). bezeichnet diesen Faktor hoch , wobei die Anzahl der Vermögenswerte ist (8 in diesem Pool). Er steuert die Kurvenform zwischen Constant-Sum (nahe des Gleichgewichts) und Constant-Product (an den Extremen).
Die entscheidende Eigenschaft: 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 durch eine Fixpunkt-Iteration neu, die auf 256 Runden begrenzt ist. Dieser Algorithmus ist im Code als _calc_supply() implementiert (Details in Abschnitt 0x2.1). Jede Runde führt drei Schritte aus:
Schritt 1: Aktualisierung der Mengen-Schätzung.
Schritt 2: Aktualisierung des Produktterms entsprechend der neuen Menge.
Schritt 3: Konvergenzprüfung.
Wenn , wird zurückgegeben; andernfalls wird ab Schritt 1 wiederholt.
Die Anfangswerte , und beeinflussen die frühen Iterationen; obwohl sie für die endgültige Konvergenz theoretisch irrelevant sind, wirken sie sich in der Praxis aufgrund begrenzter Iterationen und fester Präzisionsarithmetik auf die Ergebnisse aus.
Die Implementierung verwendet Ganzzahloperationen mit fester Präzision: Division rundet ab, und die Subtraktion ist nicht gegen Underflow abgesichert. Unter normalen Pool-Bedingungen bleiben Zwischenwerte innerhalb sicherer Bereiche. Unter extremen Pool-Zuständen ist das nicht der Fall. Abschnitt 0x2.1 analysiert diese Fehlermodi im Detail.
0x1.3 Die drei Schnittstellen und der Invarianten-Solver
Das Protokoll bietet drei Einstiegspunkte, die den Pool-Zustand beeinflussen, indem sie den gewichteten Produktterm (im Code als vb_prod gespeichert) aktualisieren:
| Schnittstelle | Was sie tut | Löst _calc_supply() aus? |
|---|---|---|
add_liquidity() |
Zahlt Vermögenswerte in beliebigen Anteilen ein | Ja |
update_rates() |
Aktualisiert externe Wechselkurse | Ja |
remove_liquidity() |
Hebt Vermögenswerte proportional nach Gewicht ab | Nein (nutzt proportionale Skalierung) |
Die Asymmetrie ist entscheidend: add_liquidity() erlaubt Einzahlungen in beliebigen Anteilen (was den Pool massiv verzerren kann), während remove_liquidity() immer proportional abhebt. Wiederholte Add/Remove-Zyklen können den Pool daher schrittweise in zunehmend unausgeglichene Zustände treiben.
Der Mechanismus zur Aktualisierung der Kurse
Wie oben besprochen, werden virtuelle Salden () basierend auf den Wechselkursen der LSTs berechnet. Daher ist es wichtig, die Methode zur Aktualisierung der Kurse zu verstehen.
Konkret können die Funktionen add_liquidity() und update_rates() Kurse über die interne Funktion _update_rates() aktualisieren, während die Funktion remove_liquidity() keine Kurssynchronisation durchführt.
add_liquidity()ruft_update_rates()vor der Ausführung kritischer Operationen auf, um sicherzustellen, dass die Wechselkurse der Vermögenswerte auf den neuesten Stand synchronisiert sind.update_rates()ermöglicht manuelle Kursaktualisierungen.
Die Funktion _update_rates() prüft, ob die im Vertrag hinterlegten Wechselkurse mit den externen Kursen übereinstimmen. Wird eine Abweichung festgestellt, wird eine Neuberechnung der virtuellen Salden ausgelöst und anschließend die Invariante aktualisiert; andernfalls wird der Aktualisierungsprozess übersprungen.
Wie jede Schnittstelle mit π umgeht
Basierend darauf, wie sie die Invariante beeinflussen, lassen sich diese drei Funktionen in zwei Kategorien einteilen. Konkret erlauben add_liquidity() und update_rates() nicht-proportionale Änderungen der virtuellen Salden und erfordern daher eine iterative Neuberechnung der Menge und des Produkts . Im Gegensatz dazu entzieht remove_liquidity() Liquidität proportional und erfordert keine iterative Berechnung.
Die Grundformel zur Berechnung des Produkts von Grund auf lautet:
wobei die Menge ist, das Gewicht des Vermögenswerts , sein virtueller Saldo (im Code als vb[i] gespeichert) und n die Anzahl der Vermögenswerte. Diese Form ist algebraisch äquivalent zur Definition in Abschnitt 0x1.1, wobei in das Produkt verteilt wird.
add_liquidity()hat zwei Pfade (Code in Abschnitt 0x2.2 dargestellt):
- Bootstrap-Pfad (wenn
prev_supply == 0): Berechnetvb_prodvon Grund auf mithilfe von Gleichung (4). Dass dieser Pfad nach dem Deployment zugänglich bleibt, ist die in Abschnitt 0x2.2 besprochene Zustandsverwaltungsschwachstelle. - Normalpfad (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 Salden:
wobei und die virtuellen Salden vor und nach der Einzahlung sind.
-
b) Kalibriert den genauen Wert iterativ, indem
_calc_supply()mit dieser Schätzung als Eingabe aufgerufen wird, wodurch die Invariante und der exakte Wert von neu berechnet werden.
-
-
update_rates()wird ausgelöst, wenn sich Wechselkurse ändern, was die Aktualisierung der virtuellen Salden der entsprechenden Vermögenswerte bewirkt. Der darauffolgende Berechnungsablauf folgt dem Normalpfad vonadd_liquidity(), d. h. die Invariante wird iterativ neu berechnet. Zusätzlich prägt oder verbrennt der Vertrag basierend auf der neu berechneten Menge yETH, um sicherzustellen, dass die Liquiditätsmenge mit dem aktualisierten virtuellen Saldenzustand übereinstimmt. -
remove_liquidity()berechnetvb_prodimmer von Grund auf mithilfe von Gleichung (4), nachdem jeder virtuelle Saldo proportional reduziert wurde.
0x2 Grundursachenanalyse
Zwei Schwachstellen wurden ausgenutzt, mit unterschiedlichen Rollen und Auswirkungen. Die primäre Grundursache war ein Berechnungsfehler im Invarianten-Solver _calc_supply(), der zwei Fehlermodi aufwies: (A) Abrunden konnte den Produktterm auf null setzen, wodurch die Invariante zu einem Constant-Sum-Modell degenerierte und zu übermäßigem LP-Minting führte (Mengeninflation); und (B) eine Underflow-Bedingung konnte ebenfalls die Menge aufblasen. Nur Fehlermodus A wurde in Phase 2 genutzt (~8,1 Mio. USD). Fehlermodus B war von der sekundären Schwachstelle abhängig.
Die sekundäre Grundursache war ein Zustandsverwaltungsfehler: Der Initialisierungszweig des Pools blieb zugänglich. Nachdem Phase 2 die Menge auf null gebracht hatte, kombinierte sich Fehlermodus B mit dem Bootstrap-Pfad, um zusätzliche Verluste von ~0,9 Mio. USD zu ermöglichen (Phase 3).
0x2.1 Unsichere Arithmetik in _calc_supply() (primär)
Abbildung 2 ordnet die Implementierung von _calc_supply() dem mathematischen Verfahren aus Abschnitt 0x1.2 zu und markiert die beiden unten analysierten arithmetischen Fehlerstellen:
Die Code-Variablen entsprechen den mathematischen Begriffen wie folgt:
| Code-Variable | Mathematische Rolle |
|---|---|
s |
Aktuelle Mengen-Schätzung |
r |
Produktterm |
sp |
Nächste Mengen-Schätzung |
l |
Zählerkonstante: |
d |
Nennerkonstante: |
Die kritischen Ausdrücke sind:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # Step 1: D[m+1]
r = unsafe_div(unsafe_mul(r, sp), s) # Step 2: π update (per asset)
Innerhalb dieser Funktion existieren zwei arithmetische Fehlermodi, die unterschiedliche Zeilen betreffen und unterschiedliche Effekte erzeugen. 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 moderater positiver Wert, und die Iteration konvergiert in wenigen Runden.
1. Fehlermodus A: Abrundung setzt das Produkt auf null
In Schritt 2 wird das Produkt pro Vermögenswert aktualisiert als:
r = unsafe_div(unsafe_mul(r, sp), s) # r = r * sp / s
Da unsafe_div() eine Ganzzahldivision durchführt, wird immer abgerundet. Wenn der Pool stark unausgeglichen ist und sp viel kleiner als s ist (wie es nach einer manipulierten großen Einzahlung geschieht), kann der Zähler r * sp kleiner werden als der Nenner s. Die Ganzzahldivision ergibt dann r = 0.
Sobald r null ist, bleibt es für alle nachfolgenden Iterationen null. Der Produktterm ist dauerhaft zusammengebrochen.
Eine häufige Fehlzuschreibung behauptet, dieser Fehler stamme von einer Rundungsabweichung zwischen pow_up() und pow_down(). Abschnitt 0x4 präsentiert Belege dafür, dass dies nicht zutrifft.
2. Fehlermodus B: Underflow bläst die Menge auf
In Schritt 1 wird die neue Mengen-Schätzung berechnet als:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # sp = (l - s*r) / d
Die Subtraktion l - s*r entspricht in Gleichung 2. Unter normalen Bedingungen ist dies positiv. Erreicht der Pool jedoch einen degenerierten Zustand mit Menge null, berechnet der Initialisierungszweig in add_liquidity() (Details in Abschnitt 0x2.2) den Produktterm von Grund auf neu, und die relativen Größenordnungen können sich umkehren.
Konkret: Wenn add_liquidity() auf einem Pool mit Menge null mit Dust-Beträgen aufgerufen wird, ruft der Initialisierungszweig _calc_vb_prod_sum() auf, um mit Gleichung (4) (Abschnitt 0x1.3) frische Werte zu berechnen. Bei winzigen Einzahlungen ist vb_sum minimal (z. B. 16), aber die Division durch nahezu null liegende Salden und das Potenzieren mit hohen Exponenten verstärkt das Produkt zu einem unverhältnismäßig großen Wert (z. B. ~9,13e20). Übersteigt s * r l, ergibt die Subtraktion ein negatives mathematisches Ergebnis.
Da unsafe_sub() die Subtraktion in ungeprüfter uint256-Arithmetik durchführt, läuft ein negatives Ergebnis über und wird zu einer enorm großen positiven Ganzzahl (nahe an ). Dieser übergelaufene Wert setzt sich durch die Division und nachfolgende Iterationen fort und erzeugt eine absurd große Mengen-Schätzung, die das Protokoll anschließend als reale yETH-Token prägt.
Eine häufige Behauptung besagt, dass ein solcher Underflow in der zweiten Iteration eines bestimmten Mengenmanipulationsschritts auftritt. Abschnitt 0x4 zeigt, dass diese Behauptung falsch ist: Der tatsächliche Underflow, der die Menge aufbläst, tritt in einem völlig anderen Kontext auf (Phase 3 des Angriffs).
3. Wie diese Fehler den Angriff ermöglichen
Diese beiden Fehlermodi wirken in unterschiedlichen Phasen des Exploits, mit unterschiedlichen Gewinnbeiträgen:
-
Fehlermodus A (Phase 2, ~8,1 Mio. USD): Wenn der Angreifer in einen stark unausgeglichenen Pool einzahlt, wird der Produktterm auf null gesetzt, was dazu führt, dass
_calc_supply()eine aufgeblasene Menge zurückgibt. Das Protokoll prägt übermäßig viel yETH für den Angreifer. Dieser Fehlermodus allein, ohne jegliche Beteiligung des Bootstrap-Pfads, ermöglichte es dem Angreifer, den yETH Weighted Stableswap Pool seiner LST-Vermögenswerte zu entleeren. -
Fehlermodus B (Phase 3, ~0,9 Mio. USD): Nachdem die Menge auf null reduziert wurde, berechnet der Bootstrap-Pfad einen großen Produktterm aus Dust-Einzahlungen neu, wodurch die Subtraktion überläuft. Das Protokoll prägt eine astronomisch große Menge yETH, die der Angreifer nutzt, um den separaten yETH/WETH Curve Pool zu entleeren.
Die Abhängigkeit ist einseitig: Fehlermodus A ist unabhängig ausnutzbar und verursachte 90 % der Verluste, während Fehlermodus B voraussetzt, dass Fehlermodus A zuvor die Menge auf null bringt.
0x2.2 Nicht deaktivierter Bootstrap-Pfad (sekundär)
Die Funktion add_liquidity() enthält einen Zweig für die erste Einzahlung des Pools:
Die Logik lässt sich wie folgt abstrahieren:
if prev_supply == 0:
# Bootstrap path — compute vb_prod and vb_sum from scratch
vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
supply = vb_sum
else:
# Normal path — use stored vb_prod, perform incremental checks
...
# Called after both branches, with prev_supply == 0 as a flag
supply, vb_prod = _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, prev_supply == 0)
Wenn prev_supply == 0 gilt, umgeht die Funktion den gespeicherten Zustand und berechnet vb_prod und vb_sum von Grund auf über _calc_vb_prod_sum() neu, unter Verwendung von Gleichung (4) (Abschnitt 0x1.3). Dieser Bootstrap-Zweig war für eine einmalige Verwendung während der Pool-Initialisierung vorgesehen, wurde aber nach der ersten Einzahlung nie dauerhaft gesperrt.
Kann die Gesamtmenge (durch eine beliebige Kombination von Burns und Abhebungen) auf null gebracht werden, wird der Zweig wieder zugänglich. Ein Angreifer, der diesen Pfad erneut aufsucht, kontrolliert die Anfangsbedingungen, die an _calc_supply() übergeben werden, und kann so möglicherweise die oben beschriebenen arithmetischen Fehler unter Parametern auslösen, die während des normalen Pool-Betriebs niemals auftreten würden.
Dies ist ein bekanntes Schwachstellenmuster. Im August 2023 hing der Balancer-V2-Vorfall in ähnlicher Weise davon ab, die Menge auf null zu bringen, um interne Kurse zurückzusetzen, was dem Angreifer ermöglichte, die Initialisierungslogik mit künstlich günstigen Parametern erneut aufzurufen [6]. Ob ein bereits deployter Pool wieder in seinen Ausgangszustand zurückversetzt werden kann und welche Invarianten dabei gelten, ist eine Frage, die Protokoll-Designer explizit adressieren müssen.
0x3 Angriffsanalyse
Der Exploit entfaltet sich über eine koordinierte Abfolge der Angriffstransaktion [5], gegliedert in drei Phasen. Jede Phase baut auf dem in der vorherigen Phase geschaffenen Zustand auf.
0x3.1 Phase 1: Verzerrung des Pools (Vorbereitung)
Ziel: Ein extremes Ungleichgewicht in den virtuellen Salden über alle Vermögenswerte hinweg erzeugen.
Die folgende Abbildung zeigt die Transaktionsspur für diese Phase (der Flash-Loan-Schritt wird aus Platzgründen ausgelassen):
Der Angreifer leiht sich zunächst große Mengen an LST-Vermögenswerten über Flash-Loans von Balancer und Aave, konkret 5.500e18 wstETH, 3.100e18 WETH, 1.800e18 rETH, 2.000e18 ETHx und 200e18 cbETH.
Anschließend tauscht der Angreifer ungefähr 800e18 WETH für etwa 416e18 yETH im yETH/WETH Curve Pool und nutzt das erhaltene yETH, um Liquidität aus dem Pool zu entziehen.
Die zentrale Manipulation nutzt die in Abschnitt 0x1 (Hintergrund) beschriebene Asymmetrie der Schnittstellen: add_liquidity() erlaubt Einzahlungen in beliebigen Anteilen, während remove_liquidity() Vermögenswerte proportional nach Pool-Gewichten abhebt (im obigen Bild im roten Rechteck hervorgehoben). Durch wiederholtes Durchlaufen von Add-→-Remove-Operationen, bei denen nur ausgewählte Vermögenswerte eingezahlt, aber alle Vermögenswerte proportional abgehoben werden, treibt der Angreifer den Pool schrittweise in einen stark unausgeglichenen Zustand:
| Vermögenswert | 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% |
Die Vermögenswerte 3 (cbETH), 6 (WOETH) und 7 (mETH) wurden um über 98 % ausgeschöpft. Dieses Ungleichgewicht führt nicht direkt zu Gewinn. Es schafft die numerischen Voraussetzungen für die nächste Phase.
0x3.2 Phase 2: Zusammenbruch der Menge auf null (~8,1 Mio. USD)
Ziel: Das Invarianten-Produkt auf null bringen und dann die yETH-Menge auf null entleeren. Diese Phase nutzt ausschließlich die primäre Schwachstelle (unsichere Arithmetik) und verursachte ~90 % der Gesamtverluste.
Diese Phase verwendet einen sich wiederholenden fünfstufigen Zyklus, der dreimal ausgeführt wird:
- Das Produkt über
add_liquidity()korrumpieren; - Eine Voraussetzung für die Korrektur über
add_liquidity()schaffen; - Das Produkt über
remove_liquidity()mit 0 yETH zurücksetzen; - Die Menge über
update_rates()korrigieren; - Vermögenswerte über
remove_liquidity()abheben.
Die folgende Abbildung zeigt die Transaktionsspur, in der drei Wiederholungen des fünfstufigen Zyklus deutlich erkennbar sind:
1. Das Produkt über add_liquidity() korrumpieren
Der Angreifer zahlt große Mengen an hochgewichteten Vermögenswerten ein (Indizes 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH), jeweils etwa das Dreifache ihres aktuellen virtuellen Saldos.
add_liquidity() schätzt den neuen Produktterm über die inkrementelle Aktualisierung in Gleichung (5) (Abschnitt 0x1.3). Da für hochgewichtete Vermögenswerte gilt, liegen die Verhältnisse alle deutlich unter 1 und werden mit hohen Exponenten potenziert. Dies treibt von ~42e18 auf ~0,00353e18, eine nahezu-null-Schätzung des Produkts.
Dieses winzige Produkt fließt in _calc_supply() ein. In der Iteration trifft die Produktaktualisierung r = r * sp / s auf die in Abschnitt 0x2 (Grundursachenanalyse) beschriebene Abrundungsbedingung: Der Zähler fällt unter den Nenner, und die Ganzzahldivision rundet r auf null ab. Die Funktion gibt ein Produkt von null und eine aufgeblasene Menge (~vb_sum) zurück, was dazu führt, dass das Protokoll übermäßig viel yETH prägt.
2. Eine Voraussetzung für die Korrektur über add_liquidity() schaffen
Der Angreifer fügt einseitige Liquidität für den Vermögenswert Index 3 (cbETH, ein ausgeschöpfter, niedrig gewichteter Vermögenswert) hinzu und zahlt ~6,5-mal den aktuellen Poolsaldo dieses Vermögenswerts ein. Dies erhält nur wenige yETH-Token, gleicht den Pool aber ausreichend aus, damit die nächste Iteration nicht wild oszilliert.
Ohne diesen Schritt würde die Iteration in Schritt 4, selbst nach dem Zurücksetzen des Produkts auf einen Wert ungleich null in Schritt 3, aufgrund heftiger Oszillationen infolge des extremen Ungleichgewichts weiterhin ein Produkt von null erzeugen. Unsere Foundry-Simulation bestätigt dies: Wird Schritt 2 ausgelassen, schlägt die Korrektur in Schritt 4 fehl.
3. Das Produkt über remove_liquidity() mit 0 yETH zurücksetzen
Der Angreifer ruft remove_liquidity() mit dem Betrag 0 auf. Es werden keine Token abgehoben, aber die Funktion berechnet vb_prod aus dem aktuellen Pool-Zustand neu, unter Verwendung von Gleichung (4) (Abschnitt 0x1.3). Da die virtuellen Salden ungleich null sind, entsteht ein Produkt ungleich null (~9,09e19), das den korrumpierten Nullwert überschreibt.
4. Die Menge über update_rates() korrigieren
Der Angreifer ruft update_rates() für den Vermögenswert Index 6 (WOETH) oder 7 (mETH) auf. Hat sich der Wechselkurs seit der letzten Aktualisierung geändert, löst die Funktion _calc_supply() mit dem wiederherstellten (nicht-null) Produkt aus. Diesmal konvergiert die Iteration korrekt und erzeugt eine Mengenwert, der deutlich unter dem aktuell aufgeblasenen Wert liegt. Die Differenz wird aus dem yETH-Staking-Vertrag verbrannt. Gemäß dem offiziellen Post-Mortem-Bericht [2] handelt es sich hierbei um Protocol-Owned Liquidity (POL), was bedeutet, dass die Burns die Position des Protokolls reduzieren, nicht die Bestände des Angreifers. Diese Asymmetrie ist entscheidend: Jeder Zyklus reduziert die Gesamtmenge, während der yETH-Saldo des Angreifers unverändert bleibt.
Die Kursabweichung selbst ist keine Gewinnquelle; sie dient ausschließlich als Auslösemechanismus. Von den drei Pool-Schnittstellen rufen nur add_liquidity() und update_rates() _calc_supply() auf; remove_liquidity() nutzt proportionale Skalierung und tut dies nicht. Nachdem Schritt 3 ein Produkt ungleich null wiederherstellt, muss der Angreifer _calc_supply() auslösen, ohne zusätzliche Vermögenswerte einzuzahlen. Der Aufruf von update_rates() mit einem veralteten Kurs erreicht genau dies: Die Kursänderung löst die Mengenneuberechnung ohne Kosten für den Angreifer aus.
Dies erklärt einen subtilen Aspekt des Angriffs: Während der Vorbereitungsphase (Phase 1) vermied der Angreifer bewusst, Liquidität für WOETH und mETH hinzuzufügen. Wären diese Kurse während add_liquidity() aktualisiert worden, hätte keine Kursabweichung bestanden, und update_rates() in diesem Schritt hätte _calc_supply() nicht ausgelöst.
5. Vermögenswerte über remove_liquidity() abheben
Am Ende jedes Zyklus hebt der Angreifer Vermögenswerte über remove_liquidity() ab.
Wie Gewinn entnommen wird
Der Gewinnmechanismus funktioniert wie folgt: In Schritt 1 zahlt der Angreifer LSTs ein und erhält übermäßig geprägtes yETH (aufgrund des korrumpierten Produkts). In Schritt 4, wenn die Menge korrigiert wird, wird das überschüssige yETH aus dem POL (Staking-Vertrag) verbrannt, nicht vom Angreifer. In Schritt 5 hebt der Angreifer LSTs proportional zu seinem yETH-Bestand ab. Da POL den Burn absorbiert hat, während der yETH-Saldo des Angreifers unverändert blieb, hebt der Angreifer am Ende mehr LSTs ab, als er eingezahlt hat. Diese Differenz, über drei Zyklen entnommen, summiert sich auf ~8,1 Mio. USD.
Zweck des Rebase
Die Spur (zwischen dem ersten und dem zweiten Zyklus) zeigt außerdem einen Aufruf von OETHVaultProxy.rebase(), der ein OETH-Rebase auslöst: Der von der WOETH-Vertrag gehaltene OETH-Saldo steigt an, wodurch der effektive Wechselkurs von WOETH erhöht wird. Diese „gesparte“ Kursabweichung ist es, die Schritt 4 des zweiten Zyklus wieder möglich macht: Wenn update_rates() schließlich aufgerufen wird, erkennt es die Abweichung und löst _calc_supply() aus.
Entleerung auf null
Nachdem dieser fünfstufige Zyklus dreimal wiederholt wurde, hat der Angreifer die Gesamtmenge des Pools unter den Betrag des von ihm gehaltenen yETH reduziert. Ein abschließender remove_liquidity()-Aufruf mit der verbleibenden Menge entleert sie auf NULL.
Der Pool hält nun eine Menge von null, ein Produkt von null und ein vb_sum von null. Dieser degenerierte Zustand verletzt die implizite Designannahme, dass ein Pool mit früheren Einzahlungen nie in seinen uninitialisierten Zustand zurückkehren würde.
0x3.3 Phase 3: Ausnutzung der Nullmenge für zusätzlichen Gewinn (~0,9 Mio. USD)
Ziel: Eine enorme Menge yETH aus dem degenerierten Pool-Zustand prägen und diese dann gegen echte Vermögenswerte tauschen. Diese Phase nutzt die wechselseitig abhängige Kombination der sekundären Schwachstelle (nicht deaktivierter Bootstrap-Pfad) und Fehlermodus B (Underflow), die zusammen ~10 % der Gesamtverluste ausmachen.
1. Minting über Underflow
Bei einer Gesamtmenge von null ruft der Angreifer add_liquidity() mit Dust-Beträgen auf (Saldo [1, 1, 1, 1, 1, 1, 1, 9]).
Da prev_supply == 0 gilt, tritt der Code in den in Abschnitt 0x2 (Grundursachenanalyse) beschriebenen Bootstrap-Pfad ein: Er umgeht den gespeicherten Zustand und berechnet vb_prod und vb_sum von Grund auf über _calc_vb_prod_sum() neu, bevor diese Werte an _calc_supply() übergeben werden. Dies ist die zweite Schwachstelle in Aktion: Der Angreifer hat den Pool zurück in seinen uninitialisierten Zustand versetzt und dadurch Kontrolle über die Anfangsbedingungen erlangt, die dem Solver zugeführt werden.
Da alle virtuellen Salden auf Dust-Niveau liegen (Wechselkurse nahe 1e18), ergeben sich folgende berechnete Werte:
vb_sum= 16vb_prod≈ 9,13e20_supply=vb_sum= 16
Innerhalb von _calc_supply() werden die Variablen wie folgt initialisiert:
l=_amplification * _vb_sum≈ 4,5e20 × 16 ≈ 7,2e21d=_amplification - PRECISION≈ 4,49e20s=_supply= 16r=_vb_prod≈ 9,13e20
Nun die Subtraktion l - s * r:
Dies ist negativ. In ungeprüfter uint256-Arithmetik läuft unsafe_sub dies zu ungefähr über, einem astronomisch großen Wert. Nach der Division durch d (~4,49e20) liegt die resultierende Mengen-Schätzung bei ~2,35e56, und das Protokoll prägt diesen gesamten Betrag für den Angreifer. Dieser Underflow ist nur möglich, weil die Gesamtmenge in Phase 2 auf null gebracht wurde; unter jedem nicht-degenerierten Pool-Zustand gilt l > s * r, und die Subtraktion ist sicher.
2. Tausch gegen echte Vermögenswerte
Der Angreifer tauscht einen Teil des übermäßig geprägten yETH gegen ~1.097e18 WETH im yETH–WETH Curve Pool und entleert dessen WETH-Reserven. Nach Abzug der in Phase 1 aufgewendeten 800e18 WETH betrug der Nettogewinn ~0,9 Mio. USD.
Zusammen mit den ~8,1 Mio. USD an LST-Vermögenswerten, die während Phase 2 entnommen wurden, erzielt der Angreifer nach Rückzahlung der Flash-Loans einen Gesamtgewinn von etwa 9 Millionen US-Dollar.
Eine detaillierte Analyse der Fondsströme, einschließlich der Herkunft der Mittel und der Zieladressen, wurde in anderen veröffentlichten Analysen (z. B. [2]) behandelt und liegt außerhalb des Umfangs dieses Artikels.
0x4 Korrektur von Missverständnissen
Die meisten veröffentlichten Analysen zu diesem Vorfall konzentrieren sich auf die arithmetischen Symptome, ohne vollständig zu erklären, wie der Angreifer die Voraussetzungen dafür schafft. Zwei bestimmte Behauptungen bedürfen einer Korrektur.
0x4.1 Behauptung: „Rundungsabweichung zwischen pow_up() und pow_down() korrumpiert die Invariante“
Eine verbreitete Interpretation schreibt die Grundursache der Verwendung von pow_up() in manchen Codepfaden und pow_down() in anderen zu und argumentiert, dass die richtungsabhängige Abweichung ausnutzbare Inkonsistenzen einführt.
Wir haben dies direkt getestet: Wir haben den Vertrag so modifiziert, dass er einheitlich pow_down() verwendet (indem alle pow_up()-Aufrufe ersetzt wurden), und die vollständige Angriffssimulation in Foundry erneut ausgeführt. Der Exploit war identisch erfolgreich. Das Produkt bricht weiterhin auf null zusammen, die Menge entleert sich weiterhin, und der Underflow erzeugt weiterhin ein aufgeblasenes Minting.
Die Rundung, die den Nullprodukt-Zustand ermöglicht, ist die Floor-Division in r = unsafe_div(unsafe_mul(r, sp), s) innerhalb der Iterationsschleife, nicht die Rundungsrichtung der Potenzfunktionen, die zur Schätzung der Anfangsproduktwerte verwendet werden.
0x4.2 Behauptung: „Underflow in der zweiten Iteration setzt den Zwischenterm auf null“
Eine weitverbreitete Erklärung besagt, dass während der zweiten Iteration von _calc_supply() ein Underflow in unsafe_sub sp ≈ 1,94e18 erzeugt, was anschließend 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 Verifikation) reproduziert. Die Foundry-Simulation verfolgt _calc_supply() Iteration für Iteration:
======= _calc_supply iteration 0 =======
l = 4905875511098192451202650000000000000000
s = 2514373972590845290489 ← initial supply
r = 3538247433646816 ← initial product (very small)
d = 4490000000000000000000
sp = (l - s*r) / d ≈ 1.093e22 ← new supply jumps ~4x
new r ≈ 4.49e22 ← product inflates dramatically
======= _calc_supply iteration 1 =======
s = 10926206313726454855296 ← from previous sp
r = 44892226765713223838396 ← from previous inner loop
sp = 19113493328251743069 ← ≈ 1.91e19, legitimately small
new r = 0 ← rounds to zero!
Die entscheidende Beobachtung: In Iteration 1 ergibt sp ~1,91e19. Dies ist ein legitimer, kleiner positiver Wert, kein Underflow-Artefakt. Die Subtraktion l - s*r ergibt ein kleines positives Ergebnis, weil die amplifikationsgewichtete Summe l und der Mengenproduktterm s*r in dieser Iteration in ihrer Größenordnung nah beieinander liegen.
Was das Produkt auf null setzt, ist das, was anschließend 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 Ganzzahldivision 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 verursacht:
Das Produkt wird durch Rundung bei der Division auf null gesetzt, nicht durch Underflow bei der Subtraktion. Der unsafe_sub-Underflow, der die Menge aufbläst, tritt in einem völlig anderen Kontext auf: Phase 3 des Angriffs, wenn Dust-Liquidität zu einem Pool hinzugefügt wird, dessen Menge auf null entleert wurde.
0x5 Fazit
Der yETH-Exploit involvierte zwei Schwachstellen mit asymmetrischer Auswirkung. Unsichere Arithmetik in _calc_supply() war die primäre Grundursache: Ihr Abrundungsfehler (Fehlermodus A) ermöglichte unabhängig ~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 in Phase 3 zusätzliche ~0,9 Mio. USD, jedoch nur, nachdem Phase 2 die Menge bereits auf null entleert hatte. Diese Verlustaufteilung unterscheidet die vorliegende Analyse von anderen veröffentlichten Berichten, die die Gewinne aus Phase 2 und Phase 3 nicht trennen.
Der offizielle Post-Mortem-Bericht [2] identifiziert fünf Grundursachen. Wir klassifizieren diese neu als zwei Fehler (unsichere Arithmetik, die die offiziellen Punkte #1 und #5 zusammenfasst; nicht deaktivierter Bootstrap-Pfad als #4) und zwei architektonische Voraussetzungen (#2 asymmetrische Π-Behandlung; #3 POL-ermöglichter Nullmengen-Zustand). Die Unterscheidung: Fehler sind Implementierungsfehler, die der Design-Absicht widersprechen (der Solver sollte keine Nullprodukte erzeugen oder überlaufen), während Voraussetzungen Designentscheidungen sind, die wie beabsichtigt funktionieren, aber in Kombination mit Fehlern eine ausnutzbare Angriffsfläche schaffen.
Empfehlungen
- Geprüfte Arithmetik in Invarianten-Solvern. Verwenden Sie
safe_divundsafe_submit explizitem Revert bei Underflow/Overflow, selbst um den Preis der Gas-Effizienz. Der Solver läuft höchstens 256 Iterationen, und der Gas-Mehraufwand ist im Vergleich zum Sicherheitsrisiko vernachlässigbar. - Bereichsprüfungen für Zwischenwerte. Überprüfen Sie, dass der Produktterm zwischen den Iterationen innerhalb eines vernünftigen Bereichs bleibt. Ein auf null fallendes Produkt oder eine Mengen-Schätzung, die sich zwischen Iterationen um Größenordnungen erhöht, signalisiert einen degenerierten Zustand.
- Ungleichgewichtsgrenzen. Setzen Sie eine maximale Abweichung zwischen dem virtuellen Saldo eines Vermögenswerts und seinem gewichtungsproportionalen Zielsaldo durch. Dies würde verhindern, dass Phase 1 die Voraussetzungen schafft.
- Monotonie-Prüfungen der Invariante. Überprüfen Sie nach der Rückgabe von
_calc_supply(), dass die neue Menge mit der Richtung der Änderung übereinstimmt (eine Liquiditätszufuhr sollte die Menge niemals verringern, Kursaktualisierungen sollten keine 10-fachen Änderungen bewirken usw.). - Initialisierungspfade dauerhaft deaktivieren. Sperren Sie nach der ersten Einzahlung des Pools den Bootstrap-Zweig
prev_supply == 0, damit er nicht erneut aufgerufen werden kann. Dies würde Phase 3 vollständig verhindern. - Nullmengen-Zustände verhindern. Stellen Sie sicher, dass protokollseitige Burns (aus POL- oder Staking-Verträgen) die Gesamtmenge nicht auf null reduzieren können, während der Pool noch Salden ungleich null hält. Eine Mindestmengengrenze würde den Übergang in den degenerierten Zustand blockieren, der die erneute Bootstrap-Aufsuchung ermöglicht.
- Echtzeit-Anomalieerkennung. Überwachen Sie auf abnormale Zustandsübergänge (wie auf null fallende Produktterme, Mengenänderungen um Größenordnungen oder wiederholte Add/Remove-Zyklen in kurzen Zeitfenstern) und lösen Sie Warnungen oder Sicherungsmechanismen aus, bevor sich Verluste summieren.
Referenzen
- Ankündigung des Yearn Finance-Vorfalls
- Yearn Security Post-Mortem-Bericht
- yETH-Dokumentation
- yETH-Whitepaper: Ableitung der Invariante
- Angriffstransaktion auf Phalcon Explorer
- BlockSec: Analyse des Balancer-Boosted-Pool-Vorfalls (August 2023)
Über BlockSec
BlockSec ist ein Full-Stack-Anbieter für Blockchain-Sicherheit und Krypto-Compliance. Wir entwickeln Produkte und Dienstleistungen, die Kunden dabei unterstützen, Code-Audits durchzuführen (einschließlich Smart Contracts, Blockchains und Wallets), Angriffe in Echtzeit abzufangen, Vorfälle zu analysieren, illegale Mittel zurückzuverfolgen und AML/CFT-Pflichten zu erfüllen – über den gesamten Lebenszyklus von Protokollen und Plattformen hinweg.
BlockSec hat mehrere Fachartikel zur Blockchain-Sicherheit auf renommierten Konferenzen veröffentlicht, mehrere Zero-Day-Angriffe auf DeFi-Anwendungen gemeldet, mehrere Hacks blockiert, um mehr als 20 Millionen Dollar zu retten, und Kryptowährungen im Wert von Milliarden gesichert.
-
Offizielle Website: https://blocksec.com/
-
Offizieller Twitter-Account: https://twitter.com/BlockSecTeam



