Back to Blog

Angriffsanalyse | Wie unkontrollierte Mappings zu 200.000.000 $ Verlusten bei Nomad Bridge führen

Code Auditing
August 2, 2022
7 min read

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 eine fehlerhafte Prüfung in der aktualisierten Version des On-Chain-Smart-Contracts.

Der Hintergrund

Nomad Bridge ist eine aufstrebende Cross-Chain-Asset-Bridge, die ein auf Betrugsnachweisen basierendes Design verwendet. Sie funktioniert wie folgt:

  1. Nomad setzt auf jeder unterstützten Blockchain einen Kernvertrag namens Replica als Postfach für jegliche Cross-Chain-Nachrichten.
  2. Off-Chain-Agenten leiten und organisieren Cross-Chain-Nachrichten in einem Merkle-Baum und aktualisieren die Baumwurzel, indem sie einen signierten neuen Baumwurzel-Hash an diesen Vertrag posten.
  3. Neue Nachrichten, die On-Chain bestätigt werden müssen, müssen sowohl die prove() als auch die process() Prozedur durchlaufen.
    1. Die prove() Prozedur verifiziert die Nachricht und den Beweis im Merkle-Baum und markiert die Nachricht dann als bewiesen.
    2. Die process() Prozedur prüft und führt die Nachricht aus, wenn die Nachricht zuvor bewiesen wurde und die zugehörige Baumwurzel bestätigt ist.

Der Code

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

Wir überprüfen zunächst 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-Guard prüfen
    require(entered == 1, "!reentrant");
    entered = 0;
    // Nachrichtenstatus als verarbeitet aktualisieren
    messages[_messageHash] = MessageStatus.Processed;

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

Wir werfen auch einen kurzen Blick auf die alte prove() Funktion:

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // sicherstellen, dass die Nachricht nicht bewiesen oder verarbeitet wurde
    require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
    // 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;
}

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

Schauen wir uns dann die zweite Version des logischen Vertrags an. 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 nicht verarbeitet wurde
    // Hinweis: Dies erlaubt erneutes Beweisen unter einer neuen Wurzel.
    require(
        messages[_leaf] != LEGACY_STATUS_PROCESSED,
        "already processed"
    );
    // 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;
}

Wir bemerken sofort eine große Änderung hier: Aus irgendeinem Grund haben sich die Entwickler entschieden, die berechnete Wurzel als bewiesenen Status anstatt als spezielles Zeichen zu speichern. Für diese Funktion ist das in Ordnung, da der Merkle-Baum-Wurzel-Hash nicht Null ist. Es ist auch vernünftig, da sobald die Baumwurzel bestätigt ist, alle mit dieser Baumwurzel bewiesenen neuen Nachrichten zur Ausführung bereit sind.

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

Wir bemerken die Zeile messages[_messageHash]. Es ist eine häufige Fallstrick, dass der Abruf eines nicht existierenden Mapping-Eintrags Null zurückgibt. In diesem Zusammenhang bedeutet dies, dass die mit dieser Nachricht verknüpfte Merkle-Baum-Wurzel Null ist. Wir müssen das Ergebnis dieser Null weiter prüfen. Daher sollten wir die neue Funktion acceptableRoot() sorgfältig prüfen.

function acceptableRoot(bytes32 _root) public view returns (bool) {
    // dies dient der 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 zu sehen, ob die Merkle-Baum-Wurzel bestätigt wurde.

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

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

In der alten Version des Replica-Vertrags ist dies 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] jedoch für eine neue Nachricht 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 unbekannte Nachricht die Validierungslogik durchlaufen und ausgeführt werden kann. Also einfach eine Nachricht fälschen und process() aufrufen.

Interessanterweise erfolgte der erste Aufruf der Funktion process() in diesem Vertrag vor nur zwei Tagen (bei Block 15249565) unter 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0.

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

Messages-Storage-Slot
Messages-Storage-Slot

Dann wurde dieser Slot auf zwei gesetzt (d. h. der Status LEGACY_STATUS_PROCESSED, 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 eines Mappings ausnutzt. Solidity-Entwickler sollten beim Umgang mit Mappings besondere Aufmerksamkeit widmen, um unerwartete Ergebnisse zu vermeiden.

Sign up for the latest updates
~$4.72M Lost: TAC, Transit Finance & More | BlockSec Weekly
Security Insights

~$4.72M Lost: TAC, Transit Finance & More | BlockSec Weekly

This BlockSec weekly security report covers 3 notable attack incidents identified between May 11 and May 17, 2026, across TRON, TON, and Ethereum, with total estimated losses of approximately $4.72M. Three incidents are analyzed in detail: the highlighted $1.88M Transit Finance exploit on TRON, where a deprecated swap bridge contract with lingering token approvals was exploited through arbitrary calldata forwarding; the $2.8M TAC TON-to-EVM bridge exploit caused by missing canonical wallet verification in the jetton deposit flow; and the $46.75K Boost Hook exploit on Ethereum, where spot price manipulation on a Uniswap V4 hook-based perpetual protocol forced the protocol to buy tokens at inflated prices using its own reserves.

~$15.9M Lost: Trusted Volumes, Wasabi & More | BlockSec Weekly
Security Insights

~$15.9M Lost: Trusted Volumes, Wasabi & More | BlockSec Weekly

This BlockSec bi-weekly security report covers 11 notable attack incidents identified between April 27 and May 10, 2026, across Sui, Ethereum, BNB Chain, Base, Blast, and Berachain, with total estimated losses of approximately $15.9M. Three incidents are analyzed in detail: the highlighted $1.14M Aftermath Finance exploit on Sui, where a signed/unsigned semantic mismatch in the builder-fee validation allowed an attacker to inject a negative fee that was converted into positive collateral during settlement; the $5.87M Trusted Volumes RFQ authorization mismatch on Ethereum; and the $5.7M Wasabi Protocol infrastructure-to-contract-control compromise across multiple EVM chains.

Newsletter - April 2026
Security Insights

Newsletter - April 2026

In April 2026, the DeFi ecosystem experienced three major security incidents. KelpDAO lost ~$290M due to an insecure 1-of-1 DVN bridge configuration exploited via RPC infrastructure compromise, Drift Protocol suffered ~$285M from a multisig governance takeover leveraging Solana's durable nonce mechanism, and Rhea Finance incurred ~$18.4M following a business logic flaw in its margin-trading module that allowed circular swap path manipulatio

Best Security Auditor for Web3

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

BlockSec Audit