Parse do journal USN no navegador com Rust + WebAssembly

Como entregamos um parser completo de journal USN do NTFS ao seu navegador na forma de 105 KB de WebAssembly — e por que «parse no cliente» é a única resposta aceitável para artefactos forenses.

Por 5 min de leitura

Um journal USN é um dos ficheiros mais sensíveis que um analista alguma vez vai tocar. Um $J de 100 MB de uma workstation corporativa é o histórico recente completo dessa máquina: cada documento que o utilizador abriu, cada executável que correu, cada eviction de cache, cada save-by-rename do seu editor. Pedir a um profissional de forense que faça upload disso para um endpoint SaaS para que o servidor de outra pessoa o parseie é o tipo de pedido que devia tirar o SaaS do toolset, não levar a fazer upload do journal. Nunca quisemos ser esse SaaS.

Por isso, este site faz o inverso: o parser corre no seu navegador. O journal é lido do disco para JavaScript, entregue a um módulo WebAssembly, e os registos voltam sem um único byte sair da máquina. Este artigo percorre como isto funciona de facto, o crate Rust em que nos apoiámos, as três armadilhas do Cargo que custaram uma noite, e os números num ficheiro representativo.

O crate Rust

Sem reinventar a roda. A lógica de parse vem do usnrs, a implementação limpa de USN_RECORD_V2 da Airbus CERT. Já expõe uma interface Read + Seek, que coincide exactamente com o que std::io::Cursor<Vec<u8>> dá quando se tem bytes brutos em memória — exactamente o formato de um Uint8Array vindo do lado do navegador.

O crate envoltório tem cerca de 60 linhas de Rust. O ponto de entrada é:

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

Recebe os bytes do journal e, opcionalmente, os bytes do $MFT para resolução de caminhos completos. Com wasm-opt, o artefacto .wasm final tem cerca de 105 KB.

Três armadilhas do Cargo

Compilar usnrs mais as suas dependências transitivas de forma limpa para wasm32-unknown-unknown não é gratuito. Três coisas morderam-nos:

getrandom precisa da feature js em wasm32. O crate rand (puxado por mft) depende dele de forma transitiva, e sem o backend JS o build wasm falha com «no available getrandom backend». Forçe-o no Cargo.toml:

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

chrono precisa de wasmbind quando a feature clock está activa. Sem isso, o chrono tenta chamar time(2), que não existe em wasm32-unknown-unknown. Adicione features = ["wasmbind"] à declaração de dependência directa.

Default features do mft. A feature mft_dump puxa dependências de CLI que cross-compilam bem mas incham o artefacto wasm. Desactivámos as defaults e voltámos a activar apenas o necessário.

Nenhuma destas exigiu fork. Duas linhas de Cargo.toml resolvem tudo.

A cola do lado do navegador

O build é wasm-pack build --target web --out-dir public/wasm, que produz um pequeno shim JS em ES-module e o binário .wasm. Ambos ficam em /public/wasm/ e são servidos como assets estáticos em URLs conhecidos.

O parser corre num Web Worker para que o thread principal continue responsivo enquanto o módulo wasm mastiga um milhão de registos:

// 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 é o único sítio onde o módulo wasm e o worker se encontram. Nenhum passa pelo bundler do Next.js. Nenhuma configuração de webpack ou Turbopack foi maltratada.

Números de um ficheiro representativo

Um $J de 60 MB de uma workstation Windows 11, num Macbook recente:

  • Tempo de parse: ~1,4 segundos.
  • Memória: transitória, libertada quando o worker é terminado.
  • Registos produzidos: ~720.000.
  • Bytes a sair da máquina: 0. Confirme no separador Network.

A UI virtualiza depois a tabela de resultados com o TanStack Virtual, pelo que uma tabela de um milhão de linhas continua instantânea quando se rola ou filtra.

E journals realmente grandes?

Para journals acima de 500 MB passaríamos para uma API de streaming que yields batches de registos em vez de os acumular num Vec. A alteração é pequena — Usn já é um Iterator; expúnhamos next_batch(n) sobre wasm-bindgen. Ainda não enviámos isso porque ninguém bateu na parede. Se bater, abra um issue.

Uma segunda optimização deliberadamente deixada de fora: parsear directamente de um handle File via as stream APIs do navegador. O módulo wasm teria de aceitar uma interface tipo ReadableStream, o que significa perder o Cursor<Vec<u8>>. A complexidade ainda não compensa o ganho para journals abaixo de 500 MB.

Porque é que esta abordagem importa

A ferramenta forense tem-se historicamente dividido em dois campos. Suites desktop pesadas (X-Ways, EnCase, FTK) que se licencia, instala numa workstation e em que se confia. Scripts Python que se faz pip install e correm em qualquer endpoint que calhe ser conveniente, muitas vezes com dados sensíveis a atravessar as pastas temporárias do IDE pelo caminho.

O WebAssembly abre uma terceira via: aberto, inspeccionável, corre inteiramente no navegador, sem upload. As ferramentas do Eric Zimmerman ocupam o slot offline desktop de alta confiança. Plaso e a stack libyal ocupam o slot de pipeline scriptável. O slot do navegador esteve vazio demasiado tempo porque ninguém acreditava que pudesse igualar os outros em velocidade. Com wasm e um crate sensato por baixo, essa desculpa acabou — para USN, para EVTX, para Prefetch, para todos os artefactos Windows binários que um defensor realmente toca.

Leituras adicionais

  • O repositório do usnrs — o crate Rust upstream. Ler src/lib.rs é o caminho mais curto para perceber a interface do parser USN_RECORD_V2.
  • O livro do wasm-bindgen — a referência para o FFI entre Rust e JavaScript.
  • O Sysmon-modular do Olaf Hartong — não relacionado com wasm, mas é o tipo de artefacto que, combinado com um journal USN parseado, dá a imagem completa da actividade de ficheiros num host.