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:
- Nomad setzt auf jeder unterstützten Blockchain einen Kernvertrag namens Replica als Postfach für jegliche Cross-Chain-Nachrichten.
- 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.
- Neue Nachrichten, die On-Chain bestätigt werden müssen, müssen sowohl die
prove()als auch dieprocess()Prozedur durchlaufen.- Die
prove()Prozedur verifiziert die Nachricht und den Beweis im Merkle-Baum und markiert die Nachricht dann als bewiesen. - 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.
- Die
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);
}

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.

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.



