2 Punkte von GN⁺ 2025-02-10 | 1 Kommentare | Auf WhatsApp teilen
  • Um zu zeigen, dass sich auch Windows-Kerneltreiber in Rust schreiben lassen, wird der Booster-WDM-Treiber implementiert, der die Priorität eines beliebigen Threads ändert
  • Die Build-Umgebung muss WDK oder EWDK, LLVM/Clang, die WDK-Crates auf Basis von windows-drivers-rs sowie die passende Konfiguration von build.rs und Cargo.toml zusammenbringen
  • Im Kernel-Space kann die Standardbibliothek nicht verwendet werden, daher müssen #![no_std], der WDK-Allocator, ein Panic-Handler und unsafe-FFI-Aufrufe kombiniert werden
  • Der Treiber erstellt \\Device\\Booster und \\??\\Booster und ändert mit den in IRP_MJ_WRITE empfangenen ThreadData die Thread-Priorität im Bereich 1 bis 31
  • Das Schreiben von Windows-Kerneltreibern in Rust ist möglich, aber die WDK-Crates befinden sich erst auf Stand 0.3, daher sind sicherere Wrapper und weniger unsafe-Code nötig

Booster-WDM-Treiber in Rust implementiert

  • Das Beispiel ist eine Rust-Portierung des in der Windows-Kernelprogrammierung verwendeten Treibers „Booster“
    • Ziel ist es, die Priorität eines beliebigen Threads auf einen gewünschten Wert zu setzen
    • Das Treibermodell ist WDM
  • Das Rust-Ökosystem bietet Speichersicherheit zur Compile-Zeit, Sicherheit bei Nebenläufigkeit, das Build-System cargo und ein Crate-Ökosystem
  • Wenn C-Typen in Rust direkt behandelt werden, kann der Code sehr ausführlich werden
    • Mit geeigneten Wrappern und Makros lässt sich dieser Aufwand verringern

Build-Vorbereitung und Cargo-Konfiguration

  • Für die Vorbereitung des Treiber-Builds sollte man sich an Windows Drivers-rs orientieren und WDK oder EWDK installieren
  • Auch LLVM muss installiert werden, um Zugriff auf den Clang-Compiler zu haben
  • Es wird ein neues Rust-Bibliotheksprojekt angelegt; ein Kerneltreiber ist technisch gesehen eine DLL, die in den Kernel-Space geladen wird
cargo new --lib booster
  • build.rs konfiguriert cargo so, dass statisch gegen die CRT gelinkt wird, und richtet den WDK-Binary-Build ein
fn main() -> Result<(), wdk_build::ConfigError> {
    std::env::set_var("CARGO_CFG_TARGET_FEATURE", "crt-static");
    wdk_build::configure_wdk_binary_build()
}
  • In Cargo.toml werden das WDM-Treibermodell, der Crate-Typ cdylib und die WDK-bezogenen Abhängigkeiten eingetragen
    • Wichtige Crates sind wdk, wdk-macros, wdk-alloc, wdk-panic und wdk-sys
    • In den dev-/release-Profilen werden panic = "abort" und lto = true gesetzt
    • wdk und wdk-sys bieten optional ein nightly-Feature

Kernel-Code ohne Standardbibliothek schreiben

  • Im Kernel gibt es keine Rust-Standardbibliothek, daher wird #![no_std] verwendet
  • wdk_sys übernimmt die Interoperabilität mit Kernel-Funktionen auf niedriger Ebene, während wdk Wrapper auf höherer Ebene bereitstellt
  • Vec und String wirken wie Teile der Standardbibliothek, tatsächlich können aber Typen aus dem Modul alloc verwendet werden
    • Dafür wird ein globaler Allocator benötigt
    • Der von den WDK-Crates bereitgestellte WdkAllocator wird als #[global_allocator] festgelegt
  • WdkAllocator verwaltet Allokationen über ExAllocatePool2 und ExFreePool
  • Da die Standardbibliothek fehlt, werden zusätzlich die externen Crates wdk_panic und alloc für Allocator-Unterstützung und Panic-Handler eingebunden

