1 Punkte von GN⁺ 1 시간 전 | Noch keine Kommentare. | Auf WhatsApp teilen
  • Speichersicherheit verbessert sich deutlich, aber selbst in produktivem Rust-Code bleiben Probleme bei der Behandlung von Systemgrenzen bestehen und können zu Schwachstellen führen
  • Abläufe, bei denen derselbe Pfad über mehrere Syscalls hinweg erneut aufgelöst wird, Berechtigungen erst nach der Erstellung geändert werden oder stringbasierte Pfadvergleiche verwendet werden, führen leicht zu Problemen wie TOCTOU und Preisgabe von Rechten
  • Unter Unix werden Pfade, Umgebungsvariablen und Stream-Daten als rohe Bytes verarbeitet; daher können String-zentrierte Verarbeitung sowie from_utf8_lossy, unwrap und expect zu Datenbeschädigung oder DoS führen
  • Wenn Fehler verworfen werden, kann ein Fehlschlag wie ein Erfolg aussehen; auch Verhaltensunterschiede zu GNU coreutils können in Shell-Skripten und privilegierten Werkzeugen unmittelbar zu Sicherheitsproblemen führen
  • In diesem Audit wurden keine Bugs aus der Kategorie Speichersicherheit wie buffer overflow, use-after-free oder double-free gefunden; das verbleibende Hauptrisiko konzentrierte sich weniger auf Rust selbst als auf die Grenzen zur Außenwelt

Rusts Grenzen, wie sie im Audit sichtbar wurden

  • Die von Canonical veröffentlichten 44 CVEs in uutils zeigen, dass auch in produktivem Rust-Code Schwachstellen verbleiben können, die borrow checker, clippy und cargo audit nicht erfassen
  • Im Zentrum der Probleme stand weniger die Speichersicherheit als die Behandlung von Systemgrenzen
    • Zwischen Pfad und Syscall gab es Zeitlücken
    • Unix-Byte-Daten und UTF-8-Strings passten nicht zusammen
    • Es gab Verhaltensunterschiede zum Originalwerkzeug
    • Fehlerbehandlung fehlte oder endete in panic!
  • Diese CVE-Liste zeigt in komprimierter Form, wo Sicherheit in Rust endet

Wer einen Pfad zweimal auflöst, erzeugt TOCTOU

  • Wenn derselbe Pfad in einem Syscall geprüft und in einem folgenden Syscall erneut verwendet wird, führt das leicht zu einer TOCTOU-Schwachstelle
    • Zwischen den beiden Aufrufen kann ein Angreifer mit Schreibrechten auf das übergeordnete Verzeichnis Pfadbestandteile durch symbolische Links ersetzen
    • Beim zweiten Aufruf löst der Kernel den Pfad von Anfang an neu auf, sodass eine privilegierte Operation auf ein vom Angreifer gewähltes Ziel gelenkt wird
  • Die std::fs-API von Rust setzt standardmäßig auf erneute Auflösung auf Basis von &Path, was solche Fehler leicht macht
  • Bei CVE-2026-35355 wurde ein Ablauf ausgenutzt, bei dem nach dem Löschen einer Datei unter demselben Pfad eine neue Datei erstellt wurde
    • In src/uu/install/src/install.rs folgte auf fs::remove_file(to)? direkt File::create(to)?
    • Wenn to zwischen Löschen und Erstellen in einen symbolischen Link auf ein Ziel wie /etc/shadow geändert wird, kann ein privilegierter Prozess diese Datei überschreiben
  • Die Korrektur verwendet nun OpenOptions::create_new(true), um nur neue Dateien zu erstellen
    • Laut Dokumentation akzeptiert create_new am Zielort weder vorhandene Dateien noch dangling symlinks
  • Wenn auf demselben Pfad zweimal gearbeitet werden muss, ist es sicherer, am Dateideskriptor festzumachen
    • Außer beim Erzeugen neuer Dateien sollte man das Elternverzeichnis einmal öffnen und dann relativ zu diesem Handle arbeiten
    • Wenn auf demselben Pfad zweimal gearbeitet wird, sollte das als TOCTOU gelten, bis das Gegenteil bewiesen ist

