Autor: Jiǔjiǔ
Bearbeitung: 77
Hintergrund
Am 4. September 2026 wurde der bekannte dezentrale Kreditverleih- und Borrowing-Platform Notional Finance angegriffen, wobei ein Verlust von ungefähr 1,73 Millionen US-Dollar entstand. Im Folgenden eine konkrete Analyse des SlowMist Security Teams zu diesem Angriffsereignis:
Vorkenntnisse
In Notional Finance V1 kann fCash als eine Cash-Forderung mit Fälligkeitsdatum verstanden werden. Ein cash receiver erhält die Zahlung bei Fälligkeit, ein cash payer zahlt bei Fälligkeit. Das Protokoll schreibt beide Arten von Positionen ins Portfolio und entscheidet dann über einen Test der freien Sicherheiten, wie gesund ein Konto ist und ob es eine Position eröffnen darf.
Die safeTransferFrom-Funktion von ERC1155Trade übernimmt hier die Asset-Buchhaltung. Wenn der übergebene Asset-Typ ein cash receiver ist, ruft der Vertrag Portfolios.mintfCashPair auf. Dabei wird dem payer eine neue Schuld hinzugefügt und dem receiver eine gleich hohe Forderung. Dieser Aufruf sieht wie ein ERC1155-Transfer aus, führt aber tatsächlich zu einer Prägung von fCash-Pairs.
Nach dem Schreiben der neuen Position in das Portfolio müssen Berechnung und Prüfung der freien Sicherheiten (free collateral) durchgeführt werden. Der RiskFramework-Vertrag fasst zunächst die fCash-Schulden von payer zu einem signierten int256 zusammen und rechnet dann die Salden der einzelnen Währungen in ETH um, um sie zu aggregieren. Negative Werte stehen für auszuzahlende Cash-Beträge, positive Werte für Cash, das das Konto besitzt, oder für Forderungen. Dieses Berechnungsergebnis entscheidet darüber, ob die Transaktion erlaubt werden kann.
Nach Ablauf der Position ruft das Portfolio die Funktion portfolioSettleCash von Escrow auf, um das fCash zum Fälligkeitstermin in einen Kassenbestand umzuwandeln. Anschließend holt das Konto die entsprechenden DAI- oder USDC-Vermögenswerte über die Auszahlungsfunktion aus Escrow heraus.
Ursache
Die zentrale Schwachstelle dieses Angriffs liegt in der Implementierung von Escrow im ExchangeRate._convertToETH: In einem Teil des Codes wird der Absolutwert eines signierten Integer-Werts direkt in uint128 umgewandelt.

Der Angreifer erstellt zuerst zwei Schulden-Positionen mit demselben payer. Die Beträge betragen jeweils 1 und uint128.max. Nachdem die beiden Schulden addiert wurden, ergibt sich genau 2^128. Da in der Risikoberechnung die Zahlung von payer als negativer Wert erfasst wird, ist der entscheidende Parameter, der an die Escrow-Funktion convertBalancesToETH übergeben wird, daher -2^128.
Da die im Vertrag verwendete Solidity-Version jedoch 0.6.x ist, wird der Absolutwert von 2^128 nach der erzwungenen Umwandlung in uint128 überlaufen und zu 0. Dadurch wird diese Schuld auch nicht in das ETH-bewertete Ergebnis einbezogen.
In der mintfCashPair-Funktion wird abschließend nur verlangt, dass die freien Sicherheiten des payer größer oder gleich 0 sind. Nach dem Overflow zu 0 kann diese Prüfung passieren. Selbst ein Konto, das nicht über ausreichend Assets verfügt, um die Schulden zu decken, kann dadurch weiterhin eine Position aufbauen.
Analyse der Angriffs-Schritte
1. In dem Vorab-Trade (0xe1589a19…d60a) erstellt der Angreifer zunächst mehrere Hilfsverträge und ruft dann die Funktion setApprovalForAl des ERC1155Trade-Vertrags auf, um den Hilfsverträgen eine Genehmigung zu erteilen. Außerdem fragt er im Voraus die Fälligkeit zweier CashMarket ab und berechnet die Werte der drei AssetId-Parameter, die für die folgenden Schritte übergeben werden müssen.

2. Direkt im Anschluss prägt der Angreifer über einen Aufruf der safeTransferFrom-Funktion des ERC1155Trade-Vertrags ein fCash-Pair in Höhe von 1. Dazu gehört der Bond-Asset mit cashGroupId 2 und ein Fälligkeitszeitstempel von 1788480000 (4. September 2026, 8:00 Uhr). Dabei wird die interne Funktion _upsertAsset aufgerufen, um für die Adresse from (Angreifervertrag) und die Adresse to (Empfangsvertrag 1) jeweils ihre Schulden und erwarteten Einnahmen zu aktualisieren.

