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:
- Nomad stellt auf jeder unterstützten Blockchain einen Kern-Contract namens Replica bereit, der als Postfach für sämtliche Cross-Chain-Nachrichten dient.
- 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.
- Neue Nachrichten, die On-Chain bestätigt werden müssen, müssen sowohl den
prove()- als auch denprocess()-Vorgang durchlaufen.- Der
prove()-Vorgang verifiziert die Nachricht und den Beweis im Merkle-Baum und markiert die Nachricht anschließend als bewiesen (proven). - 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
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.



