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
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

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly
Security Insights

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly

This BlockSec weekly security report covers eight attack incidents detected between April 20 and April 26, 2026, across Ethereum, Avalanche, Sui, Base, HyperLiquid, and MegaETH, with total estimated losses of approximately $7.04M. The highlighted incident is the $1.3M GiddyDefi exploit, where the attacker did not break any cryptography or use a flash loan but simply replayed an existing on-chain EIP-712 signature with the unsigned `aggregator` and `fromToken` fields swapped out for a malicious contract, demonstrating how partial signature coverage turns any historical signature into a generic permit. Other incidents include a $3.5M Volo Vault operator key compromise on Sui, a $1.5M Purrlend privileged-role takeover, a $413K SingularityFinance oracle misconfiguration, a $142.7K Scallop cross-pool index injection, a $72.35K Kipseli Router decimal mismatch, a $50.7K REVLoans (Juicebox) accounting pollution, and a $64K Custom Rebalancer arbitrary-call exploit.

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026

This BlockSec weekly security report covers four attack incidents detected between April 13 and April 19, 2026, across multiple chains such as Ethereum, Unichain, Arbitrum, and NEAR, with total estimated losses of approximately $310M. The highlighted incident is the $290M KelpDAO rsETH bridge exploit, where an attacker poisoned the RPC infrastructure of the sole LayerZero DVN to fabricate a cross-chain message, triggering a cascading WETH freeze across five chains and an Arbitrum Security Council forced state transition that raises questions about the actual trust boundaries of decentralized systems. Other incidents include a $242K MMR proof forgery on Hyperbridge, a $1.5M signed integer abuse on Dango, and an $18.4M circular swap path exploit on Rhea Finance's Burrowland protocol.

Best Security Auditor for Web3

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

BlockSec Audit