Parsare il USN Journal nel browser con Rust + WebAssembly

Come consegniamo un parser completo di USN Journal NTFS nel tuo browser come 105 KB di WebAssembly, e perché 'parsarlo lato client' è l'unica risposta accettabile per gli artefatti forensi.

Di 5 min di lettura

Un USN journal è uno dei file più sensibili che un analista toccherà mai. Un $J da 100 MB di una workstation aziendale è una storia recente completa di quella macchina: ogni documento che l'utente ha aperto, ogni eseguibile che è girato, ogni eviction di cache, ogni save-by-rename nel suo editor. Chiedere a un professionista della forense di caricare quella roba su un endpoint SaaS perché il server di qualcun altro lo parsi è il tipo di richiesta che dovrebbe togliere il SaaS dal toolset, non caricare il journal. Non abbiamo mai voluto essere quel SaaS.

Quindi questo sito fa l'inverso: il parser gira nel tuo browser. Il journal viene letto da disco in JavaScript, passato a un modulo WebAssembly, e i record tornano senza che un singolo byte lasci la macchina. Questo post percorre come funziona davvero, la crate Rust su cui ci siamo appoggiati, i tre tranelli di Cargo che sono costati una serata, e i numeri su un file rappresentativo.

La crate Rust

Nessuna reinvenzione. La logica di parsing arriva da usnrs, l'implementazione pulita di USN_RECORD_V2 di Airbus CERT. Espone già un'interfaccia Read + Seek, che è esattamente ciò che std::io::Cursor<Vec<u8>> ti dà quando hai byte grezzi in memoria — esattamente la forma di un Uint8Array che arriva dal lato browser.

La crate wrapper è circa 60 righe di Rust. L'entry point è:

#[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)
}

Prende i byte del journal e, opzionalmente, i byte di $MFT per la risoluzione del percorso completo. Con wasm-opt l'artefatto .wasm finale è circa 105 KB.

Tre tranelli di Cargo

Compilare usnrs più le sue dipendenze transitive pulitamente per wasm32-unknown-unknown non è gratis. Tre cose ci hanno morso:

getrandom necessita la feature js su wasm32. La crate rand (tirata in da mft transitivamente) dipende da essa, e senza il backend JS la build wasm fallisce con "no available getrandom backend". Forzala in Cargo.toml:

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

chrono necessita wasmbind quando la feature clock è abilitata. Senza, chrono prova a chiamare time(2), che non esiste in wasm32-unknown-unknown. Aggiungi features = ["wasmbind"] alla dichiarazione diretta della dipendenza.

Feature di default di mft. La feature mft_dump tira in dipendenze CLI che alla fine cross-compilano bene, ma gonfiano l'artefatto wasm. Disabilitiamo i default e riabilitiamo solo ciò che ci serve.

Nessuna di queste ha richiesto di fare fork di niente. Due righe di Cargo.toml risolvono il tutto.

La colla lato browser

La build è wasm-pack build --target web --out-dir public/wasm, che produce un piccolo shim JS modulo ES e il binario .wasm. Entrambi vivono sotto /public/wasm/ e sono serviti come asset statici a URL noti.

Il parser gira in un Web Worker così il main thread resta reattivo mentre il modulo wasm mastica un milione di record:

// 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 });
};

Questo è l'unico posto in cui il modulo wasm e il worker si incontrano. Nessuno dei due passa per il bundler Next.js. Nessuna configurazione webpack o Turbopack è stata danneggiata.

Numeri su un file rappresentativo

Un $J da 60 MB di una workstation Windows 11, su un Macbook recente:

  • Tempo di parsing: ~1,4 secondi.
  • Memoria: transitoria, liberata quando il worker viene terminato.
  • Record prodotti: ~720.000.
  • Byte che lasciano la macchina sulla rete: 0. Conferma nella tab Network.

La UI poi virtualizza la tabella dei risultati con TanStack Virtual, quindi una tabella da un milione di righe rimane istantanea quando scrolli o filtri.

E i journal davvero grossi?

Per journal oltre i 500 MB passeremmo a un'API streaming che produce batch di record invece di accumularli in un Vec. Il cambio è piccolo: Usn è già un Iterator; esporremmo next_batch(n) su wasm-bindgen. Non l'abbiamo spedito perché nessuno ha colpito quel muro. Se lo fai, apri una issue.

Una seconda ottimizzazione che abbiamo deliberatamente lasciato fuori: parsare direttamente da un handle File via le API stream del browser. Il modulo wasm dovrebbe accettare un'interfaccia tipo ReadableStream, il che significa perdere Cursor<Vec<u8>>. La complessità non vale ancora il guadagno per journal sotto i 500 MB.

Perché questo approccio conta

Il tooling forense si è storicamente diviso in due campi. Suite desktop pesanti (X-Ways, EnCase, FTK) che licenzi, installi su una workstation e di cui ti fidi. Script Python che fai pip install e fai girare su qualsiasi endpoint capiti a tiro, spesso con dati sensibili che attraversano le directory temp dell'IDE per strada.

WebAssembly apre una terza corsia: aperto, ispezionabile, gira interamente nel browser, niente upload. Gli strumenti di Eric Zimmerman occupano lo slot desktop offline ad alta fiducia. Plaso e lo stack libyal occupano lo slot pipeline scriptabile. Lo slot browser è rimasto vuoto troppo a lungo perché nessuno credeva potesse pareggiare gli altri in velocità. Con wasm e una crate sensata sotto, quella scusa è andata — per USN, per EVTX, per Prefetch, per ogni artefatto binario Windows che un difensore tocchi davvero.

Letture aggiuntive

  • Il repo usnrs — la crate Rust upstream. Leggere src/lib.rs è il modo più breve per capire l'interfaccia del parser USN_RECORD_V2.
  • Il libro wasm-bindgen — la reference per l'FFI tra Rust e JavaScript.
  • Sysmon-modular di Olaf Hartong — non correlato a wasm, ma il tipo di artefatto che, abbinato a un USN journal parsato, ti dà il quadro completo dell'attività sui file su un host.