Berechtigungen nicht nachträglich ändern, sondern beim Erstellen festlegen

  • Auch ein Ablauf, bei dem ein Verzeichnis oder eine Datei mit Standardrechten erzeugt und später per chmod geändert wird, schafft ein kurzes Offenlegungsfenster
    • Wenn man etwa fs::create_dir(&path)? und danach fs::set_permissions(&path, Permissions::from_mode(0o700))? schreibt, existiert path dazwischen mit Standardrechten
    • Andere Nutzer können in diesem Zeitfenster open() ausführen, und auch ein späteres chmod zieht bereits erhaltene Dateideskriptoren nicht zurück
  • Berechtigungen sollten im Moment der Erstellung mitgesetzt werden
    • Dafür sollten OpenOptions::mode() und DirBuilderExt::mode() verwendet werden, damit das Objekt mit den gewünschten Rechten entsteht
    • Der Kernel wendet zusätzlich die umask an; wenn deren Einfluss wichtig ist, muss auch sie explizit behandelt werden

Stringvergleich von Pfaden ist keine Dateisystem-Identität

  • Die anfängliche --preserve-root-Prüfung von chmod machte nur einen Stringvergleich
    • recursive && preserve_root && file == Path::new("/")
    • Eingaben, die tatsächlich auf das Wurzelverzeichnis zeigten, aber als String nicht / waren, konnten die Prüfung umgehen, etwa /../, /./, /usr/.. oder symbolische Links auf /
  • Die Korrektur vergleicht nun nach Auflösung des Pfads in seinen tatsächlichen absoluten Pfad per fs::canonicalize
    • PR mit der Korrektur
    • canonicalize liefert den tatsächlichen Pfad zurück, in dem .., . und symbolische Links aufgelöst sind
  • Im Fall von --preserve-root funktioniert das, weil / kein Elternverzeichnis hat
  • Wenn man allgemein prüfen will, ob zwei beliebige Pfade auf dasselbe Dateisystemobjekt zeigen, sollte man nicht Strings, sondern (dev, inode) vergleichen
    • GNU coreutils macht es ebenso
  • Bei CVE-2026-35363 wies rm zwar . und .. zurück, akzeptierte aber ./ und .///, sodass das aktuelle Verzeichnis gelöscht werden konnte
    • Wenn Unterschiede in der Eingabeform nur auf Stringebene behandelt werden, lassen sich Prüfungen leicht umgehen

An Unix-Grenzen Bytes vor Strings behandeln

  • Rusts String und &str sind immer UTF-8, aber Pfade, Umgebungsvariablen, Argumente und Stream-Daten unter Unix liegen in einer Welt aus rohen Bytes
  • Die falsche Wahl beim Überschreiten dieser Grenze führt zu zwei Arten von Bugs
    • Verlustbehaftete Konvertierungen wie from_utf8_lossy ersetzen ungültige Bytes durch U+FFFD und beschädigen Daten stillschweigend
    • Strikte Konvertierungen wie unwrap oder ? können Eingaben zurückweisen oder den Prozess beenden
  • CVE-2026-35346 in comm war ein Fall beschädigter Ausgabe durch verlustbehaftete Konvertierung
    • In src/uu/comm/src/comm.rs wurden die Eingabebytes ra und rb mit String::from_utf8_lossy umgewandelt und dann per print! ausgegeben
    • GNU comm reicht Bytes auch bei Binärdateien unverändert durch, aber uutils ersetzte ungültiges UTF-8 durch U+FFFD und beschädigte die Ausgabe
    • Die Korrektur bestand darin, rohe Bytes mit BufWriter und write_all unverändert nach stdout zu schreiben
  • print! erzwingt über Display einen UTF-8-Roundtrip, Write::write_all dagegen nicht
  • In Systemcode für Unix sollte man je nach Kontext den passenden Typ verwenden
    • Für Dateipfade Path und PathBuf
    • Für Umgebungsvariablen OsString
    • Für Stream-Inhalte Vec<u8> oder &[u8]
  • Wenn man der Formatierungsbequemlichkeit wegen über String geht, schleicht sich Datenbeschädigung leicht ein

