- Wer die CVE-Zahlen von Rust und C/C++ einfach direkt vergleicht, übersieht leicht den Unterschied bei den Kriterien dafür, ob Memory-Safety-Schwachstellen als „Bibliotheksproblem“ gelten
- In C/C++ werden fehlerhafte API-Aufrufe, die zu UB oder einem Segfault führen, meist als Fehlgebrauch im Anwendungscode behandelt; nicht jede bloße Möglichkeit wird als CVE erfasst
- Der Aufruf
libcurls curl_getenv(NULL) wird ohne Warnung gebaut und kann zur Laufzeit einen Segfault auslösen, wird aber normalerweise nicht als curl-Schwachstelle betrachtet
- In Rust gilt ein Speicherfehler, der allein durch Aufrufe sicherer APIs entsteht und bei dem im Anwendungscode kein
unsafe vorkommt, als Soundness-Bug der Bibliothek
- Deshalb werden einige CVEs in Rust nach strengeren Maßstäben erfasst als in C/C++, und ein bloßer Vergleich roher CVE-Zahlen reicht nicht aus, um Memory Safety zu beurteilen
Warum der Vergleich von CVE-Zahlen ins Wanken gerät
- CVE ist eine Datenbank zur Klassifizierung und Meldung von Softwaresicherheitslücken
- Schwachstellen können aus einfachen Logikfehlern in Programmen entstehen oder aus Memory-Safety-Problemen, die sich leichter zu Exploits entwickeln lassen
- Beim Vergleich der CVE-Zahlen von Rust und C/C++ wird teils behauptet, Rust sei „in Wirklichkeit nicht speichersicher“ oder „eine Einführung nicht wert“
- Doch es gibt große Unterschiede darin, wie beide Ökosysteme potenzielle Schwachstellen rund um Memory Safety behandeln
Schwachstellen sind auch in Rust möglich
- Auch Rust-Programme können UB und Memory-Safety-Bugs verursachen
- In den meisten Fällen ist dafür das Schlüsselwort
unsafe nötig
- Die Behauptung, Rust-Programme könnten grundsätzlich nie UB erleben, ist falsch
- Auch allgemeine Schwachstellen ohne Bezug zu Memory Safety sind in Rust möglich
- Ein fehlender Berechtigungscheck für den Zugriff auf ein Admin-Dashboard kann in jeder Sprache vorkommen
Beispiel aus einer C-Bibliothek: curl_getenv(NULL)
curl ist eine weit verbreitete und gut gepflegte netzwerkbezogene Bibliothek auf C-Basis
curl_getenv aus libcurl ist eine portable Abstraktionsfunktion, um auf verschiedenen Betriebssystemen Umgebungsvariablen auszulesen
- Das folgende C-Programm übergibt einen
NULL-Pointer an curl_getenv
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- Dieses Programm lässt sich mit
gcc test.c -otest -lcurl -Wall -Wextra ohne Warnungen kompilieren
- Bei der Ausführung kann ein Segfault auftreten; das lässt sich als Memory-Safety-Bug und potenzielle Schwachstelle sehen
- Solche Beispiele gelten jedoch normalerweise nicht als meldefähige
curl-Schwachstelle
In C/C++ führen bloße Fehlgebrauchsmöglichkeiten nicht zu einem CVE
- Fälle wie
curl_getenv(NULL) werden im Allgemeinen als falsche Verwendung der API angesehen
- Der Ort des Fehlers wird eher im Anwendungscode als in der Bibliothek oder API verortet
- Für diese Praxis gibt es zwei Gründe
- Mit dem eingeschränkten Typsystem von C lassen sich Verträge, Invarianten, Vorbedingungen und Nachbedingungen einer API nur schwer präzise ausdrücken
- Es ist auch nicht praktikabel, jede mögliche Fehlverwendung zu dokumentieren
- Tatsächlich sagt die Dokumentation von
curl_getenv nicht, dass Aufrufe mit NULL verboten sind oder zu einem Segfault führen können
- Da sich in C/C++ UB sehr leicht unbeabsichtigt auslösen lässt, würden die meisten Bibliotheken von einer enormen Zahl an CVEs überschwemmt, wenn jede potenzielle Schwachstelle gemeldet würde
- Daher drehen sich CVEs in C/C++ meist nicht um die bloße Existenz missbrauchbarer APIs, sondern um konkrete Missbrauchsfälle
In Rust verlaufen die Verantwortungsgrenzen sicherer APIs anders
- Angenommen, ein Programm segfaultet in Rust allein durch einen sicheren Aufruf wie
hyper::foo(None); dann könnte das eine CVE für hyper sein
- Wenn im Anwendungscode kein
unsafe-Block vorkommt und dennoch ein Speicherfehler auftritt, muss die betreffende Bibliothek einen Soundness-Bug haben
- Wenn in Rust bei irgendeiner Nutzung einer sicheren Bibliotheks-API ein Speicherfehler auftreten kann, wird das nicht dem Anwendungscode, sondern der Bibliothek als Bug zugerechnet
- Man sagt dann, diese API sei unsound oder habe ein Soundness Hole
- Selbst wenn in realen Programmen noch kein konkreter Vorfall gefunden wurde, kann eine CVE entstehen, wenn allein die Nutzung sicherer APIs Speicherfehler auslösen kann
safe und unsafe machen Verantwortung sichtbar
- In Rust ist die Antwort auf die Frage „Wird diese Funktion aus Sicht der Memory Safety korrekt verwendet?“ klarer als in C/C++
- Ist die aufgerufene Funktion nicht als
unsafe markiert, sollte sie sicher verwendbar sein
- Ist die aufgerufene Funktion
unsafe, braucht die Aufrufstelle einen unsafe-Block, und riskante Stellen werden im Code-Review und im Codebestand klar sichtbar
- Diese Unterscheidung ist ein Grund dafür, dass sich Rusts Memory Safety in der Praxis gut skalieren lässt
- Wenn der Anwendungscode kein
unsafe verwendet und auch kein Compiler-Bug vorliegt, ist es schwer, potenzielle Ursachen für Memory-Safety-Probleme dem Anwendungscode anzulasten
- Wenn eine Bibliothek keine
unsafe-Schnittstelle nach außen anbietet, sollte sie von Nutzern nicht auf eine Weise verwendet werden können, die Speicherfehler verursacht
- Selbst wenn eine Bibliothek intern
unsafe nutzt und dabei einen Bug enthält, erfolgt die Korrektur innerhalb der Bibliothek, und die Nutzer sind danach wieder vor Speicherfehlern geschützt
Mit rohen CVE-Zahlen allein lässt sich Memory Safety schwer vergleichen
- Übertrüge man dieselbe Logik auf C, müsste auch
curl_getenv als CVE für curl markiert werden, doch C kennt keine Unterscheidung wie Rusts safe und unsafe
- Praktisch jeder C-Code ist implizit eher
unsafe, weshalb sich Rust-Maßstäbe nicht einfach 1:1 anwenden lassen
- Selbst wenn Entwickler von C/C++-Bibliotheken sichere und robuste Bibliotheken bauen, können die vielen C-Programme, die sie nutzen, durch falschen API-Gebrauch leicht wieder Memory-Safety-Probleme erzeugen
- Dieser Unterschied gilt nicht nur für
curl, sondern für nahezu alle C/C++-Bibliotheken sowie auch für die Standardbibliotheken beider Sprachen
- Rohe Zahlenvergleiche wie CVEs pro Codezeile in Rust und C/C++ können bei der Bewertung von Memory Safety daher in die Irre führen
1 Kommentare
Lobste.rs-Meinungen
Vielleicht ist das eine naive Frage, aber wenn viele Probleme in C/C++ aus undefiniertem Verhalten kommen, warum definiert man es dann nicht einfach?
Erstens gibt es Dinge, die man „einfach definieren“ könnte, weil sie mittlerweile historische Altlasten sind, um die sich niemand mehr kümmert, und wie @fanf sagte, laufen dazu bereits Arbeiten. Zum Beispiel ist eine Quelldatei mit einem nicht abgeschlossenen String-Literal in C tatsächlich undefiniertes Verhalten.
Zweitens gibt es Dinge, die man definieren könnte, die aber Performance kosten würden. Das bekannteste Beispiel ist Überlauf bei vorzeichenbehafteten Ganzzahlen: Wenn man einfach definiert, dass der Wert zyklisch umläuft, ist es kein undefiniertes Verhalten mehr, aber der Compiler kann dann keine Optimierungen mehr vornehmen, die auf der Annahme beruhen, dass so etwas „niemals passiert“. In den Gremien sitzen viele Compiler-Leute, die zu Benchmark-Fixierung neigen, daher wird sich das wohl nicht so leicht ändern. Ganz ohne Bewegung ist es aber nicht: P2723 schlägt zum Beispiel vor, in C++ alle lokalen Variablen, die sonst uninitialisiert wären, implizit mit 0 zu initialisieren.
Drittens gibt es Dinge, für die sich ein sinnvolles Verhalten nur schwer definieren lässt. Ein gutes Beispiel ist use-after-free. Wenn man nicht entweder allen ein schwergewichtiges Runtime-Capability-System wie Fil-C aufzwingt oder Rust-artige Lifetime-Annotationen über die ganze Sprache verteilt einführt, ist unklar, wie man den Bereich möglicher Verhaltensweisen bei use-after-free sinnvoll eingrenzen soll. Man könnte festschreiben: „Bei use-after-free wird entweder auf den Speicher zugegriffen, der zu diesem Zeitpunkt dort liegt, oder es gibt einen Segfault/Abbruch“, aber das hilft niemandem. Es bleibt gefährlich, CVEs entstehen genauso, und man kann danach immer noch nichts Sinnvolles darüber sagen, was das Programm tun kann oder nicht. Dann ist es im Grunde nur anders benanntes undefiniertes Verhalten.
Leider ist die Wirkung der dritten Kategorie so überwältigend groß, dass es zwar gut ist, einen Teil einfach „nun zu definieren“, die Gesamtlage aber nicht grundlegend verändert.
Soweit ich weiß, wurde die Bibliothek bisher größtenteils noch nicht angegangen, aber Funktionen mit Größenparametern wurden so geändert, dass sie mit Nullzeigern sinnvoll umgehen. Das hing mit einer Sprachänderung zusammen, die es erlaubt, zu einem Nullzeiger 0 zu addieren. Es gibt viele Funktionen, die man ähnlich bereinigen könnte, aber bei
getenv()wäre es vermutlich besser, das mit POSIX abzustimmen.Diese Performance-Gewinne sind fast immer sehr speziell und bestenfalls gering. Wenn es eine Funktion gibt, die
rm -rf /aufruft, aber in Wirklichkeit niemals aufgerufen wird, und man dann einen Funktionszeigeraufruf mit undefiniertem Verhalten erzeugt, dürfte der Compiler technisch gesehen Code erzeugen, der diese Funktion zum Löschen der Platte bedingungslos aufruft. Letztlich ist das einfach schlechte Spezifikationsgestaltung und ein Altlastenproblem.for (int ii = 0; ii < something; ii++)kann die Möglichkeitsomething == INT_MAXignorieren, weil Überlauf bei vorzeichenbehafteten Ganzzahlen undefiniert ist, und das erlaubt verschiedene Loop-Transformationen.In Rust wird die entsprechende Funktionalität in sichere Funktionen und
unsafe-Funktionen aufgeteilt. Sichere Funktionen können etwas langsamer sein, undunsafe-Funktionen erlauben bei falscher Verwendung undefiniertes Verhalten. Siehei32::wrapping_add()undi32::unchecked_add().Wenn man in C bestimmte Funktionen als
unsafemarkieren und eine Notation hinzufügen würde, die die Verwendung vonunsafe-Funktionen nur in bestimmten Bereichen erlaubt, könnte man anfangen, sichere Varianten zu definieren. Aber irgendwann steht der Aufwand, C zu verändern – und noch wichtiger, die Denkweise der Leute zu ändern, die C kontrollieren – nicht mehr im Verhältnis zum Ziel, und dann ist es einfacher, gleich eine Sprache zu wählen, die besser zum Ziel passt.In C ist es undefiniertes Verhalten, wenn man einen Zeiger auf ein Heap-Objekt an
freeübergibt und danach auf dieses Objekt zugreift. CHERIoT definiert diesen Fall so, dass eine Trap ausgelöst wird, aber das ist nur möglich, weil wir die Hardware gebaut haben, die das unterstützt. Der Standard muss viele unterschiedliche Hardware-Plattformen unterstützen, daher stellt sich die Frage, wie man das überhaupt definieren sollte.Es gibt grob zwei Ansätze. Der eine ist, die Freigabe zu verzögern und festzulegen, dass ein Objekt nicht verschwindet, solange noch irgendwelche Zeiger darauf existieren. Das erfordert etwas in Richtung Garbage Collector und wäre für viele Einsatzbereiche von C ein untragbarer Overhead. Der andere ist, ein Typsystem zu definieren, das alle Orte kennt, an denen sich Zeiger auf das Objekt befinden, und diese ungültig machen kann. Rust geht diesen zweiten Weg, weshalb man in Rust für die Implementierung von Datenstrukturen, die keine Bäume sind,
unsafeoder Standardbibliotheksfunktionen braucht, dieunsafeverwenden. So etwas kann man in der Sprachentwurfsphase einbauen, nachträglich aber fast nicht mehr hinzufügen.Bei Grenzfehlern ist es ähnlich. In CHERI-Systemen sind die Grenzen eines Objekts oder Unterobjekts ein inhärenter Teil des Zeigers, daher führen Zugriffe außerhalb der Grenzen zu einer Trap. Auf anderen Plattformen ist ein Zeiger nur ein Wort, das eine Adresse enthält. Nach arithmetischen Operationen gibt es keine Möglichkeit mehr, ihn dem ursprünglichen Objekt zuzuordnen, also stellt sich die Frage, woher die Grenzen kommen sollen. Werkzeuge wie AddressSanitizer speichern Grenzen in separaten Strukturen und verlangen Prüfungen bei Zeigerarithmetik, aber der Speicher- und Performance-Overhead ist so groß, dass man in Produktionsumgebungen mit ASan in C deutlich schlechter fährt als einfach Java zu verwenden – und vermutlich schreibt man den Code dann auch schneller.
Ich dachte, das Dereferenzieren eines Nullzeigers sei wohldefiniertes Verhalten.
An diesem Artikel stört mich ein Punkt.
SEGFAULT ist ein Denial-of-Service-Angriff, genau wie ein Panic.
Beides gehört zur gleichen Fehlerkategorie, und wenn man an Memory Safety denkt, denkt man meist eher an Dinge wie Stack-Smashing, Datenkorruption oder Codekorruption. Solche Dinge sind in Rust sehr, sehr viel schwerer und lassen sich bis zu einem gewissen Grad auch in C erschweren.
Der ganze Artikel wirkte auf mich weitgehend wie eine Aussage darüber, dass das Typsystem von C miserabel ist. In C++ kann man solche Fehler verhindern, und auch in C kann man mit dem GCC-Attribut
nonnulldas Übergeben vonNULLan Funktionen zu einem Compilerfehler machen.Persönlich fände ich Out-of-Bounds-Zugriffe ein besseres und typischeres Beispiel.
Ein Panic ist eine im Programm eingebaute Sicherheitsprüfung, die zuverlässig ausgelöst wird und deren Verhalten klar definiert ist.
Ein Segfault bedeutet, dass das Betriebssystem eine ungültige Speicheroperation abgefangen hat, und zwar nur für Adressen außerhalb der Seiten in der virtuellen Speicherabbildung des Programms. Deshalb lassen sich viele Segfault-Bugs in irgendeine Form von beliebiger Codeausführung umformen.
Im Normalfall sehen die Ergebnisse vielleicht gleich aus, aber grundsätzlich sind das völlig unterschiedliche Dinge.