Zurück zum Blog

Folgen und abonnieren

Weitgehende Verteidigungsstrategien: So stoppen Sie einen Wasm Compiler Bug, bevor er zum Problem wird

iximeow

Staff Software Engineer, Lucet & WebAssembly

Chris Fallin

Staff Software Engineer für WebAssembly, Fastly

Sicherheit ist mehr als nur lästiges Beiwerk. In modernen Cloud-Anwendungen muss Sicherheit in alle Bereiche des Produkts und der Umgebung integriert sein. Nicht anders sieht es mit einer kontinuierlichen Bewertung dieser Sicherheit durch ständiges Testen, Fuzzing und Sicherheitsanalysen bei neuen Funktionen und Merkmalen aus. Das ist unsere zentrale Philosophie, wenn es um den Einsatz von Sicherheit geht, wie wir erst neulich wieder feststellen konnten. 

Bei Fastly haben wir vor Kurzem einen Compiler Bug in Cranelift entdeckt, was wiederum Teil des WebAssembly Compilers für Compute@Edge ist. Der Fehler hätte zur Folge gehabt, dass ein WebAssembly Modul auf Speicher außerhalb des eigenen Sandbox-Heaps zugegriffen hätte. Dank der vielen daran beteiligten Personen, Prozesse und Tools wurde der Bug entdeckt und gepatcht, bevor ein Missbrauch möglich war. In diesem Post wollen wir schildern, wie wir den Bug entdeckt haben, wie er zustande kam, warum er ernste Konsequenzen hätte haben können und warum wir sicher sein können, dass ein Missbrauch rechtzeitig verhindert wurde.

Wir schreiben diesen Post im Sinne der Transparenz, aber auch um zu zeigen, dass eine integrierte Sicherheitsphilosophie nicht nur die betreffenden Tools, sondern auch die Prozesse selbst berücksichtigen muss. Auf Compute@Edge implementieren wir starke Sicherheitsgrenzen, und neben dem WebAssembly Sandboxing verlassen wir uns auf Sicherheitsmechanismen auf Betriebssystemebene. Da aber keine Software frei von Bugs ist, gehört es zu unserer Sicherheitsphilosophie, auf Probleme angemessen zu reagieren. Schauen wir uns das doch mal genauer an.

Szenario: Cranelift und Heap Sandboxing

Auf Compute@Edge führen wir bei jeder eingehenden Anfrage Kundencode aus, der in WebAssembly (Wasm) Modulen enthalten ist. Ein wichtiger Aspekt dabei ist, dass jede Kundenanfrage in einer eigenen Instanz des Moduls läuft: Es wird weder Speicher mit anderen Anfrage-Handlern geteilt, noch gibt es eine Vermengung mit anderem Kundencode.

Diese Speicherisolierung, auch Heap Sandboxing genannt, ist ein zentrales Design Feature von WebAssembly, aus dem sich viele starke Sicherheitseigenschaften ableiten lassen. Umgekehrt gilt, dass eine Kompromittierung dieser Heap-Trennung auch ernste Sicherheitsprobleme zur Folge haben kann. Genau aus diesem Grund nehmen wir die Compiler-Verlässlichkeit besonders ernst und nutzen mehrere Prozess- und Sicherheits-Layer, um unsere Kunden optimal zu schützen.

Wir kompilieren die Wasm Module so, dass sie auf unseren Servern mit Cranelift ausgeführt werden. Dabei handelt es sich um einen Compiler, der Wasm Bytecode zuvor in x86-Maschinencode umwandelt. So kann der Code sofort ausgeführt werden, sobald eine Anfrage eingeht, was die Kaltstart-Dauer erheblich reduziert und einen wesentlichen Vorteil von Compute@Edge darstellt. Cranelift konvertiert auch den Heap-Zugriff in nativen x86 Code. Jede WebAssembly Instanz (eine pro Anfrage) verfügt über ihre eigene Region im virtuellen Speicher und platziert bei der Ausführung in dieser Region einen Pointer. Wenn der Wasm Bytecode auf den Heap zugreift, konvertiert Cranelift ihn in einen Zugriff abseits der Heap-Basis. Da Wasm Pointer 32 Bits breit sind, darf dieser Offset nicht über 4 GiB (4 binäre Gigabyte oder 2<sup>32</sup> Byte) liegen. Indem wir die virtuellen Speicherregionen größer bemessen, sorgen wir dafür, dass eine Wasm Instanz nie den Speicher einer anderen Instanz erreicht. Außerdem werden keine Überprüfungen der Runtime-Grenzen durchgeführt, wodurch Compute@Edge noch performanter wird. Zwischen die Regionen setzen wir sogenannte Guard Pages ein: Dabei handelt es sich um nicht zugeordnete virtuelle Adressen, die die Wasm Instanz terminieren, falls sie in die Nähe einer anderen Speicherregion gelangt.

