- Speichersicherheit und Thread-Sicherheit sind keine trennbaren Konzepte; ohne Thread-Sicherheit lässt sich keine echte Speichersicherheit erreichen
- Bei nicht thread-sicheren Sprachen wie Go kann Speichersicherheit schon allein durch Thread-Probleme verletzt werden
- Einige Sprachen wie Java sorgen mit einem Nebenläufigkeits-Speichermodell dafür, dass selbst Data Races als definiertes Verhalten behandelt werden und sichern so Sicherheit auf Sprachebene
- Go ist anfällig für Data Races, und es gibt reale Fälle von Verletzungen der Speichersicherheit
- Die Eigenschaft, die wirklich wichtig ist, ist die Abwesenheit von Undefined Behavior (undefiniertem Verhalten)
Ohne Thread-Sicherheit lässt sich keine Speichersicherheit garantieren
Begriffsverwirrung: Speichersicherheit vs. Thread-Sicherheit
- Speichersicherheit steht derzeit stark im Fokus, doch was genau damit gemeint ist, ist oft nicht klar definiert
- Traditionell bezeichnet Speichersicherheit Sprachen, die use-after-free oder out-of-bounds-Speicherzugriffe verhindern
- Thread-Sicherheit hingegen beschreibt Programme ohne Nebenläufigkeitsfehler; beide Konzepte werden oft getrennt behandelt
- Der Autor hält diese Unterscheidung für praktisch wenig nützlich und betont, dass wir in Wirklichkeit die Abwesenheit von Undefined Behavior (UB) wollen
Verletzung der Speichersicherheit durch Data Races: das Beispiel Go
- Um das Problem der getrennten Betrachtung von Speichersicherheit und Thread-Sicherheit zu zeigen, wird die Programmiersprache Go als Beispiel herangezogen
- Go wird als speichersichere Sprache eingestuft, doch in einem Programm wie dem folgenden kann schon ein Data Race zu einem Speicherfehler führen
globalVar를 반복적으로 다른 타입 값(Int, Ptr)으로 변경하면서 동시에 별도 고루틴에서 이를 읽어 메서드를 호출
- Weil sich zwei Threads überlappen und die beiden internen Pointer von
globalVar (Daten, vtable) getrennt aktualisieren, kann beim Lesen dazwischen ein Mischzustand entstehen, der zu einem ungültigen Speicherzugriff führt
- In der Folge versucht das Programm, auf eine falsche Adresse zuzugreifen (z. B.
0x2a, hexadezimal für 42), und beendet sich mit einem Fehler
- Ein ähnliches Phänomen gibt es auch bei Go-Interfaces, Slices usw.; die Ursache ist, dass mehrere Felder nicht atomar aktualisiert werden
Wie andere Sprachen mit Nebenläufigkeit umgehen und was das für Speichersicherheit bedeutet
- Auch andere Sprachen wie Java können Data Races haben, wenden aber ein definiertes Nebenläufigkeits-Speichermodell an und stellen so sicher, dass das Programm die Sprache selbst nicht verletzt
- Beispiel: Java gestaltet sein Speichermodell so, dass es auch in Multi-Threading-Umgebungen nicht zu Laufzeitfehlern wie etwa einem erzwungenen Segmentation Fault kommt
- Die meisten Sprachen kontrollieren Nebenläufigkeitsprobleme auf eine von zwei Arten
- Sie definieren ein Speichermodell, das für alle nebenläufigen Programme konsistentes Verhalten garantiert (dafür mit Einschränkungen bei Compiler-Optimierungen und höherem Implementierungsaufwand)
- Java, C#, OCaml, JavaScript, WebAssembly usw.
- Sie verbieten die meisten Data Races durch ein starkes Typsystem und behandeln nur wenige Ausnahmen sicher (Rust, Swift mit strikter Nebenläufigkeit)
- Go folgt keiner dieser beiden Optionen
- Speichersicherheit wird nur garantiert, wenn keine Data Races vorliegen
- Es gibt zwar Werkzeuge zur Erkennung von Data Races, doch in realen Programmen ist es schwierig, mit Tests alle Fälle abzudecken
- Forschungsergebnisse und Praxiserfahrungen berichten von zahlreichen tatsächlichen Verletzungen der Speichersicherheit
Gos Speichermodell und Probleme in der Dokumentation
- Das offizielle Dokument zum Go-Speichermodell sagt zwar, dass die meisten Races begrenzte Folgen haben, erklärt aber nicht klar, dass manche Data Races unbegrenzte Folgen haben können
- Es wird auch behauptet, Go sei Java oder JavaScript ähnlich, doch diese beiden Sprachen investieren deutlich mehr Aufwand als Go, um Nebenläufigkeitssicherheit zu gewährleisten
- Erst in einigen Detailabschnitten der Dokumentation wird eingeschränkt erwähnt, dass bestimmte Data Races vollständig undefiniertes Verhalten auslösen können
Fazit: Die Abwesenheit von Undefined Behavior (UB) ist das eigentliche Ziel
- Die Eigenschaft, die Nutzer in der Praxis wirklich wollen, ist, dass ein Programm die Sprache selbst nicht verletzt (Abwesenheit von UB)
- Die verschiedenen Sicherheitslücken, die durch Verletzungen der Speichersicherheit entstehen, existieren deshalb, weil UB tatsächlich aufgetreten ist
- Sobald UB eintritt, ist jedes weitere Verhalten unvorhersehbar, und Angreifer können das ausnutzen
- Der eigentliche Unterschied zwischen „sicheren“ und „unsicheren“ Sprachen liegt in der Möglichkeit, dass UB auftritt
- Wichtiger als die Aufspaltung in Speichersicherheit, Thread-Sicherheit, Typsicherheit usw. ist ob überhaupt UB auftreten kann
- In der Praxis gibt es auch bei Sicherheit ein Spektrum; Go ist sicherer als C, garantiert aber keine vollständige Sicherheit
- Auf Basis von Daten ist es sehr schwierig, tatsächliche Sicherheit in Go zu „beweisen“, und es ist wichtig, die oft kontraintuitiven Folgen der Entscheidungen einzelner Sprachen richtig zu verstehen
1 Kommentare
Hacker-News-Kommentare
Swift hat dasselbe Problem, und ich habe einmal ein Programm geschrieben, das zeigt, wie leicht Swift beim Zugriff auf gemeinsam genutzte Datenstrukturen Segfaults auslösen kann.
Zu sagen, Go sei im Sinne von Rust oder Java memory-safe, ist daher etwas übertrieben.
mapsteht in der Go-Spezifikation recht klar, dass sie nicht thread-safe sind und man beim Verändern vorsichtig sein muss.Ich würde gern mehr Details zu dem Problemfall bei Dropbox hören.
Memory Safety ist weniger ein Begriff aus der Programmiersprachentheorie als vielmehr ein Begriff aus der Software-Sicherheit.
Letztlich kennen Go-Programmierer diesen Unterschied sehr wohl, und deshalb basiert Go standardmäßig auf der Prämisse „nicht durch Teilen kommunizieren, sondern durch Kommunikation teilen“.
Natürlich ist dieses Konzept in der Praxis nicht vollständig umgesetzt worden, und heute versteht jeder, dass auch in Go viel geteilt wird und Synchronisierung nötig ist.
Wenn ich auf mehrere Jahre mit Go in der Praxis zurückblicke, glaube ich kaum, dass ich solche Bugs je wirklich erlebt habe.
Uber hat Bugs in Go-Code ausführlich dokumentiert, und dieser Artikel enthält eine Tabelle dazu, wie oft solche Probleme in der Praxis auftreten.
Die meisten gleichzeitigen Zugriffe auf
mapoderslicein Go betreffen denselben Slice, und dafür muss ein „torn read“ auftreten, daher ist das in der Praxis nicht häufig.Trotzdem vermeiden Leute solche Probleme offenbar gut, wahrscheinlich weil sie meist ausreichend vorsichtig sind und das Risiko kennen, Variablen in Situationen mit gleichzeitigen Zugriffen neu zuzuweisen.
Da die Sprache selbst
atomics,channelundmutexbereitstellt, kommt es in der Praxis selten vor, dass man bei konkurrierendem Zugriff etwas falsch benutzt, und mit dem Race Detector findet man solche Probleme auch schnell.Selbst wenn es Leistungseinbußen gibt, halte ich das Torn-Read-Problem für etwas, das man einfach beheben kann, und in produktivem Go-Code war es für mich nie ein großes Problem.
Passendes Video
Selbst der Race Detector fand nichts, und niemand verstand, was eigentlich passierte.
Am Ende stellte sich heraus, dass ein Schleifenzähler überlief und dieselbe Berechnung extrem oft wiederholte, wodurch Anfragen gelegentlich statt 100 ms ganze 3 Minuten dauerten.
In der Produktion wurde das Problem indirekt über
perfsichtbar, und meine Debugging-Erfahrung als Plattform-Entwickler half dem Team sehr.Weil ich so viele verschiedene Race-Situationen in Go gesehen habe, würde ich mir persönlich wünschen, dass Rust überall eingeführt würde.
Zum Beispiel erfordert dieses Issue ein großes Refactoring des Compilers und braucht deshalb lange.
Send/Sync-Typen in Rust.In der Praxis gibt es bislang nur wenig nebenläufigen Zig-Code, daher sind die Probleme noch nicht groß sichtbar geworden, aber ich denke, sobald
asyncbreiter verwendet wird, könnten viele Probleme auf einmal aufbrechen.Natürlich hat man weniger Bugs als in C, aber das gilt auch für C++, und niemand nennt C++ memory-safe.
Natürlich heißt das nicht, dass das Risiko vollkommen null ist, aber es deutet darauf hin, dass dies aus Sicherheitssicht bei Go-Anwendungen wahrscheinlich kein Prioritätsthema ist.
Bei C/C++ stammen dagegen 60–75 % der realen Schwachstellen aus Memory-Safety-Problemen.
Auch Memory Safety ist ein Kontinuum, und ich denke, ab einem gewissen Punkt nimmt der zusätzliche Nutzen ab.
Auch ein nicht ausnutzbarer Bug muss am Ende trotzdem behoben werden.
Da in Wartung deutlich mehr Zeit fließt als in die Erstentwicklung, halte ich es für sinnvoll, selbst eine verzögerte Erstveröffentlichung in Kauf zu nehmen, wenn sich dadurch die Wartung verringern lässt.
In Go ist Thread Safety dagegen keine Hauptursache für CVEs.
Theoretisch gibt es dafür zwar eine Grundlage, in der Praxis tritt es aber nicht stark hervor.
Wenn Speicher geteilt wird, kann das Beschädigen einer Datenstruktur dazu führen, dass in einem anderen Thread unsicheres oder falsches Verhalten auftritt.
Wenn zum Beispiel ein Thread die Größe eines Vektors verändert, während ein anderer darauf zugreift, wird etwas, das bei sequentieller Ausführung sicher ist, unter Nebenläufigkeit riskant.
Auch Go ist davon nicht ausgenommen.
Wenn ein Thread-Safety-Problem dagegen nur in einem Segfault endet, ist es möglicherweise lediglich ein DoS-Angriff (Denial of Service).
Eine Race Condition kann zwar auch zu stärkeren Angriffen führen, ist aber wesentlich schwerer auszulösen.
Das ist eine Hauptursache für Datenkorruption und Races.
In vielen Situationen ist ein prozessbasiertes Modell ein besseres Nebenläufigkeitsmodell als Threads, hat aber den Nachteil, zu schwergewichtig zu sein.
Wenn es standardmäßig wäre, alle für einen Thread nötigen Daten per Message Passing zu übergeben, würden die meisten dieser Probleme vermutlich verschwinden.
Wie auch immer: Auf Plattformen haben wir die Freiheit, globale Variablen und Shared Memory zu verwenden, also können wir uns auch einfach dagegen entscheiden.
Rusts ursprüngliches Ziel war nicht ein memory-safe Systems Language, sondern eine thread-safe Systems Language, und Memory Safety ergab sich dabei quasi als Nebenprodukt.
In Rust kann man strukturierte Nebenläufigkeit mit
thread::scopeund Ähnlichem nutzen, wodurch Thread-Arbeit sehr angenehm wird.channelusw.) betont als das direkte Teilen von Speicher.Siehe dieses Dokument.
Konkretes Beispiel: In diesem Code übergibt
buf.Bytes()nur eine Referenz auf den internen Speicher, und durch den Aufruf vonReset()wird derselbe Backing Memory wiederverwendet, sodassprocessDataundmaingleichzeitig auf denselben Speicher zugreifen und dadurch ein Data Race entsteht.In Rust würde solcher Code wegen zweier mutabler Referenzen gar nicht erst kompilieren; die Sprache würde eine Ownership-Übertragung oder eine Kopie erzwingen.
In Go ist das leicht verwirrend:
bytes.Buffer.ReadBytes("\n")oder.String()geben eine Kopie zurück und sind deshalb sicher,.Bytes()dagegen ist wie oben gefährlich.Rust-Channels verhindern dieses Problem grundlegend über Ownership- und Transfer-Konzepte, Go hat solche Schutzmechanismen nicht.
In der Folge wirkt es langsamer als ein Mutex und für Go-Einsteiger sogar schwieriger, korrekt zu verwenden.
Das heißt, „sichere“ Races oder „sichere“ Deadlocks sind eher noch verbreiteter.
Aus Sicht der PL-Theorie mag Rusts Ansatz der Race-Freiheit attraktiv sein, aber in realen Anwendungen liegen die wichtigen Daten ohnehin in einem RDBMS, und wenn man etwa bei
SELECTkeinFOR UPDATEverwendet, können Races jederzeit auftreten.Selbst wenn eine Rust-Anwendung überhaupt kein
unsafeverwendet, existieren abhängig von der Datenbank weiterhin Races.Dass Go fast keine Memory-Corruption-Bugs zulässt, erkennt man am Fehlen realer Exploits.
Folgte man der Behauptung des Artikels, wären die meisten High-Level-Sprachen ebenfalls nicht memory-safe, im Text wird praktisch nur Java ausgenommen.
Rust mag „sicherer“ als Go sein, aber „memory safety“ ist kein kontinuierliches Spektrum, sondern eher ein Bestehen-oder-Nichtbestehen-Konzept.
Wenn man behaupten will, eine Sprache sei memory-unsafe, sollte man zwingend einen POC zeigen.
Das Beispiel im Artikel zeigt, dass man leicht Memory Corruption erzeugen kann, indem ein
intfälschlich als Pointer interpretiert wird.Im Demo wird absichtlich
42verwendet, damit es zu einem Segfault kommt; hätte man eine echte Adresse verwendet, wäre echte Corruption möglich gewesen.SIGSEGV.Daher kann eine Sprache, in der Data Races möglich sind, nicht als memory-safe gelten.
Ich frage mich, ob man das unter solchen Umständen noch memory-safe nennen kann.
Um solche Probleme zu vermeiden, werden manchmal Personennamen verwendet, wie bei „Gaussian Curvature“ oder „Riemann Integrals“.
Fälle, in denen „die ursprüngliche Bedeutung eng bestehen bleibt und zusätzlich eine breitere Bedeutung entsteht“, gibt es ebenfalls, etwa bei der „Galois Group“.
In diesem Sinne ist Memory Safety keine Ausnahme.
Ich hätte gern ein konkretes Beispiel.
In der FAQ wird etwa in Erwähnungen von memory safety oder in Antworten zu unions impliziert, dass Go memory-safe sei, aber was genau das heißt, bleibt unklar.
In einem Vortrag von Rob Pike aus dem Jahr 2012 hieß es zwar „Not purely memory safe“, aber selbst die Bedeutung von „purely“ ist nicht definiert.
Auch in der Dokumentation zum Race Detector bleibt unklar, was „safe“ genau bedeutet (Beispieldokument).
Von außen wird Go dagegen oft sehr deutlich als „memory-safe programming language“ bezeichnet.
Beispiele sind etwa die Sicherheitsdokumentation von fly.io oder die Klassifikation von Go als memory safe auf memorysafety.org.
Im selben Dokument werden jedoch auch „Out of Bounds Reads and Writes“ als Memory-Safety-Probleme beschrieben, und die im Beitrag genannten Go-Fehler fallen genau darunter.
Mindestens sollten Go und die Community die genaue Bedeutung von „memory safety“ klarstellen.
Solange solche Fälle existieren, ist es sinnvoller, Go nicht ohne Erklärung als memory-safe Sprache zu bezeichnen.
Als Go entstand, war die Sichtweise verbreitet, dass „mit Garbage Collector automatisch memory-safe“ gemeint sei, und im Vergleich zu C/C++ ist Go tatsächlich deutlich sicherer.