Das vereinfachte Modell von Fil-C
(corsix.org)- Eine Struktur, die AllocationRecord-Metadaten zusammen mit C/C++-Zeigern verfolgt und beim Dereferenzieren Speichergrenzenprüfungen durchführt
- Ein Ansatz, bei dem bei Zeigerzuweisungen, Arithmetik, der Übergabe von Funktionsargumenten, Rückgaben sowie Aufrufen von
mallocundfreedie ursprünglichen Zeigerwerte zusammen mit den zugehörigen Metadaten mitgeführt oder in Fil-C-spezifische Aufrufe umgewandelt werden - Zeigermetadaten im Heap-Speicher werden separat in invisible_bytes abgelegt; beim Laden und Speichern von Zeigern werden Wert und Metadaten gemeinsam gelesen bzw. geschrieben, zusätzlich mit Ausrichtungsprüfung
filc_freegibt nurvisible_bytesundinvisible_bytesfrei und behält das AllocationRecord selbst bei; die spätere Bereinigung übernimmt der Garbage Collector, und lokale Variablen, deren Adresse entweichen könnte, werden per Heap-Promotion behandelt- Es bleiben reale Implementierungskomplexitäten wie Threads, Funktionszeiger sowie Speicher- und Performance-Optimierungen, aber das Modell kann zur Prüfung der Speichersicherheit großer C/C++-Codebasen oder als konkretes Systembeispiel für Pointer Provenance dienen
Vereinfachtes Fil-C-Modell
- Fil-C verwendet eine Struktur, die zusammen mit Zeigern AllocationRecord*-Metadaten verfolgt, um C/C++-Code speichersicher zu behandeln
- Die tatsächliche Implementierung arbeitet per LLVM-IR-Umschreibung, das vereinfachte Modell nimmt jedoch die Form einer automatischen Transformation von C/C++-Quellcode an
- Für jede lokale Zeigervariable einer Funktion wird eine entsprechende lokale Variable vom Typ
AllocationRecord*hinzugefügt - Zum Beispiel wird zu
T1* p1zusätzlichAllocationRecord* p1ar = NULLeingefügt
- Einfache Zuweisungen und Berechnungen mit lokalen Zeigervariablen verschieben AllocationRecord* zusammen mit dem ursprünglichen Zeigerwert
p1 = p2wird zup1 = p2, p1ar = p2artransformiert- Auch bei
p1 = p2 + 10wirdp1ar = p2armitgeführt - Bei einem Cast von Integer zu Zeiger werden die Metadaten auf
NULLgesetzt - Ein Cast von Zeiger zu Integer bleibt unverändert
- Auch bei der Übergabe von Funktionsargumenten und bei Rückgaben wird zusätzlich AllocationRecord* mitgegeben, und bestimmte Aufrufe der Standardbibliothek werden durch Fil-C-spezifische Funktionen ersetzt
- Aufrufe von
mallocundfreewerden jeweils infilc_mallocbzw.filc_freeumgewandelt - Beispielsweise wird
p1 = malloc(x); free(p1);zu{p1, p1ar} = filc_malloc(x); filc_free(p1, p1ar);
- Aufrufe von
filc_mallocreserviert nicht nur einen angeforderten Speicherbereich, sondern führt drei Allokationen durch- Allokation eines
AllocationRecord-Objekts - Allokation von
visible_bytesfür die eigentlichen Daten - Allokation von
invisible_bytespercalloczum Speichern unsichtbarer Metadaten AllocationRecordenthält die Feldervisible_bytes,invisible_bytesundlength
- Allokation eines
Dereferenzierung und Grenzprüfungen
- Beim Dereferenzieren eines Zeigers wird das zugehörige AllocationRecord* verwendet, um Grenzprüfungen durchzuführen
- Es wird geprüft, ob die Zeigermetadaten nicht
NULLsind - Die Differenz zwischen der aktuellen Zeigerposition und der Startadresse von
visible_byteswird berechnet - Es wird geprüft, ob der Offset kleiner als die Gesamtlänge ist
- Es wird geprüft, ob die verbleibende Länge für die Größe des Dereferenzierungsziels ausreicht
- Es wird geprüft, ob die Zeigermetadaten nicht
- Für Lese- und Schreibzugriffe gilt derselbe Prüfablauf
- Auch vor
x = *p1wird geprüft - Ebenso vor
*p2 = x
- Auch vor
- So blockiert die Struktur Zugriffe, bei denen ein Zeiger außerhalb seines allokierten Bereichs zeigt
Zeiger im Heap und invisible_bytes
- Für im Heap-Speicher abgelegte Zeiger kann der Compiler nicht wie bei lokalen Variablen direkt separate Begleitvariablen verwalten; deshalb werden invisible_bytes verwendet
- Befindet sich ein Zeiger an der Position
visible_bytes + i, dann wird das zugehörigeAllocationRecord*an der Positioninvisible_bytes + igespeichert - Anders gesagt verhält sich
invisible_byteswie ein Array, dessen ElementtypAllocationRecord*ist
- Befindet sich ein Zeiger an der Position
- Beim Lesen oder Schreiben von Zeigerwerten im Speicher wird zusätzlich zur normalen Grenzprüfung eine Ausrichtungsprüfung durchgeführt
- Es wird geprüft, ob der Offset
iein Vielfaches vonsizeof(AllocationRecord*)ist - Nur dann kann sicher auf
invisible_byteswie auf einAllocationRecord**-Array zugegriffen werden
- Es wird geprüft, ob der Offset
- Beim Laden eines Zeigers werden Datenzeiger und Metadaten gemeinsam geladen
p2 = *p1wird durch ein anschließendesp2ar = *(AllocationRecord**)(p1ar->invisible_bytes + i)ergänzt
- Beim Speichern eines Zeigers werden nicht nur der Zeigerwert, sondern auch die zugehörigen Metadaten geschrieben
*p1 = p2führt nach dem eigentlichen Datenspeichern zusätzlich*(AllocationRecord**)(p1ar->invisible_bytes + i) = p2araus
filc_free und Garbage Collector
filc_freegibt, wenn der Zeiger nichtNULList, nach einer Konsistenzprüfung mit dem AllocationRecord genau zwei Speicherbereiche frei- Prüfung von
par != NULL - Prüfung von
p == par->visible_bytes - Freigabe von
visible_bytesundinvisible_bytes - Danach werden
visible_bytesundinvisible_bytesaufNULLsowielengthauf 0 gesetzt
- Prüfung von
- Obwohl
filc_mallocdrei Allokationen durchführt, gibtfilc_freedas AllocationRecord-Objekt selbst nicht frei- Diese Differenz wird vom Garbage Collector behandelt
- Für das vereinfachte Modell reicht ein Stop-the-World-GC aus; das tatsächliche Fil-C verwendet einen parallelen, nebenläufigen, inkrementellen Collector
- Der GC verfolgt
AllocationRecord-Objekte - Nicht mehr erreichbare
AllocationRecord-Objekte werden zur Freigabe markiert
- Der GC verfolgt
- Der GC führt zusätzlich zwei weitere Aufgaben aus
- Beim Freigeben eines nicht erreichbaren
AllocationRecordwirdfilc_freeaufgerufen - Alle Zeiger, die auf ein
AllocationRecordmitlength == 0zeigen, werden auf ein einzelnes kanonischesAllocationRecordder Länge 0 umgestellt
- Beim Freigeben eines nicht erreichbaren
- Dadurch entsteht selbst ohne Aufruf von
freekein Speicherleck- Der GC gibt den Speicher automatisch frei
- Ein expliziter
free-Aufruf ermöglicht jedoch eine frühere Freigabe als der GC
- Nach
freewird das betreffendeAllocationRecordschließlich unerreichbar und kann später bereinigt werden
Entweichende Adressen lokaler Variablen und Heap-Promotion
- Durch das Vorhandensein eines GC erweitert sich der Bereich, in dem Adressen lokaler Variablen sicher behandelt werden können
- Wenn die Adresse einer lokalen Variablen genommen wurde und der Compiler nicht beweisen kann, dass diese Adresse nicht über die Lebensdauer der Variable hinaus entweicht, wird sie in eine Heap-Allokation promotet
- Solche lokalen Variablen werden dann per
mallocstatt auf dem Stack allokiert- Ein separates Einfügen von
freeist nicht nötig - Das Aufräumen übernimmt der GC
- Ein separates Einfügen von
Fil-C-Version von memmove
- Die
memmove-Funktion der C-Standardbibliothek verarbeitet beliebigen Speicher, weshalb der Compiler nicht wissen kann, ob darin Zeiger enthalten sind - Deshalb wird eine Heuristik verwendet
- Zeiger in beliebigem Speicher müssen vollständig innerhalb dieses Speicherbereichs liegen
- Zeiger müssen korrekt ausgerichtet sein
- Dadurch entstehen selbst bei derselben Verschiebung von 8 Byte Unterschiede im Verhalten
- Werden ausgerichtete 8 Byte in einem Schritt mit
memmoveverschoben, dann werden die entsprechendeninvisible_bytesmitverschoben - Werden 8 Byte einzeln in acht 1-Byte-
memmove-Operationen verschoben, dann werden dieinvisible_bytesnicht mitverschoben
- Werden ausgerichtete 8 Byte in einem Schritt mit
Zusätzliche Komplexität in der tatsächlichen Implementierung
-
Threads
- Nebenläufigkeit erhöht die GC-Komplexität
filc_freekann Speicher nicht sofort freigeben- Denn zwischen dem freigebenden Thread und einem anderen Thread, der auf denselben Speicher zugreift, kann ein Race Condition auftreten
- Auch atomare Operationen auf Zeigern benötigen zusätzliche Behandlung
- Die grundlegende Umschreibung ersetzt Zeiger-Loads/Stores durch je zwei Loads/Stores und zerstört damit die Atomarität
-
Funktionszeiger
- Zusätzliche Metadaten im
AllocationRecordmarkieren, obvisible_bytesnormale Daten oder ein ausführbarer Codezeiger sind - Ein Aufruf über den Funktionszeiger
p1prüft sowohlp1 == p1ar->visible_bytesals auch, obp1artatsächlich einen Funktionszeiger repräsentiert - Um Type-Confusion-Angriffe auf Funktionszeiger zu verhindern, ist auch auf ABI-Ebene eine Prüfung der Typsignatur erforderlich
- Ein möglicher Ansatz ist, allen Funktionen dieselbe Typsignatur zu geben
- Etwa so, dass alle Argumente in eine Struktur gepackt und über den Speicher übergeben werden
- An der ABI-Grenze empfängt jede Funktion dann nur ein einzelnes
AllocationRecord, das dieser Struktur entspricht
- Zusätzliche Metadaten im
-
Optimierung der Speichernutzung
- Es kann erwogen werden, dass
filc_mallocinvisible_bytesnicht sofort allokiert, sondern erst bei Bedarf - Ebenso kann
AllocationRecordzusammen mitvisible_bytesin einer einzigen Allokation platziert werden - Wenn das zugrunde liegende
mallocMetadaten am Anfang jeder Allokation anfügt, könnten diese auch in dasAllocationRecordübernommen werden
- Es kann erwogen werden, dass
-
Performance-Optimierung
- Die Speichersicherheit von Fil-C bringt Performance-Kosten mit sich
- Es gibt Spielraum für verschiedene Techniken, um einen Teil der verlorenen Leistung zurückzugewinnen
Einsatzszenarien für Fil-C
- Fil-C kann eingesetzt werden, wenn große C/C++-Codebasen zwar zu funktionieren scheinen, aber keine Prüfung der Speichersicherheit haben und man bereit ist, für Speichersicherheit GC-Einführung und deutliche Performance-Einbußen in Kauf zu nehmen
- Es wird als mögliche Übergangslösung bis zu einer Neuschreibung in Java, Go oder Rust erwähnt
- Ähnlich wie ASan kann Fil-C auch zur Erkennung von Speicherfehlern ausgeführt werden
- C/C++-Code kann unter Fil-C ausgeführt werden, um Speicherfehler zu überprüfen
- In Sprachen, bei denen Compile-Time-Sprache und Runtime-Sprache identisch sind und die starke Compile-Time-Sicherheit bieten, ist auch eine Nutzung für sichere Compile-Time-Auswertung möglich
- Als Beispiel wird Zig erwähnt
- Selbst wenn die Runtime-Auswertung unsicher ist, könnte die Compile-Time-Auswertung eine Fil-C-Konfiguration verwenden
- Auch als konkretes Systembeispiel für Pointer Provenance ist Fil-C von Bedeutung
- Wenn
p1undp2denselben Typ haben, stellt sich die Frage, ob eine Optimierung vonif (p1 == p2) { f(p1); }zuif (p1 == p2) { f(p2); }zulässig ist - In Fil-C ist die Antwort eindeutig nein, weil sich das an
fübergebeneAllocationRecord*unterscheiden kann - In diesem Punkt dient Fil-C als konkretes Beispiel für ein System mit Pointer Provenance
- Wenn
1 Kommentare
Hacker-News-Kommentare
Man könnte auch Referenzzählung oder Varianten des invisible capability system ausprobieren, und vielleicht sogar Speicher sparen – auf Kosten eines kleinen zusätzlichen Indirektionsaufwands.
Hoffentlich ist das hilfreich für Leute, die beides zusammen nutzen möchten, um hermetic builds zu erstellen.
Upon freeing an unreachable AllocationRecord, call filc_free on it.So wie ich das sehe, soll damit wohl gemeint sein, dass man vor dem Freigeben eines nicht erreichbaren AR zuerst den Speicher freigibt, auf den die Felder
visible_bytesundinvisible_byteszeigen.Interessanter als das übliche „rewrite it in Rust“ im Namen der Sicherheit finde ich, dass sich bestehende C-Programme vollständig speichersicher kompilieren lassen.
Erstens ist Fil-C langsamer und größer. Wenn das akzeptabel wäre, könnte man auch sagen, dass man in den letzten zehn Jahren eher zu Java oder C# als zu Rust hätte wechseln sollen.
Zweitens verwendet man immer noch C. Für die Pflege bestehenden Codes ist das in Ordnung, aber wenn man viel neuen Code schreibt, finde ich Rust deutlich angenehmer.
Drittens bietet Fil-C Laufzeitsicherheit, während Rust einen Teil davon schon zur Compile-Zeit ausdrücken kann. Und Sprachen wie WUFFS gehen noch weiter und versuchen, Sicherheit bereits beim Kompilieren ohne Laufzeitprüfungen zu beweisen; der Code kann dann zwar logisch falsch sein, aber Abstürze oder die Ausführung beliebigen Codes sollen verhindert werden.
Es gab Threads wie Fil-Qt: A Qt Base build with Fil-C experience, Linux Sandboxes and Fil-C, Ported freetype, fontconfig, harfbuzz, and graphite to Fil-C, A Note on Fil-C, Notes by djb on using Fil-C, Fil-C: A memory-safe C implementation und Fil's Unbelievable Garbage Collector.
Man kann immer noch speicherunsicheren Code schreiben, nur ist das Ergebnis jetzt eher ein sicherer Absturz statt einer Schwachstelle.
Wenn man etwa eine Web-API baut, die nicht vertrauenswürdige Eingaben annimmt, kann so ein Problem am Ende immer noch zu einem denial-of-service führen; das ist zwar besser, aber meiner Meinung nach noch nicht gut genug.
Das soll die Arbeit an Fil-C nicht kleinreden, aber ich denke, Laufzeitansätze haben klare Grenzen.
Fairerweise muss man aber sagen, dass Fil-C deutlich langsamer als Rust ist und auch mehr Speicher verbraucht.
Andererseits unterstützt Fil-C safe dynamic linking und man könnte in mancher Hinsicht sogar sagen, dass es strenger sicher ist als Rust.
Letztlich sind das Trade-offs, und man sollte je nach Situation auswählen.
Deshalb scheint diese Idee zwar technisch interessant zu sein, emotional aber nicht leicht anzukommen.
Capability- und Pointer-Werte können während einer Zuweisung zerrissen werden; wenn das Thread-Interleaving ungünstig ausfällt, kann dadurch mit einem falschen Pointer auf ein Objekt zugegriffen werden, was zu beliebigem Fehlverhalten führen kann.
Mit dieser Einschränkung an sich könnte ich leben, aber schade finde ich die Atmosphäre, in der selbst Unterstützer hart gegen Leute vorgehen, die auf das Problem hinweisen.
Leider ist das auch einer der großen Gründe für den Overhead.
Solche Ansätze wurden schon oft implementiert und dann wieder verworfen, weil die Sicherheitsgarantien nicht ausreichten, non-fat ABI boundaries schwer zu überqueren waren oder der Overhead zu hoch war.
Außerdem lässt sich filc meiner Meinung nach nicht allein durch einfache fat pointer erklären.