Jedes panic kann zu Denial of Service führen

  • In CLI-Programmen können unwrap, expect, Slice-Indexing, ungeprüfte Arithmetik und from_utf8 zu DoS-Stellen werden, wenn Angreifer die Eingabe steuern können
    • panic! unwound den Stack und beendet den Prozess
    • Läuft das Programm in einem cron job, einer CI pipeline oder einem shell script, kann dadurch die gesamte Aufgabe stoppen
    • In Umgebungen mit wiederholten Neustarts kann eine Crash-Schleife sogar das ganze System lähmen
  • Bei CVE-2026-35348 in sort --files0-from brach das Programm ab, sobald es in einer NUL-getrennten Dateinamenliste auf einen nicht UTF-8-kodierten Dateinamen traf
    • Der Parser rief für die Bytes jedes Namens std::str::from_utf8(bytes).expect(...) auf
    • GNU sort behandelt Dateinamen wie der Kernel als rohe Bytes, uutils erzwang jedoch UTF-8 und beendete beim ersten nicht-UTF-8-Pfad den gesamten Prozess
  • In Code, der nicht vertrauenswürdige Eingaben verarbeitet, sollten unwrap, expect, Indexing und as-Casts als potenzielle CVEs gelten
    • Stattdessen sollte man ?, get, checked_* und try_from verwenden und echte Fehler an den Aufrufer weiterreichen
  • Vorgeschlagene clippy-Regeln für die CI sind:
    • unwrap_used
    • expect_used
    • panic
    • indexing_slicing
    • arithmetic_side_effects
  • In Testcode können solche Warnungen zu streng sein; sinnvoll ist daher eine Begrenzung im Bereich cfg(test)

Wenn Fehler verworfen werden, sieht Scheitern wie Erfolg aus

  • Einige CVEs entstanden dadurch, dass Fehler ignoriert wurden oder Fehlerinformationen verloren gingen
  • chmod -R und chown -R gaben über den gesamten Lauf hinweg nur den Exit-Code der letzten Datei zurück
    • Selbst wenn die Verarbeitung vieler früherer Dateien fehlschlug, konnte das Programm mit 0 enden, wenn die letzte Datei erfolgreich war
    • Skripte konnten dann fälschlich annehmen, die gesamte Operation sei erfolgreich abgeschlossen worden
  • dd rief zur Nachbildung des GNU-Verhaltens bei /dev/null auf dem Ergebnis von set_len() die Methode Result::ok() auf
    • Gedacht war das, um in einem begrenzten Spezialfall Fehler zu verwerfen, aber derselbe Code galt auch für normale Dateien
    • Selbst bei voller Festplatte konnte so still eine nur halb geschriebene Zieldatei zurückbleiben
  • Wenn Result per .ok(), .unwrap_or_default() oder let _ = verworfen wird, geht oft die entscheidende Ursache eines Fehlschlags verloren
  • Auch wenn man nicht beim ersten Fehler sofort abbricht, sollte man sich zumindest den schwersten Fehlercode merken und damit beenden
  • Wenn ein Result wirklich verworfen werden muss, sollte im Code vermerkt sein, warum dieser Fehler sicher ignoriert werden kann

Exakte Kompatibilität zum Originalwerkzeug ist ebenfalls eine Sicherheitsfunktion

  • Mehrere CVEs entstanden nicht dadurch, dass gefährliche Operationen ausgeführt wurden, sondern weil sich das Programm anders als GNU verhielt
    • Reale Shell-Skripte hängen vom Verhalten des ursprünglichen GNU-Tools ab, und semantische Unterschiede können zu Sicherheitsproblemen werden
  • Ein typisches Beispiel ist CVE-2026-35369 bei kill -1
    • GNU interpretiert -1 als signal 1 und verlangt zusätzlich eine PID
    • uutils deutete dies als Senden des Standard-Signals an PID -1
    • Unter Linux bedeutet PID -1 alle sichtbaren Prozesse, sodass ein einfacher Tippfehler ein Kill des gesamten Systems auslösen kann
  • Bei neu implementierten Werkzeugen ist bug-for-bug-Kompatibilität eine Schutzmaßnahme, die Exit-Codes, Fehlermeldungen, Edge Cases und die Bedeutung von Optionen einschließt
  • An jeder Stelle mit anderem Verhalten gegenüber GNU steigt die Wahrscheinlichkeit, dass Shell-Skripte falsche Entscheidungen treffen
  • uutils führt nun in der CI auch die upstream GNU coreutils testsuite aus
    • Das wirkt als passende Verteidigung gegen genau solche Abweichungen