Auftreten des Bugs

Hier eine interessante Eigenschaft dieses Softwaredesigns: Wir gehen davon aus, dass der Compiler den Code zuverlässig konvertiert (was für gewöhnlich auch der Fall ist). Unser Wasm Sandboxing (das zu Lucet gehört) generiert eine add-Anweisung in Cranelifts interner Darstellung, um die Adressen von Basis-Pointer und Wasm Heap zusammen einzufügen. Was geschieht aber, wenn diese Addition von Ganzzahlen ein falsches Ergebnis liefert?

Scheinbar ist genau dies geschehen. Um das Problem zu verstehen, muss man zunächst Folgendes begreifen:

  • Wie gehen Compiler mit Werten von unterschiedlicher Breite (z. B. 32 und 64 Bits) um?

  • Wie wählt der Compiler Maschinenanweisungen, um Arithmetik durchzuführen?

  • Wie werden Register ausgewählt, um die Werte darin zu platzieren (Registerallokation)?

Wir wollen jede dieser Fragen separat beantworten.

Auf x86-64-Systemen sind alle Ganzzahlregister 64 Bit breit, aber einige Werte (z. B. 32-Bit Wasm Pointer) sind schmaler. Die meisten Compiler, darunter auch Cranelift, speichern die schmaleren Werte in den unteren Bits des Registers und lassen die oberen Bits undefiniert. Wenn der Code, der die Adresse für den Wasm Heap berechnet, generiert wird, muss er einen zero-extend-Operator einfügen, der den 32-Bit Wasm Pointer in einen 64-Bit-Addend umwandelt und zur Basisadresse hinzufügt.

Da solche Operationen häufig auftreten und eine explizite Durchführung bei jeder Generierung ziemlich kostspielig wäre, nutzt der Anweisungsselektor von Cranelift eine weitere Eigenart von x86-64-Systemen: 32-Bit-Anweisungen generieren mitunter 64-Bit-Werte, wobei die oberen Bits bereinigt werden. Das bedeutet, dass der erweiterte Operator überflüssig ist und entfernt werden kann.

Soweit, so gut, aber hier kommt jetzt der Registerallokator ins Spiel. Dabei handelt es sich um das kleine Stück Compiler Backend, das entscheidet, wo genau die Werte gespeichert werden. Wenn etwa das Programm zu viele aktive Variablen gleichzeitig aufweist, müssen einige Daten auf den Prozessor-Stack ausgelagert werden, um sie bei Bedarf später wieder zu laden. Dieser Prozess findet für das Programm in Hintergrund statt, und der Programmierer kann mehr Variablen nutzen als es Register gibt.

Damit können wir den Bug jetzt endlich näher beschreiben: Wenn der Registerallokator ein Register auslagert, kennt er den Wertetyp und sorgt in Cranelift dafür, dass nur die Bits des jeweiligen Typs erhalten bleiben. Wenn ein Wert genau 32 Bits breit ist, wir ihn aber als 64 Bits behandeln, die überflüssige 32-auf-64-Bit-Erweiterung entfernen und den Wert anschließend auslagern, kann es beim erneuten Laden zu einem Fehler kommen. Genauer gesagt – und zu unserem Verdruss – verwendet der Registerallokator eine Vorzeichenerweiterung, um einen 32-Bit-Wert zu laden. Das bedeutet, dass eine im ursprünglichen Programm vorliegende 32-Bit-Ganzzahl größer als 0x8000_0000 und mit Null-Erweiterung auf 64 Bits durch eine falsche Zeichenerweiterung plötzlich negativ wird.

