Parsear el USN Journal en el navegador con Rust + WebAssembly

Cómo entregamos un parser completo de USN Journal NTFS a tu navegador como 105 KB de WebAssembly, y por qué 'parsearlo en el cliente' es la única respuesta aceptable para artefactos forenses.

Por 5 min de lectura

Un USN journal es uno de los archivos más sensibles que un analista tocará jamás. Un $J de 100 MB de una workstation corporativa es una historia reciente completa de esa máquina: cada documento que el usuario abrió, cada ejecutable que se lanzó, cada eviction de caché, cada save-by-rename en su editor. Pedirle a un forense profesional que suba eso a un endpoint SaaS para que el servidor de otro lo parse es el tipo de petición que debería sacar al SaaS del toolset, no subir el journal. Nunca quisimos ser ese SaaS.

Así que este sitio hace lo inverso: el parser corre en tu navegador. El journal se lee de disco a JavaScript, se entrega a un módulo WebAssembly, y los registros vuelven sin que un solo byte salga de la máquina. Este post recorre cómo funciona eso realmente, la crate Rust en la que nos apoyamos, las tres trampas de Cargo que nos costaron una tarde, y los números sobre un archivo representativo.

La crate Rust

Sin reinvenciones. La lógica de parsing viene de usnrs, la implementación limpia de USN_RECORD_V2 de Airbus CERT. Ya expone una interfaz Read + Seek, que es exactamente lo que std::io::Cursor<Vec<u8>> te da cuando tienes bytes en bruto en memoria, exactamente la forma de un Uint8Array llegando desde el lado del navegador.

La crate envoltorio tiene unas 60 líneas de Rust. El punto de entrada es:

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

Toma los bytes del journal y, opcionalmente, los bytes de $MFT para la resolución de ruta completa. Con wasm-opt el artefacto .wasm final pesa unos 105 KB.

Tres trampas de Cargo

Compilar usnrs más sus dependencias transitivas limpiamente para wasm32-unknown-unknown no es gratis. Tres cosas nos mordieron:

getrandom necesita la feature js en wasm32. La crate rand (atraída por mft transitivamente) depende de ella, y sin el backend JS la build wasm falla con "no available getrandom backend". Fuérzala en Cargo.toml:

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

chrono necesita wasmbind cuando la feature clock está habilitada. Sin ella, chrono intenta llamar a time(2), que no existe en wasm32-unknown-unknown. Añade features = ["wasmbind"] a la declaración directa de la dependencia.

Features por defecto de mft. La feature mft_dump trae dependencias CLI que resulta que sí cross-compilan bien, pero hinchan el artefacto wasm. Desactivamos los defaults y reactivamos solo lo que necesitamos.

Ninguna de estas requirió hacer fork de nada. Dos líneas de Cargo.toml resuelven todo el lote.

El pegamento del lado del navegador

La build es wasm-pack build --target web --out-dir public/wasm, que produce un pequeño shim JS de módulo ES y el binario .wasm. Ambos viven bajo /public/wasm/ y se sirven como assets estáticos en URLs conocidas.

El parser corre en un Web Worker para que el main thread permanezca responsivo mientras el módulo wasm mastica un millón de registros:

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

Este es el único punto donde el módulo wasm y el worker se encuentran. Ninguno pasa por el bundler de Next.js. Ninguna configuración de webpack o Turbopack fue herida.

Números sobre un archivo representativo

Un $J de 60 MB de una workstation Windows 11, en un Macbook reciente:

  • Tiempo de parseo: ~1,4 segundos.
  • Memoria: transitoria, liberada cuando el worker se termina.
  • Registros producidos: ~720.000.
  • Bytes que dejan la máquina por la red: 0. Confirma en la pestaña Network.

La UI luego virtualiza la tabla de resultados con TanStack Virtual, así que una tabla de un millón de filas sigue sintiéndose instantánea cuando haces scroll o filtras.

¿Y los journals realmente grandes?

Para journals por encima de 500 MB cambiaríamos a una API de streaming que produce lotes de registros en vez de acumularlos en un Vec. El cambio es pequeño: Usn ya es un Iterator; expondríamos next_batch(n) sobre wasm-bindgen. No lo hemos enviado porque nadie se ha topado con ese muro todavía. Si tú lo haces, abre un issue.

Una segunda optimización que dejamos deliberadamente fuera: parsear directamente desde un handle File vía las APIs de stream del navegador. El módulo wasm tendría que aceptar una interfaz tipo ReadableStream, lo que significa perder Cursor<Vec<u8>>. La complejidad todavía no vale la pena para journals por debajo de 500 MB.

Por qué este enfoque importa

El tooling forense se ha dividido históricamente entre dos campos. Suites de escritorio pesadas (X-Ways, EnCase, FTK) que licencias, instalas en una workstation y en las que confías. Scripts Python que haces pip install y corres en cualquier endpoint que resulte conveniente, a menudo con datos sensibles cruzando directorios temp de IDE por el camino.

WebAssembly abre un tercer carril: abierto, inspeccionable, corre enteramente en el navegador, sin upload. Las herramientas de Eric Zimmerman ocupan el slot offline de escritorio de alta confianza. Plaso y el stack libyal ocupan el slot pipeline scriptable. El slot del navegador llevaba demasiado tiempo vacío porque nadie creía que pudiera igualar a los otros en velocidad. Con wasm y una crate sensata debajo, esa excusa se acabó: para USN, para EVTX, para Prefetch, para cualquier artefacto binario Windows que un defensor realmente toque.

Lecturas adicionales

  • El repo de usnrs — la crate Rust upstream. Leer src/lib.rs es el camino más corto para entender la interfaz del parser USN_RECORD_V2.
  • El libro de wasm-bindgen — la referencia del FFI entre Rust y JavaScript.
  • Sysmon-modular de Olaf Hartong — no relacionado con wasm, pero el tipo de artefacto que, emparejado con un USN journal parseado, te da la imagen completa de la actividad de archivos en un host.