Vor dem Überschreiten einer Vertrauensgrenze zuerst auflösen

  • CVE-2026-35368 war eine local root code execution in chroot
  • Das Muster des Problems war, dass nach chroot(new_root)? ein Benutzername innerhalb der vom Angreifer kontrollierten neuen Root aufgelöst wurde
    • get_user_by_name(name)? las zur Namensauflösung Shared Libraries aus dem Dateisystem der neuen Root
    • Wenn ein Angreifer innerhalb des chroot Dateien platzierte, konnte das zu Codeausführung mit uid 0 führen
  • GNU chroot löst den Benutzer vor dem chroot auf
    • Die Korrektur änderte die Reihenfolge entsprechend
  • Sobald man eine Vertrauensgrenze überschritten hat, kann praktisch jeder Bibliotheksaufruf Angreifercode ausführen
  • Auch statisches Linken verhindert dieses Problem nicht
    • get_user_by_name läuft über NSS und kann zur Laufzeit libnss_*-Module per dlopen laden

Welche Bugs Rust tatsächlich verhindert hat

  • Es ist ebenso klar, welche Bug-Arten im Audit nicht gefunden wurden
    • Kein buffer overflow
    • Kein use-after-free
    • Kein double-free
    • Keine data race in gemeinsam veränderbarem Zustand
    • Keine null-pointer dereference
    • Kein uninitialized memory read
  • Auch wenn die Werkzeuge Bugs hatten, tauchten im Audit keine Fälle auf, die sich zu beliebigem Speicherlesen ausnutzen ließen
  • GNU coreutils hat in den letzten Jahren fortlaufend solche CVE-Klassen der Speichersicherheit produziert
    • pwd deep path buffer overflow
    • numfmt out-of-bounds read
    • unexpand --tabs heap buffer overflow
    • od --strings -N NUL-Schreiben außerhalb des heap buffer
    • sort 1-Byte-Read vor dem heap buffer
    • split --line-bytes heap overwrite in CVE-2024-0684
    • b2sum --check read aus nicht alloziertem Speicher bei malformed input
    • tail -f stack buffer overrun
  • Im Vergleich über denselben Zeitraum blieb die Rust-Neuimplementierung bei 0 Bugs dieser Kategorie
    • Allerdings mit dem Vorbehalt, dass das Audit die Abwesenheit von Speichersicherheitsfehlern nicht bewiesen hat, sondern sie nur nicht gefunden wurden
  • Die verbleibenden Probleme entstehen überwiegend nicht in Rust selbst, sondern an den Grenzen zur Außenwelt
    • Pfade
    • Bytes und Strings
    • Syscalls
    • Zeitlücken und Änderungen des Dateisystemzustands

Korrektes Rust ist auch idiomatisches Rust

  • Idiomatisches Rust ist mehr als Code, der den borrow checker passiert und bei dem clippy schweigt
  • Korrektheit muss ebenfalls Teil der Idiomatik sein
    • Denn die Formen von Code, die in der Realität bestehen, haben sich aus der Erfahrung der Community heraus verfestigt
  • Robuste Systeme sollten die Unordnung der Realität nicht verbergen, sondern direkt abbilden
    • Dateideskriptoren statt Pfaden
    • OsStr statt String
    • ? statt unwrap
    • bug-for-bug-Kompatibilität mit dem Original statt semantisch „sauberer“ wirkender Abweichungen
  • Das Typsystem kann vieles ausdrücken, aber nicht Bedingungen außerhalb seiner Kontrolle wie den Zeitablauf zwischen zwei Syscalls
  • Idiomatisches Rust sollte in Typen, Namen und Kontrollfluss die Wahrheit der Laufzeitumgebung sichtbar machen
    • Selbst wenn das weniger elegant aussieht als schöner Whiteboard-Code, braucht es die ehrlichere Form

Referenzen

Noch keine Kommentare.

Noch keine Kommentare.