Das USN Journal im Browser parsen mit Rust + WebAssembly

Wie wir einen vollständigen NTFS-USN-Journal-Parser als 105 KB WebAssembly in Ihren Browser ausliefern — und warum 'client-seitig parsen' die einzig akzeptable Antwort für forensische Artefakte ist.

Von 4 Min. Lesezeit

Ein USN Journal ist eine der sensibelsten Dateien, die ein Analyst je anfasst. Ein 100 MB großes $J von einer Unternehmens-Workstation ist eine vollständige aktuelle Historie dieser Maschine: jedes Dokument, das der Benutzer geöffnet hat, jede ausführbare Datei, die lief, jede Cache-Eviction, jedes Save-by-Rename in seinem Editor. Einen forensischen Profi zu bitten, das an einen SaaS-Endpunkt hochzuladen, damit der Server eines Anderen es parst, ist die Art von Anfrage, die das SaaS aus dem Toolset entfernen sollte, nicht das Journal hochladen. Wir wollten nie dieses SaaS sein.

Also macht diese Seite das Umgekehrte: Der Parser läuft in Ihrem Browser. Das Journal wird von der Festplatte in JavaScript gelesen, an ein WebAssembly-Modul übergeben, und die Einträge kommen zurück, ohne dass ein einziges Byte die Maschine verlässt. Dieser Beitrag durchläuft, wie das tatsächlich funktioniert, die Rust-Crate, auf die wir uns stützen, die drei Cargo-Stolperfallen, die einen Abend gekostet haben, und die Zahlen für eine repräsentative Datei.

Die Rust-Crate

Keine Neuerfindung. Die Parsing-Logik kommt von usnrs, Airbus CERTs sauberer USN_RECORD_V2-Implementierung. Sie stellt bereits eine Read + Seek-Schnittstelle bereit, was genau dem entspricht, was std::io::Cursor<Vec<u8>> Ihnen gibt, wenn Sie rohe Bytes im Speicher haben — genau die Form eines Uint8Array, das von der Browser-Seite ankommt.

Die Wrapper-Crate hat etwa 60 Zeilen Rust. Der Einstiegspunkt ist:

#[wasm_bindgen(js_name = parseUsn)]
pub fn parse_usn(
  usn_bytes: &[u8],
  mft_bytes: Option<Box<[u8]>>,
) -> Result<JsValue, JsValue> {
  let usn = Cursor::new(usn_bytes.to_vec());
  let mft = mft_bytes
    .map(|b| MftParser::from_buffer(b.into_vec()))
    .transpose()?;
  let iter = Usn::new(mft, usn, None)?;
  let records: Vec<UsnRecord> = iter.map(into_record).collect();
  serde_wasm_bindgen::to_value(&records)
}

Es nimmt die Journal-Bytes und optional die $MFT-Bytes für die Vollpfad-Auflösung. Mit wasm-opt ist das endgültige .wasm-Artefakt etwa 105 KB groß.

Drei Cargo-Stolperfallen

Das saubere Kompilieren von usnrs plus seinen transitiven Abhängigkeiten für wasm32-unknown-unknown ist nicht gratis. Drei Dinge haben uns gebissen:

getrandom braucht das js-Feature auf wasm32. Die rand-Crate (von mft transitiv hereingezogen) hängt davon ab, und ohne das JS-Backend bricht der wasm-Build mit "no available getrandom backend" ab. Erzwingen Sie es in Cargo.toml:

[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }

chrono braucht wasmbind, wenn das clock-Feature aktiviert ist. Ohne es versucht chrono, time(2) aufzurufen, was es in wasm32-unknown-unknown nicht gibt. Fügen Sie features = ["wasmbind"] zur direkten Abhängigkeitsdeklaration hinzu.

mft-Default-Features. Das mft_dump-Feature zieht CLI-Abhängigkeiten herein, die sich zwar einwandfrei kompilieren lassen, aber das wasm-Artefakt aufblähen. Wir deaktivieren die Defaults und reaktivieren nur, was wir brauchen.

Keine davon erforderte einen Fork. Zwei Zeilen Cargo.toml lösen das Ganze.

Der browser-seitige Glue-Code

