1 Punkte von GN⁺ 4 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • 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

 
GN⁺ 4 시간 전
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?

    • Ich denke, es gibt mindestens drei Gründe dafür, dass ein Verhalten im Standard undefiniert ist.
      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.
    • In dieser Überarbeitungsrunde reduziert das C-Komitee das undefinierte Verhalten der Sprache. Siehe die Dokumente zu „slaying earthly demons“ unter https://open-std.org/jtc1/sc22/wg14/www/wg14_document_log.htm
      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.
    • Die am häufigsten wiederholte Erklärung ist, dass man manches Verhalten undefiniert lassen muss, um Optimierungen zu ermöglichen, die sonst nicht zulässig wären. Ich halte das aber meist für eine Form der Rationalisierung.
      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.
    • Ein Teil des undefinierten Verhaltens wurde im Lauf der Zeit definiert, aber vieles muss wegen Optimierungen bestehen bleiben. Ein bekanntes Beispiel: for (int ii = 0; ii < something; ii++) kann die Möglichkeit something == INT_MAX ignorieren, 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, und unsafe-Funktionen erlauben bei falscher Verwendung undefiniertes Verhalten. Siehe i32::wrapping_add() und i32::unchecked_add().
      Wenn man in C bestimmte Funktionen als unsafe markieren und eine Notation hinzufügen würde, die die Verwendung von unsafe-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.
    • Hier ist ein Beispiel dafür, warum das schwierig ist.
      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, unsafe oder Standardbibliotheksfunktionen braucht, die unsafe verwenden. 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.

    • https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf Auf Seite 4, beziehungsweise Seite 18 im PDF, steht:
      1. Begriffe, Definitionen, Symbole

      3.5.3 Undefiniertes Verhalten

      Beispiel: Ein Beispiel für undefiniertes Verhalten ist das Verhalten beim Dereferenzieren eines Nullzeigers

    • Aus Sicht des CPU-Befehlssatzes mag das stimmen, aber das Ziel der Programmierung ist nicht dieser, sondern die abstrakte Maschine von C, und die abstrakte Maschine von C bezeichnet das als undefiniertes 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 nonnull das Übergeben von NULL an Funktionen zu einem Compilerfehler machen.
    Persönlich fände ich Out-of-Bounds-Zugriffe ein besseres und typischeres Beispiel.

    • Die Aussage „SEGFAULT ist ein Denial-of-Service-Angriff wie ein Panic“ stimmt nicht.
      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.