← ブログに戻る

週末を失わずに Python で $MFT を解析する

· 読了 2 分

フォレンジックワークステーション上の Python のほとんどは接着剤です。ネイティブツールでアーティファクトを取得し、誰かが書いたライブラリで解析し、結果をレポートとタイムラインに織り込むのに Python を使います。$MFT は、フォーマットが小さく PyPI のライブラリには粗いエッジがあるため、自前のものを作りたくなる誘惑が最も高いパーサの 1 つです。誘惑に抵抗してください。より良い選択肢があります。

これが実用的な Python MFT 解析の投稿です。どのライブラリをいつ使うか、私が実際に使うコードと共に紹介します。

何を読むか

NTFS の Master File Table は固定サイズ 1,024 バイトのレコードの系列です。Python から解析するには、次のことが必要です。

  1. $MFT ファイルを開く(またはディスクイメージから読む)。
  2. 一度に 1,024 バイトずつステップする。
  3. 各レコードに fixup 配列を適用する(torn write 検出メカニズム、レコード解剖の投稿 を参照)。
  4. 各レコード内の属性ストリームを走査する。

以下のライブラリはこれら 4 つすべてを処理します。struct.unpack にフォールバックする価値があるのは、ライブラリが必要なフィールドを公開していないときだけです。

選択肢 1: analyzeMFT(純粋 Python、デプロイが簡単)

analyzeMFT は古典的な純粋 Python パーサで、元々は David Kovar により、現在も保守されています。CLI ファーストですが、インポート可能です。遅いですが、理解できるレコードに対しては信頼性があります。

# pip install analyzeMFT
from analyzeMFT.mft_analyzer import MFTAnalyzer

analyzer = MFTAnalyzer(mft_file="path/to/$MFT", output_file="out.csv")
analyzer.analyze()

生成される CSV はレコードごとに 1 行で、$STANDARD_INFORMATION$FILE_NAME の両方からのタイムスタンプを持ちます。MFT が小さいときのスプレッドシート駆動のトリアージには十分です。

次のときに使ってください。

  • $MFT が小さい(数百 MB 以下)。
  • エアギャップな Python のみの環境で作業している。
  • ネイティブ依存に触れずにシンプルな CSV が欲しい。

次のときはスキップしてください。

  • 入力が複数 GB。analyzeMFT はシングルスレッド純粋 Python です。4 GB の MFT は 20 分以上かかることがあり、Rust パーサなら 30 秒で済みます。
  • レコードをプログラム的に走査するロジックを書きたい。オブジェクトモデルは CSV 出力に向けて設計されており、分析向けではありません。

選択肢 2: libmft(型付きオブジェクトモデル)

レコードを Python オブジェクトとしてクエリしたい場合、libmft はディスク上の構造に近い型付きモデルを公開します。

# pip install libmft
from libmft.api import MFT

with open("path/to/$MFT", "rb") as f:
    mft = MFT(f)
    for entry in mft:
        if not entry.is_deleted():
            continue
        name = entry.get_full_path()
        si = entry.get_attributes(0x10)[0]  # $STANDARD_INFORMATION
        print(name, si.created, si.modified)

libmft は親参照を解決するので、トラバーサルを自分で書くことなく各エントリにフルパスを問い合わせられます。また、$ATTRIBUTE_LIST 拡張レコードを透過的に処理しますが、これは analyzeMFT の CSV レイヤーは隠してしまいます。

次のときに使ってください。

  • レコードを走査し、属性でフィルタし、カスタム形状を出力するロジックを書きたい。
  • フラットな CSV ではなく、型付きオブジェクトモデル(セキュリティディスクリプタ、リパースポイント、ランリスト)にアクセスする必要がある。

次のときはスキップしてください。

  • 性能が重要。libmft は analyzeMFT より速いですが依然として純粋 Python であり、4 GB の MFT で 5〜10 分を見込んでください。

選択肢 3: Rust パーサにシェルアウト

MFT が大きい、または多くのディスクをバッチ処理する場合、最速の実用的選択肢は omerbenamram/mft_dump にシェルアウトし、その JSON Lines 出力を読むことです。

import json
import subprocess

# omerbenamram/mft — `cargo install mft` またはリリースバイナリをダウンロード
proc = subprocess.Popen(
    ["mft_dump", "-o", "json", "path/to/$MFT"],
    stdout=subprocess.PIPE, text=True,
)

for line in proc.stdout:
    record = json.loads(line)
    if record["header"]["flags"] & 0x1 == 0:  # IN_USE がクリア → 削除済み
        print(record["entry"], record["file_name"]["name"])

mft_dump は 1 行 1 レコードで出力し、全出力をメモリにロードせずに Python にストリームします。同じ入力に対する analyzeMFT と比べて、Rust パーサは通常 10 〜 50 倍速く、メモリ使用量は 1/10 です。