DriverEntry und Geräteinitialisierung

  • Der Einstiegspunkt eines Windows-Kerneltreibers ist DriverEntry
    • Der Rust-Funktionsname bleibt konventionsgemäß driver_entry, und mit #[export_name = "DriverEntry"] wird der vom Linker gesuchte Name festgelegt
  • Das Makro println! ist als Aufruf von DbgPrint neu implementiert und kann daher wie DbgPrint in C/C++ für Kernel-Debug-Ausgaben verwendet werden
  • UNICODE_STRING wird von println! nicht direkt unterstützt, daher wird er mit der Funktion unicode_to_string in einen Rust-String umgewandelt
  • Das Geräteobjekt wird mit IoCreateDevice unter \\Device\\Booster erzeugt
    • Bei einem Fehler wird der Fehlerstatus ausgegeben und der entsprechende NTSTATUS zurückgegeben
    • Für die Erfolgsprüfung wird mit nt_success ein Gegenstück zum WDK-Makro NT_SUCCESS verwendet
  • Damit das Gerät über einen normalen CreateFile-Aufruf geöffnet werden kann, wird mit IoCreateSymbolicLink ein symbolischer Link \\??\\Booster erstellt
    • Wenn das Erstellen des symbolischen Links fehlschlägt, wird das Geräteobjekt gelöscht und der Fehlerstatus zurückgegeben
  • Das Geräteobjekt wird für Buffered I/O konfiguriert
    • DriverUnload wird auf boost_unload gesetzt
    • IRP_MJ_CREATE und IRP_MJ_CLOSE werden von boost_create_close verarbeitet
    • IRP_MJ_WRITE wird von boost_write verarbeitet
  • Das Vorhandensein von Callbacks wird in Rust mit dem Typ Option<> dargestellt

Anfragen verarbeiten und Thread-Priorität ändern

  • Die Unload-Routine räumt symbolischen Link und Geräteobjekt mit IoDeleteSymbolicLink und IoDeleteDevice auf
  • Die Verarbeitung von IRP_MJ_CREATE und IRP_MJ_CLOSE ist einfach
    • IoStatus.Status des IRP wird auf STATUS_SUCCESS gesetzt
    • IoStatus.Information wird auf 0 gesetzt
    • Die Anfrage wird mit IofCompleteRequest abgeschlossen
  • IoStatus ist ein IO_STATUS_BLOCK; beim Zugriff auf Status muss über ein automatisch generiertes Union-Mitglied gegangen werden, was den Code unschön macht
    • Die entsprechende Definition muss noch genauer geprüft werden
  • Die eigentliche Prioritätsänderung erfolgt im Handler für IRP_MJ_WRITE
  • Die Struktur, die der Client an den Treiber übergibt, verwendet #[repr(C)], damit das Speicherlayout mit C/C++ übereinstimmt
#[repr(C)]
struct ThreadData {
    pub thread_id: u32,
    pub priority: i32,
}
  • boost_write interpretiert den über Buffered I/O übergebenen SystemBuffer als Zeiger auf ThreadData
  • Die Fehlerprüfung umfasst unter anderem folgende Bedingungen
    • Ist der Datenzeiger null, wird STATUS_INVALID_PARAMETER verwendet
    • Ist die Priorität kleiner als 1 oder größer als 31, wird STATUS_INVALID_PARAMETER verwendet
  • Mit PsLookupThreadByThreadId wird das Thread-Objekt gesucht
    • Falls das fehlschlägt, existiert die betreffende Thread-ID möglicherweise nicht, und die Verarbeitungsschleife wird verlassen
  • Wenn der Thread gefunden wird, setzt KeSetPriorityThread die Priorität, und mit ObfDereferenceObject wird die Referenz freigegeben
  • Beim Abschluss der Anfrage werden Status- und Informationsfeld des IRP gesetzt und anschließend IofCompleteRequest aufgerufen

Signierung, Installation, Test

  • Wenn eine INF- oder INX-Datei vorhanden ist, scheinen die Crates die Treibersignierung zu unterstützen; dieses Beispiel verwendet jedoch keine INF, daher ist eine manuelle Signierung nötig
  • Im Projekt-Root lassen sich die Build-Artefakte mit folgendem Befehl signieren
