← 返回博客

在浏览器中用 Rust + WebAssembly 解析 USN 日志

我们如何把一个完整的 NTFS USN 日志解析器以 105 KB 的 WebAssembly 形式发送到你的浏览器,以及为何「在客户端解析」是对取证痕迹唯一可接受的答案。

阅读约 1 分钟

USN 日志在本质上就是敏感数据。一台企业工作站上 100 MB 的 $J 文件,一行行记录着该机器近期完整的文件历史:用户碰过的每一个 Word 文档、运行过的每一个可执行程序、浏览器缓存的每一次淘汰。把它上传到一个 SaaS 让它「替你分析」,是任何一位取证专业人士都不该答应、也是我们永远不愿提供的事情。

所以我们把 usnparser.com 反过来构建:解析器在你的浏览器里运行。文件从磁盘读入 JavaScript,交给一个 WebAssembly 模块,记录返回时没有任何一个字节离开过你的设备。

本文是其工作机制的技术演练。

Rust crate

我们没有重新发明轮子:实际的解析逻辑来自 Airbus CERT 对 USN_RECORD_V2 的干净实现 usnrs。它已经暴露了 Read + Seek 接口 — 正是我们需要的。这正好是 std::io::Cursor<Vec<u8>> 实现的 trait,所以我们可以从一个 JS 的 Uint8Array 把原始字节喂进去。

包装 crate 大约 60 行 Rust:

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

它接收日志字节,可选地接收 $MFT 字节以做完整路径解析。整体经过 wasm-opt 后约 105 KB 的 .wasm

Cargo 的三个小坑

要让 usnrs 及其传递依赖干净地交叉编译到 wasm32-unknown-unknown,需要三个小绕过:

  1. getrandom 在 wasm32 上需要 js feature。 rand crate (被 mft 间接拉入) 传递依赖于它,如果没有 JS 后端,wasm 构建会以 "no available getrandom backend" 失败。我们在自己的 Cargo.toml 中强制开启:
    [target.'cfg(target_arch = "wasm32")'.dependencies]
    getrandom = { version = "0.2", features = ["js"] }
    
  2. chrono 启用 clock feature 时需要 wasmbind,否则它会尝试调用 time(2)。我们通过自己的直接声明把它加上。
  3. 关闭 mft 的默认 feature 其实没必要 — mft_dump feature 只额外拉入可选的 CLI 依赖,这些依赖也都能交叉编译。

浏览器端的胶水

构建命令是 wasm-pack build --target web --out-dir public/wasm,它产出一个小的 ES 模块 JS shim 和 .wasm 二进制。两者都位于 /public/wasm/ 下,以稳定 URL 作为静态资源对外提供。

解析器活在一个 Web Worker 里,这样在解析器啃下百万条记录时主线程仍能保持响应:

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

这是 wasm 与 worker 唯一相遇的地方。无论是 worker 还是 wasm 都不经过 Next.js 的打包器,这意味着 零 webpack/Turbopack 配置

数字

来自一台 Windows 11 工作站的代表性 60 MB $J:

  • 解析时间: 在较新的 Macbook 上约 1.4 秒
  • 内存: 临时占用,worker 结束时释放
  • 产生的记录数: 约 720,000 条
  • 离开你设备的字节数: 0

UI 接着用 TanStack Virtual 对表格做虚拟化,因此即便是百万行的结果也能感觉到瞬时响应。

那超大的日志怎么办?

对于 500 MB 以上的日志,我们会切换到 流式 API,按批次返回记录而不是把全部塞进一个 Vec。改动很小 — Usn 已经是一个 Iterator,只需要在 wasm 一侧暴露 next_batch(n)。我们没有先发布这个,是因为还没收到有人需要的反馈。如果你需要,开一个 issue

为什么这件事重要

取证工具历史上一直被一分为二:厚重的桌面套件 (X-Ways、EnCase),以及那些需要你安装、并把敏感数据交给它们的 Python 脚本。还有第三条路:开放、可审视、完全在客户端运行。今天的 WebAssembly 已经足够快,没有任何性能上的借口不去走这条路。