Zurück zum Blog

Angriffsanalyse | Wie ungeprüftes Mapping zu 200 Millionen Dollar Verlust bei der Nomad Bridge führte

Code Auditing
2. August 2022
7 Min. Lesezeit

Zusammenfassung

Am 2. August 2022 wurde eine Cross-Chain-Bridge namens Nomad Bridge angegriffen, was zu einem Verlust von fast 200 Millionen US-Dollar führte. Die Grundursache ist eine fehlerhafte Überprüfung in der aktualisierten Version des Smart Contracts auf der Blockchain.

Der Hintergrund

Nomad Bridge ist eine aufstrebende Cross-Chain-Asset-Bridge, die auf einem Design basiert, das sich auf Betrugssicherheit (Fraud-Proofs) stützt. Sie funktioniert wie folgt:

  1. Nomad stellt auf jeder unterstützten Blockchain einen Kern-Contract namens Replica bereit, der als Postfach für sämtliche Cross-Chain-Nachrichten dient.
  2. Off-Chain-Agenten leiten Cross-Chain-Nachrichten weiter, ordnen sie in einem Merkle-Baum an und aktualisieren die Baumwurzel, indem sie den signierten Hash der neuen Baumwurzel an diesen Contract senden.
  3. Neue Nachrichten, die On-Chain bestätigt werden müssen, müssen sowohl den prove()- als auch den process()-Vorgang durchlaufen.
    1. Der prove()-Vorgang verifiziert die Nachricht und den Beweis im Merkle-Baum und markiert die Nachricht anschließend als bewiesen (proven).
    2. Der process()-Vorgang prüft und führt die Nachricht aus, sofern diese zuvor bewiesen wurde und die zugehörige Baumwurzel bestätigt ist.

Der Code

Auf Ethereum ist die Replica ein Beacon-Proxy, der unter 0x5d94309e5a0090b165fa4181519701637b6daeba bereitgestellt wurde. Es gibt zwei Versionen des Logik-Contracts: die erste Version ist unter 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b zu finden, die zweite Version unter 0xb92336759618f55bd0f8313bd843604592e27bd8.

Wir untersuchen zuerst die vorherige Version des Logik-Contracts, insbesondere die Funktion process():

function process(bytes memory _message) public returns (bool _success) {
    bytes29 _m = _message.ref(0);
    // sicherstellen, dass die Nachricht für diese Domain bestimmt war
    require(_m.destination() == localDomain, "!destination");
    // sicherstellen, dass die Nachricht bewiesen wurde
    bytes32 _messageHash = _m.keccak();
    require(messages[_messageHash] == MessageStatus.Proven, "!proven");
    // Re-entrancy-Schutz prüfen
    require(entered == 1, "!reentrant");
    entered = 0;
    // Nachrichtenstatus als verarbeitet aktualisieren
    messages[_messageHash] = MessageStatus.Processed;

Wir zeigen hier nur einen Teil dieser Funktion. In diesem Codeabschnitt wird der Hash der Nachricht berechnet und gegen das messages-Mapping geprüft, um sicherzustellen, dass diese Nachricht zuvor bewiesen wurde. Danach folgt die Prüfung auf Reentrancy und schließlich die Aktualisierung des Nachrichtenstatus.

Wir sehen uns auch kurz die alte prove()-Funktion an:

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // sicherstellen, dass die Nachricht weder bewiesen noch verarbeitet wurde
    require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
    // die erwartete Wurzel basierend auf dem Beweis berechnen
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // wenn die Wurzel gültig ist, den Status auf Proven ändern
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = MessageStatus.Proven;
        return true;
    }
    return false;
}

Hier gibt es nichts Besonderes: Dublettenprüfung, Berechnung der Baumwurzel, und falls akzeptabel, als bewiesen markieren. In der alten Version des Replica-Contracts gibt es also eine spezielle Markierung (MessageStatus.Proven = 1) für alle Nachrichten, die bewiesen wurden.

Sehen wir uns nun die zweite Version des Logik-Contracts an. Für die neue Version prüfen wir zuerst die prove()-Funktion:

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // sicherstellen, dass die Nachricht nicht verarbeitet wurde
    // Hinweis: Dies erlaubt ein erneutes Beweisen unter einer neuen Wurzel.
    require(
        messages[_leaf] != LEGACY_STATUS_PROCESSED,
        "already processed"
    );
    // die erwartete Wurzel basierend auf dem Beweis berechnen
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // wenn die Wurzel gültig ist, den Status auf Proven ändern
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = _calculatedRoot;
        return true;
    }
    return false;
}

