Angriffsanalyse | Wie unkontrollierte Abbildung zu 200.000.000 US-Dollar Verlusten bei Nomad Bridge führt

Wie die Sicherheit der Nomad Bridge kompromittiert wurde: Analyse des Codes, der zur Schwachstelle der Nomad Bridge und zum anschließenden Exploit von fast 200 Millionen US-Dollar führte

Angriffsanalyse | Wie unkontrollierte Abbildung zu 200.000.000 US-Dollar Verlusten bei Nomad Bridge führt

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 Hauptursache ist die fehlerhafte Überprüfung in der aktualisierten Version des On-Chain-Smart-Contracts.

Der Hintergrund

Nomad Bridge ist eine aufstrebende Cross-Chain-Asset-Bridge, die auf einem Fraud-Proof-basierten Design basiert. Sie funktioniert wie folgt:

  1. Nomad implementiert einen Kernvertrag namens Replica auf jeder unterstützten Blockchain als Mailbox für jegliche Cross-Chain-Nachrichten.
  2. Off-Chain-Agenten leiten und ordnen Cross-Chain-Nachrichten in einem Merkle-Baum an und aktualisieren die Baumwurzel, indem sie einen signierten neuen Baumwurzel-Hash in diesem Vertrag veröffentlichen.
  3. Neue Nachrichten, die On-Chain bestätigt werden müssen, müssen sowohl das prove() als auch das process() Verfahren durchlaufen.
    1. Das prove() Verfahren überprüft die Nachricht und den Beweis im Merkle-Baum und markiert die Nachricht dann als bewiesen.
    2. Das process() Verfahren überprüft und führt die Nachricht aus, wenn die Nachricht zuvor bewiesen wurde und die zugehörige Baumwurzel bestätigt wurde.

Der Code

In Ethereum ist die Replica ein Beacon-Proxy, der unter 0x5d94309e5a0090b165fa4181519701637b6daeba bereitgestellt wurde. Es gibt zwei Versionen des logischen Vertrags, die erste Version, die unter 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b bereitgestellt wurde, und die zweite Version, die unter 0xb92336759618f55bd0f8313bd843604592e27bd8 bereitgestellt wurde.

Wir überprüfen zuerst die vorherige Version des logischen Vertrags, 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");
    // Reentrancy-Schutz prüfen
    require(entered == 1, "!reentrant");
    entered = 0;
    // Nachrichtstatus als verarbeitet aktualisieren
    messages[_messageHash] = MessageStatus.Processed;

Wir zeigen nur einen Teil dieser Funktion. In diesem Code-Segment wird der Nachrichten-Hash berechnet und der Hash gegen das messages-Mapping geprüft, um sicherzustellen, dass diese Nachricht zuvor bewiesen wurde, dann der Reentrancy-Check und dann die Aktualisierung des Nachrichtenstatus.

Wir überprüfen auch kurz die alte prove() Funktion:

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // sicherstellen, dass die Nachricht noch nicht bewiesen oder 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, Status auf Proven ändern
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = MessageStatus.Proven;
        return true;
    }
    return false;
}

Nichts Besonderes hier: Duplikatprüfung, Berechnung der Baumwurzel, wenn akzeptabel, dann als bewiesen markieren. In der alten Version des Replica-Vertrags gibt es also eine spezielle Markierung (MessageStatus.Proven = 1) für alle bewiesenen Nachrichten.

Überprüfen wir nun die zweite Version des logischen Vertrags. Für die neue Version prüfen wir zunächst die Funktion prove():

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // sicherstellen, dass die Nachricht noch nicht verarbeitet wurde
    // Hinweis: Dies erlaubt das erneute 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, Status auf Proven ändern
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = _calculatedRoot;
        return true;
    }
    return false;
}

Wir bemerken sofort eine wesentliche Änderung hier: Aus irgendeinem Grund haben die Entwickler beschlossen, die berechnete Wurzel anstelle einer speziellen Markierung als bewiesenen Status zu speichern. Für diese Funktion ist das in Ordnung, da der Merkle-Baum-Wurzel-Hash garantiert nicht null ist. Es ist auch vernünftig, denn sobald die Baumwurzel bestätigt ist, sind alle neuen Nachrichten, die mit dieser Baumwurzel bewiesen werden, zur Ausführung bereit.

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");
    // Reentrancy-Schutz prüfen
    require(entered == 1, "!reentrant");
    entered = 0;
    // Nachrichtstatus als verarbeitet aktualisieren
    messages[_messageHash] = LEGACY_STATUS_PROCESSED;

Wir bemerken die Zeile messages[_messageHash]. Es ist ein häufiger Fehler, dass das Abrufen eines nicht vorhandenen Mapping-Eintrags null zurückgibt. In diesem Zusammenhang bedeutet dies, dass die mit diesem Nachrichten-Hash verbundene Merkle-Baum-Wurzel null ist. Wir müssen das Ergebnis dieser Null weiter überprüfen. Daher sollten wir die neue Funktion acceptableRoot() sorgfältig prüfen.

function acceptableRoot(bytes32 _root) public view returns (bool) {
    // dies ist Abwärtskompatibilität für Nachrichten, die
    // unter 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;
}

Im Grunde prüft diese Funktion das confirmAt-Mapping, um festzustellen, ob die Merkle-Baum-Wurzel bestätigt wurde.

Leider ist in BEIDEN Versionen des Replica-Vertrags der Null-Hash im Initializer auf 1 gesetzt:

function initialize(
    uint32 _remoteDomain,
    address _updater,
    bytes32 _committedRoot, // dieser ist bei der Initialisierung null
    uint256 _optimisticSeconds
) public initializer {
    __NomadBase_initialize(_updater);
    // Speicher-Variablen setzen
    entered = 1;
    remoteDomain = _remoteDomain;
    committedRoot = _committedRoot;
    // die commitete Wurzel vorab genehmigen.
    confirmAt[_committedRoot] = 1;
    _setOptimisticTimeout(_optimisticSeconds);
}

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

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

Der Angriff

Aus der obigen Codeanalyse wissen wir, dass jede zuvor ungesehene Nachricht die Validierungslogik durchlaufen und ausgeführt werden kann. Also einfach eine Nachricht fälschen und process() aufrufen.

Interessanterweise wurde die process() Funktion in diesem Vertrag erstmals vor zwei Tagen (bei Block 15249565) in 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0 aufgerufen.

In der folgenden Abbildung sehen wir, dass der Speicherplatz für die Zustandsvariable messages für diese Nachricht ursprünglich null war, was bedeutet, dass der Replica-Vertrag diese Nachricht zuvor nicht kannte.

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

Schlussfolgerung

Dies ist ein weiterer klassischer Angriff, der den ungeprüften Rückgabewert von einem Mapping ausnutzt. Solidity-Entwickler sollten besondere Aufmerksamkeit auf Mappings legen, um unerwartete Ergebnisse zu vermeiden.

Sign up for the latest updates