Negative Offsets sind eine üble Sache, wenn es sich um Heap Offsets handelt!

Was also sind die Auswirkungen?

Die Folge ist, dass in seltenen Fällen ein Wasm Modul unseres Systems noch vor dem Start seines Sandbox Heaps auf Speicher zugreifen kann. Um sehr schnelle Start- und Reaktionszeiten zu erreichen, werden in unserem System mehrere Anfragen in einem einzigen Betriebssystem-Prozess bearbeitet. Theoretisch besteht also beim Lesen willkürlicher Speicher die Möglichkeit, dass Kundendaten durchdringen.

Tatsächlich hat sich aber herausgestellt, dass unser Systemdesign die Gefahr abwehren konnte. Beim Speicher-Layout unseres Compute@Edge Daemons sind Instanzen-Heaps im virtuellen Speicher mehr als 4 GiB voneinander entfernt und durch Guard-Regionen (nicht zugeordneten Speicher) voneinander getrennt. Dadurch war es für Wasm Instanzen nie möglich, auf einen anderen Instanzen-Heap (Linearspeicher) zuzugreifen: Mit einem maximalen rückwärts gerichteten Offset von 2 GiB gab es keine Möglichkeit, den oberen Teil eines Heaps der vorhergehenden Instanz zu erreichen.

Dennoch konnte ein schädliches Wasm Modul einen sehr sorgfältig konstruierten Load oder Store nutzen, um auf bestimmte kritische Daten genau vor dem Beginn eines Heaps zugreifen, zum Beispiel auf den Stack und die Globals der vorhergehenden Instanz. (Siehe Lucet Dokumentation für weitere Informationen zum Layout.) Als wir feststellten, dass dies möglich war, wurde uns klar, dass wir potenziell einem ziemlich großen Risiko ausgesetzt waren. Bei Lucet, das unsere WebAssembly Ausführung in der Produktivumgebung unterstützt, verfügen solche kritische Daten über Strukturen und Pointer, auf die sich die Laufzeitumgebung bezieht. Eine Änderung dieser Strukturen könnte durchaus einen komplexeren Exploit auslösen.

Zufällig fanden wir in Verbindung mit dem Compiler-Bug noch einen weiteren Bug, selbst wenn dieser weniger interessant und sicherheitsrelevant scheint. Jeder angestrebte Exploit würde deshalb einen sehr großen statischen Offset bei Load bzw. Store voraussetzen, der sich leicht feststellen ließe. Weitere Einzelheiten finden Sie im nachfolgenden Anhang.

Um das bisher Gesagte zusammenzufassen: Ein Exploit war theoretisch möglich, setzte aber einen Load oder Store mit besonderem Offset voraus. Wenn der Offset nicht ganz geeignet wäre, würde der Exploit-Versuch vermutlich trotzdem den gesamten Daemon zum Absturz bringen, weil dann die Guard-Region einer anderen Instanz betroffen wäre, welche die Daten schützt. Wenn es nicht zum Absturz käme, würden wir dennoch anomale Berichte über stark abweichende Zugriffe in unseren (aktiv überwachten) Logs vorfinden, was an sich bereits interessant wäre. Kurz und gut: Das Problem bestand, und wir mussten eine potenzielle Angriffsstelle ermitteln.

Hierzu haben wir ein Programm geschrieben, das jedes auf Compute@Edge hochgeladene Wasm Modul analysiert und nach Load- und Store-Anweisungen mit Offsets im kritischen Bereich sucht, solange der Bug in unseren Systemen vorhanden ist. Da wir die Privatsphäre unserer Kunden respektieren, haben wir auf Module niemals manuell zugegriffen bzw. diese untersucht. Diese spezielle Problemsuche lief in derselben isolierten Kompilierungs-Pipeline, mit der auch Module für Compute@Edge erstellt werden. Die Analyse ergab, dass es keine Wasm Module mit Offsets gab, die einen Exploit ermöglicht hätten. Wir konnten also belegen, dass ein Zugriff auf die Daten anderer Kunden über die Wasm Module in unserem System von vornherein unmöglich war.

