Wie ein Bug im ARM64-Compiler von Go entdeckt wurde
(blog.cloudflare.com)- Cloudflare entdeckte beim Überwachen von Traffic im großen Maßstab einen seltenen Race-Condition-Bug im Go-Compiler auf der arm64-Plattform
- Der Bug zeigte sich dadurch, dass Services beim Stack-Unwinding unerwartet in einen Panic-Zustand gerieten oder Speicherzugriffsfehler auftraten
- Bei der Ursachenanalyse wurde bestätigt, dass das Problem zwischen der asynchronen Preemption der Go-Runtime und zwei vom Compiler erzeugten Instruktionen zur Anpassung des Stack-Pointers auftrat
- Mit minimalem Reproduktionscode wurde nachgewiesen, dass es sich um ein Problem der Go-Runtime selbst handelt, und dass ein Race-Window von genau einer Instruktionsgröße existiert, in dem der Stack-Pointer nur unvollständig verändert wird
- Das Problem wurde in go1.23.12, go1.24.6, go1.25.0 behoben; der neue Ansatz vermeidet Stack-Pointer-Manipulationen, die nicht sofort atomar geändert werden können, wodurch die Race Condition grundsätzlich blockiert wird
Analyse des von Cloudflare gefundenen Go-ARM64-Compiler-Bugs
Die Rechenzentren von Cloudflare verarbeiten in mehr als 330 Städten weltweit pro Sekunde 84 Millionen HTTP-Anfragen. In einer solchen Umgebung mit massivem Traffic werden selbst seltene Bugs regelmäßig sichtbar. Dieser Beitrag analysiert detailliert anhand eines realen Falls ein Race-Condition-Problem im vom Go-Compiler für die arm64-Plattform erzeugten Code.
Untersuchung eines seltsamen Panic-Phänomens
- Im Cloudflare-Netzwerk laufen Services, die den Traffic von Produkten wie Magic Transit und Magic WAN im Kernel konfigurieren
- Auf arm64-Maschinen wurden im Monitoring seltene, aber wiederkehrende fatal panic-Meldungen erkannt
- Die erste Analyse zeigte, dass beim Stack-Unwinding eine Integritätsverletzung erkannt wurde; in älterem Code, der das Muster panic/recover verwendete, traten Panics besonders häufig auf
- Vorübergehend wurde die panic/recover-Struktur entfernt, um die Häufigkeit der Panics zu senken, später traten jedoch verdächtige fatale Panics noch häufiger auf
- Daher wurde entschieden, dass eine tiefere Ursachenanalyse über bloßes Muster-Tracking hinaus notwendig war
Überblick über Datenstrukturen der Go-Runtime und des Schedulers
- Go verwendet als leichtgewichtigen User-Space-Scheduler ein M:N-Scheduling-Modell, bei dem mehrere Goroutines auf eine kleine Zahl von Kernel-Threads abgebildet werden
- Die zentralen Strukturen des Schedulers sind
g(Goroutine),m(Maschine/Kernel-Thread) undp(Prozessor) - Fehler beim Stack-Unwinding oder Speicherzugriffsfehler treten auf, wenn sich Stack-Pointer oder Return-Adresse ungewöhnlich verändern
Strukturelle Ursache der Fehler beim Stack-Unwinding
- Die Analyse mehrerer Backtraces zeigte, dass alle Fehler während des Stack-Unwindings in der Funktion
(*unwinder).nextauftraten - In einem Fall war die Return-Adresse null, wodurch der Stack als ungültig erkannt und mit einem fatalen Fehler beendet wurde; in einem anderen Fall trat beim Zugriff auf ein Feld (
incgo) der Go-Scheduler-Strukturminnerhalb eines Stack-Frames ein Segmentation Fault auf - Der Crash trat an einer Stelle auf, die deutlich vom eigentlichen Ort des Bugs entfernt lag, was die Ursachenanalyse erschwerte
Beobachtete Muster und Zusammenhang mit der Go-Netlink-Bibliothek
- Die Prüfung der Stack Traces ergab, dass sich die Crashes auf Zeitpunkte konzentrierten, an denen eine Preemption in der Funktion
NetlinkSocket.Receiveder Go-Netlink-Bibliothek ausgelöst wurde - Danach wurden zwei Hypothesen aufgestellt
- ein möglicher Bug durch die Verwendung von unsafe.Pointer in Go Netlink
- ein möglicher Bug in der asynchronen Preemption und im Stack-Unwinding der Go-Runtime selbst
- Eine Code-Auditierung fand jedoch keine direkten Muster von Speicherbeschädigung, weshalb vermutet wurde, dass der Kern des Problems in der Runtime und ihrer Strategie zur Stack-Verwaltung lag
Asynchrone Preemption und Race Condition
- Die seit Go 1.14 eingeführte asynchrone Preemption erzeugt für lange laufende Goroutines erzwungene Scheduling-Punkte, indem ein Signal (
SIGURG) an den OS-Thread gesendet wird - Wenn diese Preemption zwischen zwei Assembler-Instruktionen zur Anpassung des Stack-Frame-Pointers eintritt, bleibt der Stack-Pointer in einem Zwischenzustand stehen
- Wird der Stack für Garbage Collection, Panic-Handling oder das Erzeugen eines Stack Trace unwinded, können falsche Positionen gelesen und dadurch Funktionsadressen oder Daten falsch interpretiert werden
Erstellung von minimalem Reproduktionscode
- Durch Anpassen der Größe der Stack-Frame-Allokation sowie das Schreiben einer Funktion mit expliziter Stack-Anpassung (
big_stack) und Code für ständige Garbage-Collection-Aufrufe ließ sich die Race Condition reproduzieren - Tatsächlich wurde der Stack-Pointer im Assembler-Code durch zwei ADD-Instruktionen angepasst; wenn dazwischen asynchrone Preemption auftrat, kam es beim Stack-Unwinding zum Crash
- Der Defekt ließ sich sogar nur mit Code aus der Standardbibliothek reproduzieren und bewies damit eine im vom Go-Compiler erzeugten Code angelegte Schwachstelle im Umfang von genau einer Instruktion
Ursache des Race-Windows auf ARM64-Compiler-Ebene
- Wegen der festen Instruktionslänge der ARM64-Architektur und der Beschränkungen bei Immediate-Werten können für die Anpassung des Stack-Pointers zwei oder mehr Instruktionen nötig sein
- In der internen Zwischenrepräsentation (IR) von Go ist die Länge solcher Immediate-Werte nicht bekannt; die Aufspaltung in mehrere Instruktionen erfolgt erst bei der Umwandlung in echten Maschinencode
- Dadurch werden für die Rückgabe des Stack-Frames (
ADD RSP, RSP) zwei Instruktionen verwendet, und es entsteht ein gegenüber Preemption anfälliges Race-Window von einer einzelnen Instruktion - Der Unwinder ist absolut auf die Korrektheit des Stack-Pointers angewiesen; wird mitten in einer Instruktionsfolge angehalten, führt das zu Fehlinterpretationen von Werten und fatalem Versagen
- Der tatsächliche Crash-Ablauf sieht so aus:
- Asynchrone Preemption tritt zwischen den beiden ADD-Instruktionen auf
- Durch GC oder andere Ursachen läuft die Stack-Unwinding-Routine an
- Es wird eine ungewöhnliche Stack-Pointer-Position durchsucht und eine falsche Funktionsadresse interpretiert
- Die Runtime crasht
Bugfix und grundlegende Verbesserung
- Das Cloudflare-Team meldete den Fehler auf Basis des minimalen Reproduktionscodes und der detaillierten Analyse an das offizielle Go-Repository; das Problem wurde schnell gepatcht und veröffentlicht
- Seit go1.23.12, go1.24.6, go1.25.0 wird zuerst der vollständige Offset in einem temporären Register berechnet und danach der Stack-Pointer in einer einzigen Instruktion verändert, wodurch die Preemption-Anfälligkeit beseitigt wird
- Der Stack-Pointer ist damit nun jederzeit garantiert gültig, sodass die Race Condition strukturell blockiert wird
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET
Fazit und Einordnung
- Dieser Bug ist ein Beispiel dafür, wie Compiler-Codegenerierung für eine bestimmte Architektur und Nebenläufigkeitssteuerung durch asynchrone Preemption auf unerwartete Weise kollidieren können
- Besonders bemerkenswert ist, dass hier eine extrem seltene Race Condition auf Instruktionsebene, die nur in Umgebungen mit großem Maßstab sichtbar wird, mit realen Betriebsdaten und systematischer Analyse aufgespürt wurde
- Wenn Sie Services auf Basis aktueller Go-Umgebungen und der ARM64-Architektur betreiben, ist ein Upgrade auf die entsprechenden Go-Versionen wichtig
1 Kommentare
Hacker-News-Kommentare
addden SP atomar anpasst. Natürlich ist das eine Instruktion mehr, aber die Atomizität ist damit gewährleistet. Alternativ könnte man auch erst mit einem temporären Register rechnen und dann zurückkopieren.signals)sscanf-Bug in der Ubuntu-GCC-ARM-Toolchain gefunden. Spaß gemacht hat das nicht, aber als ich das Problem genau identifiziert und sogar einen Regressionstest geschrieben hatte, war es wirklich sehr befriedigend.