Der Build ist wasm-pack build --target web --out-dir public/wasm, der einen kleinen ES-Modul-JS-Shim und die .wasm-Binärdatei produziert. Beide liegen unter /public/wasm/ und werden als statische Assets unter bekannten URLs ausgeliefert.

Der Parser läuft in einem Web Worker, damit der Main Thread responsiv bleibt, während das wasm-Modul eine Million Einträge durchkaut:

// public/workers/parse.js
import init, { parseUsn } from "/wasm/usn_wasm.js";

await init();
self.onmessage = (event) => {
  const { usnBytes, mftBytes } = event.data;
  const records = parseUsn(
    new Uint8Array(usnBytes),
    mftBytes ? new Uint8Array(mftBytes) : null,
  );
  self.postMessage({ type: "result", records });
};

Das ist die einzige Stelle, an der sich das wasm-Modul und der Worker treffen. Keines geht durch den Next.js-Bundler. Keine Webpack- oder Turbopack-Konfiguration wurde beschädigt.

Zahlen von einer repräsentativen Datei

Ein 60 MB großes $J von einer Windows-11-Workstation, auf einem aktuellen Macbook:

  • Parse-Zeit: ~1,4 Sekunden.
  • Speicher: transient, freigegeben, wenn der Worker beendet wird.
  • Produzierte Einträge: ~720.000.
  • Bytes, die die Maschine über die Leitung verlassen: 0. Im Network-Tab bestätigen.

Die UI virtualisiert die Ergebnistabelle dann mit TanStack Virtual, sodass eine Tabelle mit einer Million Zeilen sich immer noch sofort anfühlt, wenn Sie scrollen oder filtern.

Was ist mit wirklich großen Journals

Für Journals jenseits von 500 MB würden wir auf eine Streaming-API umsteigen, die Batches von Einträgen liefert, statt sie in einem Vec zu sammeln. Die Änderung ist klein — Usn ist bereits ein Iterator; wir würden next_batch(n) über wasm-bindgen freigeben. Wir haben das nicht ausgeliefert, weil noch niemand an diese Grenze gestoßen ist. Wenn Sie es tun, öffnen Sie ein Issue.

Eine zweite Optimierung, die wir bewusst weggelassen haben: direktes Parsen von einem File-Handle über die Stream-APIs des Browsers. Das wasm-Modul müsste eine ReadableStream-artige Schnittstelle akzeptieren, was bedeuten würde, Cursor<Vec<u8>> zu verlieren. Die Komplexität ist den Gewinn für Journals unter 500 MB noch nicht wert.

Warum dieser Ansatz wichtig ist

Forensische Werkzeuge haben sich historisch in zwei Lager gespalten. Schwere Desktop-Suites (X-Ways, EnCase, FTK), die Sie lizenzieren, auf einer Workstation installieren und vertrauen. Python-Skripte, die Sie pip install und auf welchem Endpoint auch immer ausführen, oft mit sensiblen Daten, die unterwegs durch IDE-Temp-Verzeichnisse wandern.

WebAssembly öffnet eine dritte Spur: offen, inspizierbar, läuft vollständig im Browser, kein Upload. Eric Zimmermans Werkzeuge belegen den Hoch-Vertrauens-Offline-Desktop-Slot. Plaso und der libyal-Stack belegen den scriptable-Pipeline-Slot. Der Browser-Slot war zu lange leer, weil niemand glaubte, dass er bei der Geschwindigkeit mit den anderen mithalten könnte. Mit wasm und einer vernünftigen Crate darunter ist diese Ausrede weg — für USN, für EVTX, für Prefetch, für jedes binäre Windows-Artefakt, das ein Verteidiger tatsächlich anfasst.

Weiterführende Literatur

  • Das usnrs-Repo — die Upstream-Rust-Crate. Das Lesen von src/lib.rs ist der kürzeste Weg, die USN_RECORD_V2-Parser-Schnittstelle zu verstehen.
  • Das wasm-bindgen-Buch — die Referenz für das FFI zwischen Rust und JavaScript.
  • Olaf Hartongs Sysmon-modular — nichts mit wasm zu tun, aber die Art von Artefakt, das, gepaart mit einem geparsten USN Journal, Ihnen das vollständige Bild der Dateiaktivität auf einem Host gibt.