次のときに使ってください。

  • 本番パイプライン。
  • 大きな入力。
  • 解析時間が重要なところすべて。

唯一の落とし穴。バイナリがインストールされていることに依存します。バージョンを固定し、ツーリングと共に出荷し、インストールを runbook に文書化してください。

ディスクイメージから直接 $MFT を読む

抽出された $MFT ファイルではなく生の .dd または .E01 イメージがある場合、pytsk3(The Sleuth Kit の Python バインディング)を使ってボリューム上の $MFT にシークしバイトをストリームします。

import pytsk3

img = pytsk3.Img_Info("disk.dd")
fs = pytsk3.FS_Info(img, offset=0)  # NTFS パーティションオフセットを使う
mft_file = fs.open_meta(inode=0)    # $MFT は常に inode 0
size = mft_file.info.meta.size
data = mft_file.read_random(0, size)
# data に $MFT が入っています。libmft に渡すかディスクに書き出します

これはボリュームがパーティションレベルで暗号化されているが、生イメージを提供する復号化ツール経由でマウントされているときの最もきれいなアプローチです。イメージに VSS スナップショットが含まれていて各スナップショットから $MFT を抽出したいときも、これが正解です。スナップショット列挙のために libvshadow と組み合わせます。

私が手元に置いている短いスクリプト

私がなじみのない MFT を見るとき、最初に手を伸ばすスクリプトに近いものです。レジデントデータを持つ削除済みレコードを見つけ、その内容をダンプします。

import json
import subprocess

proc = subprocess.Popen(
    ["mft_dump", "-o", "json", "path/to/$MFT"],
    stdout=subprocess.PIPE, text=True,
)

for line in proc.stdout:
    rec = json.loads(line)
    if rec["header"]["flags"] & 0x1:
        continue  # 使用中
    for attr in rec.get("attributes", []):
        if attr["header"]["type_code"] != 0x80:
            continue  # $DATA ではない
        if not attr["header"]["is_resident"]:
            continue  # データは他の場所にある
        # レジデント、削除済み、$DATA がインライン。興味深いケース。
        data = bytes.fromhex(attr["data"]["resident_data"])
        print(f"rec={rec['entry']} seq={rec['header']['sequence']} "
              f"name={rec.get('file_name', {}).get('name')} "
              f"bytes={len(data)}")
        # レビュー用にレコード番号で名付けたファイルに書き出す。
        with open(f"deleted_resident_{rec['entry']}.bin", "wb") as f:
            f.write(data)

このスクリプト 1 つで、調査全体にわたって自身を正当化するに足る、削除されたスクリプト、設定、ワンライナードロッパーを浮かび上がらせてきました。レジデントデータは、人々がチェックすることを考えもしない MFT レコードの中に座っています。何が収まるかは resident data を参照してください。

よくある落とし穴

  • fixup 配列を忘れる。 USA を適用せずに生 1,024 バイトのチャンクを読むと、各レコードのオフセット 510 と 1022 にゴミが入ります。上記のライブラリはこれを代行します。fixup メカニズムを レコード解剖の投稿 で理解しているのでなければ、自前のパーサを作るのは避けてください。
  • レコード番号を ID として扱う。 レコード番号は再利用されます。64 ビットのファイル参照(レコード番号 + シーケンス番号)が衝突しない識別子です。スクリプトがレコード番号だけでグループ化すると、削除済みの先代を再利用する後継と暗黙のうちに混同します。
  • 2 つのタイムスタンプセットを混同する。 すべてのレコードは $STANDARD_INFORMATION(頻繁に更新)と $FILE_NAME(ほぼ安定)にタイムスタンプを持ちます。timestomping 検出にはどちらも必要です。4 つの MFT タイムスタンプ を参照してください。
  • 拡張レコードを処理しない。 属性が 1 レコードに収まらないファイルは、拡張レコードを指す $ATTRIBUTE_LIST0x20)を持ちます。多くの素朴なスクリプトはベースレコードを出力し、拡張に存在する属性を見逃します。libmft はこれを処理します。自前のウォークを作る場合は忘れないでください。

Python を完全にスキップするとき

インストール一切なしの一回限りのインタラクティブ分析には、$MFT をこのサイトの ブラウザパーサ にドロップしてください。同じ omerbenamram/mft クレートを WebAssembly にコンパイルして実行し、クライアント側でフィルタと検索を行い、CSV をエクスポートします。Python は不要です。

参考資料

  • omerbenamram/mft。上記スクリプトが JSON 出力を消費する Rust パーサです。
  • pytsk3。The Sleuth Kit の Python バインディング。ディスクイメージから直接 $MFT を読む最もきれいな方法です。
  • David Cowen, Daily Blog and Sunday Funday。実環境の MFT で動作する実務者の Python スニペットが何年も蓄積されています。

外部リソース