Uns fällt sofort eine wesentliche Änderung auf: Aus irgendeinem Grund entschieden sich die Entwickler, die berechnete Wurzel als Beweisstatus aufzuzeichnen, statt einer speziellen Markierung. Für diese Funktion ist das in Ordnung, da sichergestellt ist, dass der Hash der Merkle-Baumwurzel nicht null ist. Es ist auch logisch, da neue Nachrichten, die mit dieser Baumwurzel bewiesen wurden, sofort zur Ausführung bereit sind, sobald die Baumwurzel bestätigt wurde.

Dann prüfen wir die process()-Funktion in der neuen Version:

function process(bytes memory _message) public returns (bool _success) {
    // sicherstellen, dass die Nachricht für diese Domain bestimmt war
    bytes29 _m = _message.ref(0);
    require(_m.destination() == localDomain, "!destination");
    // sicherstellen, dass die Nachricht bewiesen wurde
    bytes32 _messageHash = _m.keccak();
    require(acceptableRoot(messages[_messageHash]), "!proven");
    // Re-entrancy-Schutz prüfen
    require(entered == 1, "!reentrant");
    entered = 0;
    // Nachrichtenstatus als verarbeitet aktualisieren
    messages[_messageHash] = LEGACY_STATUS_PROCESSED;

Uns fällt die Zeile messages[_messageHash] auf. Es ist eine häufige Falle, dass der Zugriff auf einen nicht existierenden Mapping-Eintrag null zurückgibt. In diesem Kontext bedeutet das, dass die dem Nachrichten-Hash zugeordnete Merkle-Baumwurzel null ist. Wir müssen das Resultat dieser Null überprüfen. Wir sollten daher die neue acceptableRoot()-Funktion sorgfältig prüfen.

function acceptableRoot(bytes32 _root) public view returns (bool) {
    // dies dient der Abwärtskompatibilität für Nachrichten, die in 
    // früheren Versionen bewiesen/verarbeitet wurden.
    if (_root == LEGACY_STATUS_PROVEN) return true;
    if (_root == LEGACY_STATUS_PROCESSED) return false;

    uint256 _time = confirmAt[_root];
    if (_time == 0) {
        return false;
    }
    return block.timestamp >= _time;
}

Grundsätzlich prüft diese Funktion das confirmAt-Mapping, um zu sehen, ob die Merkle-Baumwurzel bestätigt wurde.

Unglücklicherweise ist in BEIDEN Versionen des Replica-Contracts der Null-Hash im Initialisierer auf 1 gesetzt:

function initialize(
    uint32 _remoteDomain,
    address _updater,
    bytes32 _committedRoot, // dies ist zur Initialisierung null
    uint256 _optimisticSeconds
) public initializer {
    __NomadBase_initialize(_updater);
    // Speichervariablen setzen
    entered = 1;
    remoteDomain = _remoteDomain;
    committedRoot = _committedRoot;
    // die committedRoot vorab genehmigen.
    confirmAt[_committedRoot] = 1;
    _setOptimisticTimeout(_optimisticSeconds);
}

In der alten Version des Replica-Contracts ist das völlig in Ordnung: In prove() kann kein Baumwurzel-Hash null sein, daher ist es sicher, den Eintrag für den Null-Hash im confirmAt-Mapping auf 1 zu setzen.

In der neuen Version hingegen gibt messages[_messageHash] für eine neue Nachricht null zurück. Danach greift acceptableRoot auf den Eintrag für den Null-Hash im confirmAt-Mapping zu und gibt dann wahr (true) zurück.

Der Angriff

Aufgrund der obigen Code-Analyse wissen wir, dass jede bisher unbekannte Nachricht einfach die Validierungslogik durchlaufen und ausgeführt werden kann. Man muss also nur eine Nachricht fälschen und process() aufrufen.

Interessanterweise fand der erste Aufruf der process()-Funktion in diesem Contract erst vor zwei Tagen statt (bei Block 15249565) in 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0.

In der folgenden Abbildung sehen wir, dass der Speicher-Slot für die messages-Statusvariable für diese Nachricht ursprünglich null war, was bedeutet, dass der Replica-Contract diese Nachricht vorher nicht kannte.

Dann wurde dieser Slot auf zwei gesetzt (d. h. der Status LEGACY_STATUS_PROCESSED, was bedeutet, dass diese Nachricht verarbeitet wurde). Dies zeigt, dass eine ungültige Nachricht die prove()-Logik umgangen hat und direkt verarbeitet wurde.

Fazit

Dies ist ein weiterer klassischer Angriff, der den ungeprüften Rückgabewert eines Mappings ausnutzt. Solidity-Entwickler sollten beim Umgang mit Mappings besonders vorsichtig sein, um unerwartete Ergebnisse zu vermeiden.

Für die neuesten Informationen anmelden
Exploit bei NEAR Intents: Fehler bei der Rückerstattung und Lehren für die Sicherheit
Security Insights

Exploit bei NEAR Intents: Fehler bei der Rückerstattung und Lehren für die Sicherheit

Der Exploit bei NEAR Intents nutzte überhöhte Rückerstattungs-Events, um interne Guthaben aufrechtzuerhalten, nachdem die eingezahlten Vermögenswerte zurückerstattet worden waren. Diese Analyse rekonstruiert den Ablauf der Receipt-Verarbeitung und sieben Auszahlungen auf der BNB Chain in Höhe von insgesamt 3.865.021 USDT, grenzt diese Mittelabflüsse von der vom Projekt gemeldeten vollständigen Rückerlangung der Mittel ab und erläutert Sicherheitsaspekte bei der Entwicklung und Prüfung von NEAR-Smart-Contracts.

Verluste von rund 5,57 Millionen USD: Exploits bei X100 Freezer und NEM | BlockSec-Wochenrückblick
Security Insights

Verluste von rund 5,57 Millionen USD: Exploits bei X100 Freezer und NEM | BlockSec-Wochenrückblick

Dieser Wochenbericht behandelt zwei Sicherheitsvorfälle auf Ethereum und BNB Chain im Zeitraum 19.10.2026–25.10.2026. Er untersucht eine durch einen Flash Loan finanzierte Übernahme der Governance von X100 Freezer sowie einen Fehler bei der Steuerabrechnung, der einen Liquiditätspool eines NEM-Tokens auf BNB Chain betrifft. Die geschätzten Verluste im Rahmen des Berichts belaufen sich insgesamt auf etwa 5,57 Millionen USD: rund 4,75 Millionen USD aus der exemplarischen X100-Transaktion und rund 820 Tausend USD aus dem Vorfall beim NEM-Pool. Gesondert gemeldete spätere Entnahmen aus X100 sind in der Gesamtsumme nicht enthalten.

Verluste von rund 3,87 Millionen USD: Hacks bei Autheo Network und UNCX | BlockSec Weekly
Security Insights

Verluste von rund 3,87 Millionen USD: Hacks bei Autheo Network und UNCX | BlockSec Weekly

Dieser Wochenbericht behandelt 3 Sicherheitsvorfälle im Zeitraum 12.10.2026–18.10.2026 mit einem geschätzten Gesamtschaden von etwa 3,87 Millionen USD. Bei Autheo Network wurde ein privater Schlüssel kompromittiert; der Schaden betrug rund 1,76 Millionen USD. Qubic Wallet verlor aufgrund einer schwachen Seed-Generierung rund 1,30 Millionen USD. Bei den Smart Token Vesting V2-Verträgen von UNCX Network führte ein Buchführungsfehler auf Ethereum, BNB Chain, Base und Robinhood Chain zu einem Schaden von rund 807 Tausend USD. Die hervorgehobene Analyse zu UNCX erklärt, wie eine fehlerhafte Berechnung der Gesamtanteile Vesting-Tranchen zusammenführte, die mit unterschiedlichen Token-zu-Anteil-Verhältnissen erstellt worden waren, und dadurch überhöhte Entnahmen aus gemeinsamen Pools ermöglichte.

Der beste Sicherheitsauditor für Web3

Lassen Sie Design, Code und Geschäftslogik vor dem Launch prüfen. Nach den höchsten Sicherheitsstandards der Branche.

BlockSec-Audit
Angriffsanalyse | Wie ungeprüftes Mapping zu 200 Millionen Dollar Verlust bei der Nomad Bridge führte