Natürlich haben wir parallel zu dieser Analyse auch den Bug in Cranelift sofort gepatcht und unsere Infrastruktur neu aufgesetzt. Wenn man die rückblickende Analyse und die Bug-Behebung zusammen betrachtet, dürfen wir zuversichtlich sein, dass die Kundendaten stets sicher waren und es auch weiterhin sind.

Wie wir das Problem entdeckt haben

Die Geschichte um die Entdeckung des Bugs ist ebenso interessant wie der Bug selbst.

Es begann eines schönen Morgens mit einigen anomalen Log-Einträgen. Einer unserer Entwickler stellte fest, dass ein Compute@Edge Daemon bei einem PoP mehrere Male abgestürzt war, wodurch es nicht mehr möglich war, auf mehrere Speicheradressen zuzugreifen. Da läuteten sofort die Alarmglocken: Jeder unerklärliche Speicherzugriff stellt potenziell ein ernsthaftes Problem dar.

Wir stellten schnell fest, dass das verursachende Wasm Modul von Javier Cabrera Arteaga kam, einem Sicherheitsforscher beim KTH Royal Institute of Technology, der mit uns vereinbart hatte, Compute@Edge für seine Sicherheitsuntersuchung zu nutzen. Wir setzten uns mit Javier in Verbindung und baten um eine Kopie des Wasm Moduls. Zudem versuchten wir zu verstehen, welche Inputs wohl erforderlich waren, um dieses Verhalten zu reproduzieren. Javier teilte uns umgehend mit, worum es bei seinen Experimenten ging, und verschaffte uns Zugriff auf den Quellcode des Moduls.

Sobald wir die genaue Version des Wasm Moduls hatten, das auf den Fastly Systemen zum Absturz geführt hatte, konnten wir den Absturz reproduzieren und bald darauf das Problem auf einen Debugger zurückführen. In der Disassemblierung war der Compiler Bug deutlich zu erkennen, und mit Kenntnis des Problems konnten wir Cranelift so patchen, dass unsere Infrastruktur sicher war.

Das war aber noch nicht alles. Wir wollten auch die Auswirkungen des Bugs verstehen, um jede potenzielle Schwachstelle und Reaktion abzuschätzen. Wir haben Heap-Layouts und Grenzwertüberprüfungs-Schemata untersucht und die Auswirkungen des Bugs unter unterschiedlichen Compiler- sowie Laufzeit-Konfigurationen genau quantifiziert. Wir haben festgestellt, welche Einstellungen und Anwendungsfälle den Bug in Cranelift auslösen und welche Auswirkungen dies für unsere Infrastruktur hätte. Genau dabei ist es uns gelungen, die ausnutzbaren Schwachstellen zu verstehen, und wir haben gezielt, wie zuvor bereits beschrieben, nach Wasm Modulen mit spezifischen statischen Load- und Store-Offsets gesucht. Im Zusammenhang mit dieser Arbeit und wegen unseres Engagements für die Open-Source-Community im Allgemeinen und die Bytecode Alliance im Besonderen, versuchten wir auch die Auswirkungen auf den Open-Source Wasmtime und die Lucet Laufzeiten zu klären. Diese Untersuchungen mündeten schließlich in unserem Bericht zur Offenlegung von Schwachstellen.

Während wir die Voraussetzungen untersuchten, haben wir zugleich einen Working Exploit für unseren Compute@Edge Daemon entwickelt. Dies war einerseits ernüchternd, andererseits stimmte uns die Arbeit aber auch optimistisch, weil wir jetzt genau verstanden, wie das Problem aktiv ausgenutzt werden konnte. Wir haben erkannt, dass die Positionen von Wasm Heap Load und Store genau stimmen mussten, um den Absturz des Daemons zu verhindern. Nur mit Insider-Wissen war es uns letztlich möglich, den Exploit fertigzustellen. Durch ein Ausbleiben von Abstürzen, die in unseren Echtzeit-Logs an anderer Stelle beobachtet wurden, kombiniert mit unserer Analyse aller bestehenden Wasm Module, kamen wir letztlich zum Schluss, dass der Exploit in unserer Umgebung bislang noch nicht zum Einsatz gekommen war.

Weitgehende Verteidigungsstrategien: sichere Prozesse, aktives Monitoring und proaktive Fehlerbehebung

