Über die Hash-Flooding-Sicherheitslücke in Node.js…
Anfang Juli dieses Jahres hat Node.js ein Sicherheitsupdate für alle derzeit gepflegten Zweige veröffentlicht, um eine Hash-Flooding-Sicherheitslücke zu beheben. Dieser Zwischenfix geht jedoch auf Kosten einer signifikanten Verschlechterung der Startleistung. In der Zwischenzeit hat V8 eine Lösung implementiert, die die Leistungseinbußen vermeidet.
In diesem Beitrag möchten wir Hintergrundinformationen und die Geschichte der Sicherheitslücke sowie die endgültige Lösung geben.
Hash-Flooding-Angriff
Hash-Tabellen gehören zu den wichtigsten Datenstrukturen in der Informatik. Sie werden in V8 häufig verwendet, zum Beispiel zum Speichern der Eigenschaften eines Objekts. Im Durchschnitt ist das Einfügen eines neuen Eintrags sehr effizient mit 𝒪(1). Hash-Kollisionen könnten jedoch zu einem Worst-Case von 𝒪(n) führen. Das bedeutet, dass das Einfügen von n Einträgen bis zu 𝒪(n²) dauern kann.
In Node.js werden HTTP-Header als JavaScript-Objekte dargestellt. Paare von Header-Namen und -Werten werden als Objekteigenschaften gespeichert. Mit geschickt vorbereiteten HTTP-Anfragen könnte ein Angreifer eine Denial-of-Service-Attacke ausführen. Ein Node.js-Prozess würde nicht mehr reagieren, da er damit beschäftigt wäre, die Hash-Tabellen im Worst-Case einzufügen.
Dieser Angriff wurde bereits im Dezember 2011 offengelegt und es wurde gezeigt, dass er eine breite Palette von Programmiersprachen betrifft. Warum hat es so lange gedauert, bis V8 und Node.js dieses Problem endlich angesprochen haben?
Tatsächlich haben V8-Ingenieure sehr bald nach der Offenlegung zusammen mit der Node.js-Community an einer Abschwächung gearbeitet. Seit Node.js v0.11.8 wurde dieses Problem angesprochen. Der Fix führte einen sogenannten Hash-Seed-Wert ein. Der Hash-Seed wird zufällig beim Start ausgewählt und bei jedem Hash-Wert in einer bestimmten V8-Instanz verwendet. Ohne Kenntnis des Hash-Seeds hat ein Angreifer es schwer, den Worst-Case zu treffen, geschweige denn einen Angriff zu entwickeln, der auf alle Node.js-Instanzen abzielt.
Dies ist Teil der Commit-Message des Fixes:
Diese Version löst das Problem nur für diejenigen, die V8 selbst kompilieren oder diejenigen, die keine Snapshots verwenden. Ein auf Snapshot basierendes vorkompiliertes V8 wird weiterhin vorhersehbare String-Hash-Codes haben.
Diese Version löst das Problem nur für diejenigen, die V8 selbst kompilieren oder diejenigen, die keine Snapshots verwenden. Ein auf Snapshot basierendes vorkompiliertes V8 wird weiterhin vorhersehbare String-Hash-Codes haben.
Start-Snapshot
Start-Snapshots sind ein Mechanismus in V8, um sowohl den Start der Engine als auch das Erstellen neuer Kontexte (z. B. über das vm-Modul in Node.js) erheblich zu beschleunigen. Anstatt initiale Objekte und interne Datenstrukturen von Grund auf einzurichten, deserialisiert V8 aus einem vorhandenen Snapshot. Ein aktueller Build von V8 mit Snapshots startet in weniger als 3ms und benötigt einen Bruchteil einer Millisekunde, um einen neuen Kontext zu erstellen. Ohne Snapshot dauert der Start mehr als 200ms, und ein neuer Kontext mehr als 10ms. Das ist ein Unterschied von zwei Größenordnungen.
Wir haben bereits erklärt, wie jeder V8-Embedder Start-Snapshots in einem früheren Beitrag nutzen kann.
Ein vorkompilierter Snapshot enthält Hash-Tabellen und andere auf Hash-Werten basierende Datenstrukturen. Einmal vom Snapshot initialisiert, kann der Hash-Seed nicht mehr geändert werden, ohne diese Datenstrukturen zu beschädigen. Ein Node.js-Release, das den Snapshot bündelt, hat einen festen Hash-Seed, was die Abschwächung unwirksam macht.
Das ist die explizite Warnung in der Commit-Message.
Fast gelöst, aber noch nicht ganz
Spulen wir bis 2015 vor, meldet ein Node.js-Issue, dass das Erstellen eines neuen Kontexts in der Leistung zurückgegangen ist. Wenig überraschend liegt dies daran, dass der Start-Snapshot als Teil der Abschwächung deaktiviert wurde. Aber zu diesem Zeitpunkt war sich nicht jeder Teilnehmer der Diskussion des Grundes bewusst.
Wie in diesem Beitrag erklärt, verwendet V8 einen Pseudo-Zufallszahlengenerator, um Math.random-Ergebnisse zu erzeugen. Jeder V8-Kontext hat eine eigene Kopie des Zustands des Zufallszahlengenerators. Dies dient dazu, vorhersehbare Math.random-Ergebnisse zwischen den Kontexten zu verhindern.
Der Zustand des Zufallszahlengenerators wird unmittelbar nach der Erstellung des Kontexts von einer externen Quelle initialisiert. Es spielt keine Rolle, ob der Kontext neu erstellt oder aus einem Snapshot deserialisiert wird.
Der Zustand des Zufallszahlengenerators wurde irgendwie verwechselt mit dem Hash-Seed. Infolgedessen wurde ab io.js v2.0.2 ein vorgefertigter Snapshot Teil der offiziellen Veröffentlichung.
Zweiter Versuch
Erst im Mai 2017, während interner Diskussionen zwischen V8, Google’s Project Zero und Google’s Cloud Platform, stellten wir fest, dass Node.js noch immer anfällig für Hash-Flooding-Angriffe war.
Die erste Reaktion kam von unseren Kollegen Ali und Myles aus dem Team hinter den Node.js-Angeboten der Google Cloud Platform. Sie arbeiteten mit der Node.js-Community zusammen, um den Startup-Snapshot standardmäßig zu deaktivieren. Dieses Mal fügten sie auch einen Testfall hinzu.
Aber wir wollten es nicht dabei belassen. Das Deaktivieren des Startup-Snapshots hat erhebliche Leistungseinbußen zur Folge. Im Laufe der Jahre haben wir viele neue Sprach- features und anspruchsvolle Optimierungen zu V8 hinzugefügt. Einige dieser Ergänzungen machten den Neuaufbau von Grund auf noch teurer. Unmittelbar nach der Sicherheitsveröffentlichung begannen wir mit der Arbeit an einer langfristigen Lösung. Ziel ist es, den Startup-Snapshot wieder zu aktivieren, ohne anfällig für Hash-Flooding zu werden.
Unter den vorgeschlagenen Lösungen wählten und implementierten wir die pragmatischste. Nach der Deserialisierung aus dem Snapshot wählen wir einen neuen Hash-Seed. Betroffene Datenstrukturen werden dann neu gehasht, um die Konsistenz sicherzustellen.
Es stellte sich heraus, dass im normalen Startup-Snapshot tatsächlich nur wenige Datenstrukturen betroffen sind. Und zu unserer Freude wurde das Neue-Hash-Tabellen-Hashing inzwischen in V8 erleichtert. Der damit verbundene Overhead ist unerheblich.
Der Patch zur Wiederaktivierung des Startup-Snapshots wurde in Node.js zusammengeführt. Er ist Teil der aktuellen Node.js v8.3.0 Veröffentlichung.