Nach Abschluss der Pair-Prägung überprüft Portfolios unmittelbar die freien Sicherheiten des Angreifervertrags. Da die Menge der ersten Prägung zu klein war, wird sie bei der aktuellen Wechselkurs- und Präzisionssituation in ETH umgerechnet und auf 0 gerundet. Daher kann die erste Prüfung bestanden werden.
3. Der Angreifer ruft anschließend erneut die Funktion safeTransferFrom des ERC1155Trade-Vertrags auf, um ein fCash-Pair in Höhe von uint128.max zu prägen. Dieses Mal hat der Bond-Asset die cashGroupId 2, aber der Zeitstempel des Fälligkeitsdatums beträgt 1796256000 (3. Dezember 2026, 8:00 Uhr).
Hier gibt es einen Detailpunkt: Der Angreifer hat zwei unterschiedliche Adressen jeweils mit Anleihe-Assets unterschiedlichen Fälligkeitsdatums geprägt. Das liegt daran, dass bei der Aktualisierung der Schulden für die Adresse from, wenn die Bond-Assets identisch sind, sie direkt aufaddiert werden. Dies führt bei der Behandlung von Addition aufgrund des Overflows zu einem revert (der Vertrag nutzt die safeMath-Bibliothek).

4. Nach dem zweiten Prägen und der anschließenden Aktualisierung der Schulden und erwarteten Einnahmen für from (Angreifervertrag) und to (Empfangsvertrag 2) ruft die Funktion mintfCashPair die Funktion freeCollateral auf, um die Netto-Sicherheitenposition der Adresse from zu berechnen und zu prüfen, dass diese größer oder gleich 0 sein muss, damit sie als gesund gilt.

Dabei wird zunächst in der Funktion getRequirement im RiskFrameork-Vertrag die Summe der beiden gepaarten Schulden aus den beiden Prägevorgängen für die Adresse from addiert:

Das endgültige Ergebnis ist -2^128. Anschließend ruft es die Funktion convertBalancesToETH des Escrow-Vertrags auf, um diesen Wert in eine ETH-Bewertung umzuwandeln. Die convertBalancesToETH-Funktion ruft wiederum die _convertToETH-Funktion aus der ExchangeRate-Bibliothek zur Berechnung auf:


Bei der weiteren Verfolgung der _convertToETH-Funktion erkennt man, dass sie zuerst den balance-Wert in den Absolutwert umwandelt und ihn dann über uint128 von einer 256-Bit- in eine 128-Bit-Zahl konvertiert. Der oben genannte Schulden-Gesamtsaldo von from beträgt -2^128. Nach dem Absolutwert entsteht 2^128; bei der anschließenden erzwungenen Typumwandlung zu uint128 wird jedoch der zulässige Bereich überschritten, was zu einem Überlauf und schließlich zu 0 führt. Das bedeutet, dass das Protokoll fälschlicherweise annimmt, dass die freien Sicherheiten (free collateral) der Adresse from in Höhe von 0 gesund sind, wodurch die finale Prüfung innerhalb der mintfCashPair-Funktion bestanden wird.

5. Danach teilt Empfänger-Vertrag 2 seine Position erneut in zwei weitere Hilfsverträge auf. Diese beiden safeTransferFrom-Aufrufe gelangen ebenfalls in die mintfCashPair-Funktion. Zu diesem Zeitpunkt ist der payer jedoch Empfänger-Vertrag 2 aus dem zweiten Prägevorgang. Dessen gehaltene riesige erwartete Einnahmen-Position wird in der Risikoberechnung als positive Forderung betrachtet, wodurch die Sicherheitenprüfung bestanden wird. Damit erhalten die beiden Hilfsverträge ihre receiver fCash, die nach Ablauf in Bargeld umgewandelt werden können.

6. Anschließend initiiert der Angreifer den zweiten formalen Profit-Trade. In dieser Transaktion werden die zugehörigen Forderungen der beiden gehaltenen Positionen aus dem vorherigen Trade von den jeweiligen Hilfsverträgen abgerechnet. Dadurch wird der Cash-Bestand der entsprechenden Assets erhöht. Danach ruft er die withdraw-Funktion des Escrow-Vertrags auf, um daraus den Gewinn auszuzahlen.

Zusammenfassung
Der entscheidende Punkt dieses Angriffs besteht darin, dass der Angreifer zuerst mithilfe von zwei separaten Positionen die Summe der payer-Schulden auf 2^128 bringt und anschließend Escrow diese Schuld in ETH umrechnet. Durch den Umwandlungs-Overflows wird das Ergebnis auf 0 abgeschnitten, wodurch die Risikoprüfung freigegeben wird.
Das Team der SlowMist Security empfiehlt, vor jeder Typkonvertierung zwingend Bereichsprüfungen durchzuführen. Ergebnisse, die über type(uint128).max hinausgehen, sollten direkt abgelehnt werden. Gleichzeitig sollten alle Grenzen, die signierte Beträge, Präzisionsskalierung und Asset-Nennwerte betreffen, mit Extremwert-Tests abgedeckt werden – insbesondere Eingaben wie uint128.max, uint128.max + 1 und Negativwerte im Absolutwert. Man kann sich an der SafeCast-Bibliothek von OpenZeppelin orientieren oder sie zur Konvertierung verwenden.