Einige der bedeutsamsten Rückschlüsse aus dieser Störung kamen vom Prozess selbst. Wir stellten fest, dass unsere Prozesse nach dem Bug suchten, sobald eine Anomalie in den Logs festgestellt worden war. Außerdem stellten wir fest, dass wir alle relevanten Produktentwickler, Sicherheitstechniker, Mitarbeiter der Kommunikations- und der Rechtsabteilung auf effiziente Weise koordinieren konnten, um die Schwachstelle in kurzer Zeit zu beseitigen.

Zusätzlich dazu haben wir auch einige neue Fähigkeiten entwickelt. Es handelte sich immerhin um die erste derartige Sicherheitslücke bei Cranelift seit der Bekanntgabe des Launches von Compute@Edge. Damit wurden mehrere interne Prozessänderungen vorgenommen, wodurch wir jetzt für die Zukunft noch besser gerüstet sind. Es war auch das erste Mal, dass wir uns mit der Bytecode Alliance abgestimmt haben, um betroffene Nutzer der Software zu ermitteln und einen Sicherheitsratgeber zu veröffentlichen. Wir sind fest davon überzeugt, dass eine Zusammenarbeit mit allen Mitgliedern der Bytecode Alliance sinnvoll ist, um die Softwaresicherheit zu verbessern. Wir nutzen auch weiterhin unterschiedliche Techniken, um Bugs proaktiv zu finden und zu beheben, noch bevor sie beim Kunden Schaden anrichten.

Natürlich sind Sicherheitslücken nie wünschenswert, aber sie sind nun einmal ein Teil von moderner Software. Wichtig ist nur, wie man darauf reagiert. Dies wird umso bedeutender, je mehr Kunden sich auf Compute@Edge als sichere und vielseitige Plattform verlassen. Wir wollen unsere auf Sicherheit bedachte Arbeit auf die zuvor beschriebene Weise fortsetzen, damit dies auch weiterhin gilt.


Anhang: So funktioniert der Bug genau


Im Anschluss an diesen Post wollten wir genauer darauf eingehen, wie der Bug funktioniert, und wie es für System- und Compiler-Entwickler genau aussieht, wenn die Kompilierung aus dem Ruder läuft.

Eine annähernd minimale Reproduktion des Bugs ergibt etwa folgende Disassemblierung:

; function prologue, storing a few register-based arguments
push   rbp  
mov    rbp,rsp
sub    rsp,0xe0
mov    QWORD PTR [rsp],r12
mov    QWORD PTR [rsp+0x8],r13
mov    QWORD PTR [rsp+0x10],r14
mov    QWORD PTR [rsp+0x18],rbx
mov    QWORD PTR [rsp+0x20],r15
mov    r12,rdi                       ; bug-relevant details begin here!
                                     ; rdi is the first argument, the WebAssembly "VMContext".
                                     ; Lucet sets VMContext to the heap base, with critical structures
                                     ; placed in the (4k) page before the heap.
mov    r11,rsi                       ; rsi is the second argument, the first one from user-controlled
                                     ; WebAssembly code. call it "heap_offset".
mov    rsi,rcx                       ; rcx is the third argument, a user-controlled i64 - call it "user_qword".
mov    QWORD PTR [rsp+0x40],rsi      ; spill "user_qword", just a quirk of this PoC .

...

mov    QWORD PTR [rsp+0x30],r11      ; spill "heap_offset", again just a quirk.
movsxd rsi,DWORD PTR [rsp+0x30]      ; reload "heap_offset".
add    esi,edx                       ; this add helps convince Cranelift to spill in a way it later incorrectly sign extends.
                                    ; edx is also an argument, which is set to 0 in our PoC - this add does not change "heap_offset".
mov    QWORD PTR [rsp+0x30],rsi      ; the spill! we'll revisit this in a moment.

...

movsxd r11,DWORD PTR [rsp+0x30]      ; the incorrect sign-extended load of "heap_offset"!
mov    rdi,QWORD PTR [rsp+0x40]      ; reload "user_qword"
mov    QWORD PTR [r12+r11*1+0x0],rdi ; store "user_qword" to "VMContext" + "heap_offset".
                                     ; since "heap_offset" was sign-extended r11 might be a number like -4096,
                                     ; this store might write "user_qword" over critical structures Lucet relies on.

