← ブログに戻る

ブラウザで USN ジャーナルを Rust + WebAssembly で解析する

105KB の WebAssembly としてブラウザに NTFS USN ジャーナルのフル パーサを届ける方法。そして「クライアント側で解析する」がフォレンジック痕跡にとって唯一受け入れ可能な答えである理由。

約 1 分で読了

USN ジャーナルは本質的に機微です。企業ワークステーションから取った 100MB の $J には、ユーザが触れた Word 文書、起動された実行ファイル、立ち退きされたブラウザ キャッシュなど、そのマシンの直近のファイル履歴が一行ずつ収まっています。これを SaaS に「解析してもらう」ためにアップロードするのは、フォレンジックの専門家が決して同意してはならないことで、私たちが決して提供したくないことでもあります。

そこで usnparser.com は逆方向に作りました: パーサはあなたのブラウザで動きます。ファイルはディスクから JavaScript に読み込まれ、WebAssembly モジュールに渡され、1 バイトたりともあなたの端末を出ることなくレコードが返ってきます。

本記事はその仕組みの技術的なウォークスルーです。

Rust の crate

車輪を再発明はしていません。実際のパース ロジックは Airbus CERT による USN_RECORD_V2 の clean な実装 usnrs から借りています。すでに Read + Seek インターフェイスを公開しており、これがまさに必要なものでした — std::io::Cursor<Vec<u8>> が実装する trait そのものなので、JS の Uint8Array からの生バイトを流し込めます。

ラッパー crate は Rust で 60 行程度です:

#[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 後に約 105KB の .wasm になります。

Cargo の三つの落とし穴

usnrs とその推移的依存を wasm32-unknown-unknown 向けに綺麗にコンパイルさせるには、三つの小さな回避策が必要でした:

  1. getrandomjs フィーチャが必要。 rand crate (mft から推移的に入る) が依存しており、JS バックエンドなしだと「no available getrandom backend」で wasm ビルドが失敗します。我々の Cargo.toml で強制します:
    [target.'cfg(target_arch = "wasm32")'.dependencies]
    getrandom = { version = "0.2", features = ["js"] }
    
  2. chronoclock フィーチャ有効化時には wasmbind が必要。 これがないと time(2) を呼ぼうとして失敗します。我々の直接宣言で追加します。
  3. mft のデフォルト フィーチャを無効化する必要は実はない。 mft_dump フィーチャは CLI 用のオプション依存を引きずるだけで、いずれもクロスコンパイルできます。

ブラウザ側のグルー

ビルドは wasm-pack build --target web --out-dir public/wasm で行い、小さな ES モジュール JS シムと .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 ワークステーション由来の代表的な 60MB の $J:

  • 解析時間: 最近の Macbook で約 1.4 秒
  • メモリ: 一時的、worker の終了時に解放
  • 生成レコード: 約 720,000 件
  • 端末を出るバイト数: 0

UI 側は TanStack Virtual でテーブルを仮想化するため、100 万行の結果でも体感は即座です。

巨大なジャーナルは?

500MB を超えるジャーナルでは、Vec に全て積み上げるのではなく ストリーミング API に切り替えて、バッチ単位でレコードを返す形にします。変更は小さく — Usn はすでに Iterator なので、wasm 側で next_batch(n) を公開するだけです。誰かが必要としているという声がまだ無いため出荷していません。ご希望なら issue を開いてください

なぜこれが重要か

フォレンジック ツーリングは歴史的に、重厚なデスクトップ スイート (X-Ways、EnCase) と、インストールして機微なデータを預けねばならない Python スクリプトに二分されてきました。第三の道があります: オープン、検証可能、完全にクライアント側で動く。WebAssembly は今や十分に高速で、その道を未踏のままにしておくパフォーマンス上の言い訳はありません。