USN 日志是分析师能接触到的最敏感的文件之一。一份来自企业工作站的 100 MB $J 就是那台机器最近的完整历史:用户打开过的每一份文档、执行过的每一个可执行文件、每一次缓存淘汰、每一次编辑器中的另存重命名。让取证专业人员把它上传到 SaaS 端点,让别人的服务器去解析它,这种请求应该让那个 SaaS 被从工具箱里剔除,而不是让日志被上传。我们从来都不想成为那种 SaaS。
所以本站反其道而行:解析器在你的浏览器里运行。日志从磁盘读入 JavaScript,交给 WebAssembly 模块,记录返回,期间没有任何一字节离开机器。本文走一遍这究竟是如何运作的、我们倚赖的 Rust crate、花掉我们一整晚的三个 Cargo 坑,以及在一份代表性文件上的指标。
Rust crate
不重复造轮子。解析逻辑来自 usnrs,Airbus CERT 干净的 USN_RECORD_V2 实现。它已经暴露了 Read + Seek 接口,这与你拿到内存中原始字节后由 std::io::Cursor<Vec<u8>> 给出的形状完全匹配 — 也正是从浏览器侧到达的 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 处理后,最终的 .wasm 工件约 105 KB。
三个 Cargo 坑
把 usnrs 及其传递依赖干净地为 wasm32-unknown-unknown 编译并不是免费的。被三件事咬到了:
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"] }
当 clock feature 启用时,chrono 需要 wasmbind。 否则 chrono 会尝试调用 time(2),而它在 wasm32-unknown-unknown 上不存在。在直接依赖声明中添加 features = ["wasmbind"]。
mft 的默认 features。 mft_dump feature 会拉入一些 CLI 依赖 — 它们能交叉编译,但会让 wasm 工件臃肿。我们禁用默认,只重新启用必要项。
这些都不需要 fork。Cargo.toml 中两行就能解决全部。
浏览器侧的胶水
构建命令是 wasm-pack build --target web --out-dir public/wasm,产出一个小巧的 ES-module JS shim 和 .wasm 二进制。两者都放在 /public/wasm/,以已知 URL 作为静态资源提供。
解析器在 Web Worker 中运行,这样在 wasm 模块嚼着百万条记录时主线程依然能保持响应:
// 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 唯一相遇的地方。两者都不经过 Next.js 的打包器。没有任何 webpack 或 Turbopack 配置受到伤害。
代表性文件上的指标
来自 Windows 11 工作站的 60 MB $J,在较新的 Macbook 上:
- 解析时间: 约 1.4 秒。
- 内存: 暂态,worker 终止后释放。
- 生成的记录: 约 72 万条。
- 离开机器的字节: 0。在 Network 标签确认。
UI 之后用 TanStack Virtual 把结果表格虚拟化,百万行的表格滚动和过滤仍然瞬间响应。
真正大的日志怎么办
对 500 MB 以上的日志,我们会切换到一个流式 API,按批 yield 记录而不是把它们累积到一个 Vec。改动很小 — Usn 已经是 Iterator;我们会通过 wasm-bindgen 暴露 next_batch(n)。还没发是因为还没人撞到这道墙。如果撞到了,请开 issue。
我们刻意没做的第二项优化是:通过浏览器的 stream API 直接从 File 句柄解析。wasm 模块需要接收类似 ReadableStream 的接口,这意味着失去 Cursor<Vec<u8>>。对 500 MB 以下的日志,这份复杂度还不值。
这种做法为何重要
取证工具历史上一直分裂为两个阵营。一类是重量级桌面套件(X-Ways、EnCase、FTK),你购买许可证、安装到工作站、并对其加以信任;另一类是 pip install 后在恰巧方便的端点上跑的 Python 脚本 — 敏感数据往往一路穿过 IDE 的临时目录。
WebAssembly 开辟了第三条道路:开放、可检视、完全在浏览器中运行、无上传。Eric Zimmerman 的工具占据高可信离线桌面这一档。Plaso 与 libyal 占据可脚本管道这一档。浏览器这一档则空置太久,因为没人相信它能在速度上比肩其他。有了 wasm 和底下一个像样的 crate,这个借口就没了 — 对 USN、对 EVTX、对 Prefetch,以及对防御者实际会接触的每一个 Windows 二进制痕迹。
延伸阅读
- usnrs 代码仓库 — 上游 Rust crate。理解
USN_RECORD_V2解析器接口的最短路径就是去读src/lib.rs。 - wasm-bindgen 手册 — Rust 与 JavaScript 之间 FFI 的参考。
- Olaf Hartong 的 Sysmon-modular — 与 wasm 无关,但它是这样一种痕迹:当它与解析过的 USN 日志一起出现时,你就拿到了主机上文件活动的完整画面。