Dabei sind die Sicherheitsimplikationen ziemlich klar: Wenn unmittelbar vor der Heap-Basis wichtige Strukturen vorhanden sind, verursacht ein kleiner negativer Offset sehr einfache Zugriffsbedingungen. Die Schwierigkeit besteht jetzt darin, den Compiler zu überreden, diese Art von störanfälligem Code preiszugeben. Kurz gesagt: Wir nehmen nicht die zahlreichen Stellen auf, die zusammen gespeichert, addiert, multipliziert und gemischt werden, um den Compiler mit genügend Registrierungsdruck zu bewegen, den WebAssembly Heap Offset auszulagern.

Wie zuvor erwähnt, werden eventuelle Exploit-Versuche durch einen zweiten Bug erschwert, wenngleich derartige Versuche eindeutig belegbar wären. Der Konfigurationsparser, auf den wir uns beim Parsen von Heap-Konfigurationen verlassen, hat „4 GB“-Parameter als „4.000.000.000“ Bytes interpretiert, also als dezimale „Gigabytes“ statt binärer „Gibibytes“. Da die maximale Heap-Größe auf unter 4 GiB (4.294.967.296) konfiguriert war, wurden bei kompilierten WebAssembly Modulen für die letzten 294.967.296 Bytes an Heap-Speicher nach wie vor Grenzwertüberprüfungen durchgeführt. Dies sorgte bei der Untersuchung der Disassemblierung für einige unerwartete Anweisungen:

mov    edi, 0xee6b27fe           ; an entirely unexpected constant: 3,999,999,998
movsxd rax, DWORD PTR [rsp+0x88] ; the incorrect sign-extended load
cmp    eax, edi                  ; compare against the heap bound
jae    ff0 <guest_func_4+0x360>  ; and branch to a trap site if out of bounds

Das ist ein Glücksfall, denn ein Angreifer könnte zunächst auf die Idee kommen, einen Heap Offset wie 0xfffff000 zu verwenden, um dann leicht zurückzugehen und die kritischen Strukturen zu verändern, auf die sich Lucet verlässt. In einem solchen Fall würde die Grenzwertüberprüfung fehlschlagen und das Programm würde mit einer Unzahl von Out-of-Bounds-Zugriffen in einem besorgniserregend großen Offset hängen bleiben. Da der maximale rückwärts gerichtete Heap-Pointer (der, der am nächsten an Null ist) 0xee6b27fd ist, sollte man annehmen, dass die unmittelbar vor dem Bug kommenden 294.967.297 Bytes einer WebAssembly Instanz sicher vor Manipulation sein sollten. Leider mussten wir schnell feststellen, dass dies nicht die ganze Geschichte ist.

Die Load- und Store-Anweisungen von WebAssembly umfassen einen Offset, der explizit dafür vorgesehen ist, Load und Store im Umgang mit Datenstrukturen zu erleichtern. Und das Layout einer Datenstruktur ist gewöhnlich für das gesamte Programm identisch. So besitzt beispielsweise das Feld st_size von Struct Size immer denselben Offset, ganz gleich, wie die „Struct Size“ genau aussieht. Ein Compiler kann dann den Offset als Immediate schreiben, und wiederholte Operationen innerhalb eines Structs können immer denselben Struktur-Pointer verwenden. Der Offset wird allerdings in WebAssembly definiert, weshalb ein Angreifer die Grenzwertüberprüfung vollständig umgehen kann, indem er einen Heap-Offset mit geringer Sicherheit wählt, bei einem Load oder Store einen großen Offset hinzufügt und dann in Regionen vorstößt, die unmittelbar vor dem Heap einer Instanz liegen.

An dieser Stelle konnten wir einen PoC einer Instanzspeichermanipulation erstellen, welche die Sicherheitseigenschaften des Lucet Sandboxing verletzen würde. Wir konnten Informationen aus der Instanz lesen, noch bevor die Schadsoftware dies tat, oder bevor sie den Pointer überschrieb und die Kontrolle übernahm. Zusammenfassend soll dies eine Warnung sein, dass wir Sicherheitsfragen ernst nehmen müssen, selbst wenn wir uns aktuell kaum vorstellen können, wie ein Angreifer diese ausnutzen könnte.