signtool sign /n wdk /fd sha256 target\debug\booster.dll
  • /n wdk verwendet das WDK-Testzertifikat, das Visual Studio beim Treiber-Build normalerweise automatisch erzeugt
  • Die Dateiendung des Build-Artefakts ist DLL
    • Derzeit gibt es im cargo build-Prozess keine automatische Möglichkeit, die Endung zu ändern
    • Mit INF/INX würde die Endung in SYS geändert
    • Die Endung kann man selbst ändern oder die DLL unverändert lassen
  • Auf einem System mit aktiviertem Test Signing kann der Treiber in einer Eingabeaufforderung mit Administratorrechten per sc.exe wie ein Softwaretreiber installiert werden
sc.exe sc create booster type= kernel binPath= c:\path_to_driver_file
sc.exe start booster
  • Als Test-Client wird die vorhandene C++-Anwendung verwendet
    • Sie öffnet das Gerät mit CreateFile(L"\\\\.\\Booster", GENERIC_WRITE, ...)
    • Sie schreibt Thread-ID und Priorität in ThreadData und übergibt diese per WriteFile
    • Im Beispiel wird getestet, die Priorität des Threads mit der ID 9408 auf 26 zu setzen

Offene Punkte und Referenzen

  • Das Schreiben von Kerneltreibern in Rust ist möglich
  • Die WDK-Crates befinden sich erst in Version 0.3 und bieten noch Raum für Verbesserungen
  • Um die Vorteile von Rust wirklich zu nutzen, werden mehr sichere Wrapper benötigt
    • Der Code sollte weniger ausführlich sein
    • Die Zahl der unsafe-Blöcke sollte sinken
    • Die von Rust gebotenen Sicherheitsvorteile sollten besser ausgeschöpft werden
  • Ein KMDF-Rust-Treiberbeispiel findet sich unter Windows-rust-driver-samples
  • Den Beispielcode gibt es im Repository Booster
  • Lernmaterial zu Rust findet sich unter https://trainsec.net

1 Kommentare

 
GN⁺ 2025-02-10
Meinungen auf Hacker News
  • Ich hatte einmal vor, einen Dateisystem-Filtertreiber zu bauen, mit dem sich Regeln zur Pfad-Neuzuordnung pro Anwendung festlegen lassen.
    Zum Beispiel etwa %userprofile%\.vscode -> %appdata%\vscode oder %CSIDL_MYDOCUMENTS%\Call of Duty -> %userprofile%\Saved Games\Call of Duty.
    Es hat mich so wütend gemacht, dass der Dokumente-Ordner und das Home-Verzeichnis voller Kram sind, der schon festgelegte Ablageorte hat, dass ich ein Rust-Gerüst für ein Filtertreiber-Projekt gebaut und die Minifilter-Dokumentation gelesen habe. Als ich den Arbeitsaufwand sah, habe ich aber aufgegeben.
    Ich habe mich damit abgefunden, dass Windows-Systeme am Ende zwangsläufig voller Müll sind.

    • Windows ist nicht das einzige Betriebssystem, das das Benutzer-Dateisystem wie eine Müllhalde benutzt.
      Apple verteilt weiterhin überall Dateien wie .DS_Store, .fseventsd und ._xxxx, egal wie oft man sie löscht.
      Trotzdem hat macOS im Großen und Ganzen jeweils einen Ort für installierte Anwendungen und für Benutzerdokumente, und die meisten Apps halten sich einigermaßen daran.
      Dafür gibt es mit ~/Library eine ausgewiesene Müllhalde, in der jede Menge Kram liegt, bei dem ich nicht weiß, ob ich ihn brauche.
    • Mein Documents-Ordner sah merkwürdig leer aus; dann stellte sich heraus, dass es Shortcut to Documents (OneDrive - Personal) war.
      Jetzt wird dieser ganze Müll sogar zwischen Geräten synchronisiert – großartig.
    • Das geht auch im User Mode auf mehrere Arten.
      Die Detours-Bibliothek erlaubt es, beim Start eines Prozesses eine eigene DLL einzuhängen und Win32-Aufrufe der Dateisystem-APIs zu hooken.
      Die eingebauten „Compatibility Shims“ funktionieren ähnlich, auch wenn sie kaum dokumentiert sind: Man kann Kompatibilitäts-Flags setzen und Datei- sowie Registry-Pfade umleiten.
      Praktisch ist auch, dass sie dafür ausgelegt sind, heuristikbasiert nur für bestimmte EXE-Dateien auszulösen.
      Zur Unterstützung von Technologien wie App-V und später Docker-Containern hat Windows eine API-Oberfläche zur Virtualisierung des Dateisystems, der Registry und anderer NT-Kernel-Namespaces.
      Außerdem gibt es noch User Mode Filesystem, und vermutlich weitere Ansätze, die ich nicht kenne oder vergessen habe.
    • Ich ignoriere die Giftmülldeponie namens My Documents komplett und lege Dateien, die mir wirklich wichtig sind, an einem anderen Ort mit kürzerem Pfad ab.
      Ein oder zwei Programme, die den Speicherort ihrer Daten hart codiert haben, habe ich möglicherweise per Directory Junction umgeleitet.
    • Microsoft/Windows selbst legt auch jede Menge Kram im Home-Ordner ab, und schlimmer noch: Die Namen sind viel zu lang.
      Dinge wie ntuser.ini, ntuser.dat.LOG1, ntuser.dat.LOG2, NTUSER.DAT, NTUSER.DAT{b2352f18-cdbf-1122-8680-002248483d79}.TM.blf, NTUSER.DAT{b2352f18-cdbf-1122-8680-002248483d79}.TMContainer00000000000000000001.regtrans-ms, NTUSER.DAT{b2352f18-cdbf-1122-8680-002248483d79}.TMContainer00000000000000000002.regtrans-ms.
  • Halbwegs verwandt: Ich frage mich, ob es aktuelle Informationen zum Stand von Rust im Windows-Kernel gibt.
    Vor fast zwei Jahren hieß es „36.000 Zeilen Code, einschließlich Systemaufrufen“ [1], und ich würde gern wissen, wie dieses Projekt vorangekommen ist.
    [1] https://www.thurrott.com/windows/282471/microsoft-is-rewriti...

  • Interessant. Das sieht ziemlich anders aus als die Embedded-Treiber, mit denen ich in Rust viel gearbeitet habe.
    Dort geht es meist um Register lesen und schreiben, Bit-Shifts, DMA und Datenblätter.

  • Der Code hier wirkt praktisch wie C mit anderer Syntax. Jede Funktion ist als unsafe markiert, und alle Ressourcen werden manuell verwaltet.
    Entschuldigung für die Direktheit, aber so verstehe ich nicht, worin der Sinn von Rust liegen soll.

    • Und ich weiß nicht, wie ich das übersehen konnte, aber der Versuch, den von string_to_ustring zurückgegebenen UNICODE_STRING zu verwenden, ist ein eindeutiger Use-after-free.
      Wenn man Windows-Kernel-Code schreiben will, sollte man hier nicht anfangen.
    • Leider bestehen einfache Beispiele im Grunde vollständig aus Boilerplate, und dabei berührt man überall unsichere native Schnittstellen.
      Bei einem Treiber, der tatsächlich etwas Interessantes tut, könnte man die unsafe-Interfaces auf die Ränder beschränken und das Innere in typischeren Rust-Code schreiben.
    • Außerdem gibt es hier Unmengen von Typnamen in Großbuchstaben.
      Will man wirklich alle Standard-Namenskonventionen von Rust über Bord werfen und die alte Windows-Gewohnheit übernehmen, den Typ im Namen auszudrücken?
  • Vor etwa 25 Jahren musste ich einen bestimmten Treiber für Windows schreiben.
    Damals war ich schon komplett auf Linux umgestiegen und wollte Windows weder zum Schreiben noch zum Bauen verwenden, also habe ich ziemlich viel Aufwand betrieben, damit er mit MSYS gebaut werden konnte.
    Am Ende hat es funktioniert, und der Treiber lief auch einwandfrei.
    Damit er tatsächlich geladen wurde, musste ich auf die erzeugte PE-Datei (.sys) wohl noch einen Patcher anwenden.
    Das waren interessante Zeiten.

  • Der Artikel ist gut, aber das Blog-Design hat mich noch mehr beeindruckt.
    Es ist sauber, intuitiv, angenehm für die Augen und lädt sofort.

    • Ging mir genauso. Ich musste noch einmal nachsehen, welche Technik hinter dem Blog steckt.
      Es sieht nach WordPress.com aus.
      Offenbar verlässt es sich stark auf Caching und CDN.
      Jetzt bedauere ich, dass ich meinen WordPress-Blog vor zehn Jahren nicht weitergeführt habe.