USN レコード、MFT エントリ、レジストリハイブ、LNK ファイル、Prefetch のトレース、ジャンプリスト のいずれを開いても、見つかるタイムスタンプはすべて同じ形をしています。変換しないと無意味に見える 64 ビット整数です。その形式が FILETIME です。一度学べば、残りのキャリアを通じてすべての Windows アーティファクトが読めます。
定義
FILETIME は 1601-01-01 00:00:00 UTC からの 100 ナノ秒間隔を数える 64 ビットの符号なし整数です。仕様はこれですべてです。
1601 を選んだのは恣意的ではありません — 現在を含む 400 年のグレゴリオ周期の開始であり、Unix 時刻が扱わなければならない閏年のエッジケースをカレンダー演算で省けます。Microsoft はその型を FILETIME 構造体リファレンス で文書化しています。
実例: FILETIME 133593600000000000 は 2024-08-09 12:00:00 UTC です。ケースデータで信用する前に、コンバータをこの値で検証してください。
Unix 時刻への変換
二つの算術ステップ:
- 10,000,000 で割って 100ns ティックから秒へ。
- 11,644,473,600 を引く — 1601-01-01 と 1970-01-01 の間の秒数。
擬似コードで:
unix_seconds = filetime / 10_000_000 - 11_644_473_600
Rust では、まさに usnrs::Entry::unix_timestamp がやっていることです:
pub fn unix_timestamp(&self) -> i64 {
(self.timestamp as i64) / 10_000_000 - 11_644_473_600
}
Python では、標準ライブラリがカレンダー演算を処理してくれます:
unix_seconds = filetime / 10_000_000 - 11_644_473_600
# サブ秒精度で:
from datetime import datetime, timezone, timedelta
EPOCH = datetime(1601, 1, 1, tzinfo=timezone.utc)
dt = EPOCH + timedelta(microseconds=filetime / 10)
JavaScript では、64 ビット精度を保つために BigInt が必要です:
const unixMs = Number((filetime - 116444736000000000n) / 10000n);
const date = new Date(unixMs);
SQL (Postgres) では、Plaso のエクスポートからダンプされた生の FILETIME 値の列があるときに便利です:
SELECT TIMESTAMP '1601-01-01 00:00:00' + (filetime / 10000000) * INTERVAL '1 second';
サブ秒精度
フォレンジックで完全な 100ns 解像度が重要になることはまれです。ディスクのタイムスタンプは NTFS が記録することにしたものに量子化され、$STANDARD_INFORMATION の更新はシステムクロックではなくシステムコールに駆動されます。しかし重要になる場合 — USN レコードをパケットキャプチャと相関させる、Sysmon イベント 11 を DataExtend と並べる — には、最後のステップまで値を 100ns ティックで保持しておきたいです:
import datetime as dt
EPOCH = dt.datetime(1601, 1, 1, tzinfo=dt.timezone.utc)
ticks_100ns = 133593612345678901
microseconds = ticks_100ns // 10
nanoseconds_remainder = (ticks_100ns % 10) * 100
timestamp = EPOCH + dt.timedelta(microseconds=microseconds)
print(timestamp, f"+{nanoseconds_remainder}ns")
Python の datetime は最後の桁を除いてすべてをカバーします。ほとんどのアナリストは、意味のある精度を失わずにミリ秒で切り詰めます。
出くわすバリアント
Windows はいくつかのタイムスタンプエンコーディングを出荷しており、それらがアーティファクト間で漏れ出して新人を混乱させます。
| 形式 | 出現場所 | エンコーディング |
|---|---|---|
FILETIME | $MFT、$UsnJrnl、レジストリ、EVTX | 64 ビット LE、1601-01-01 UTC からの 100ns ティック |
SYSTEMTIME | EVTX のレンダリング出力、一部の COM API | 8 つの 16 ビットフィールド (年、月、日など) |
TIME_T (32 ビット) | 古いレジストリキー、ページファイル のヘッダ | 1970 からの Unix 秒、32 ビット |
DOSTIME | FAT (FAT からコピーされた NTFS メタデータに時々漏れる) | パックされた 16 ビットの日付 + 16 ビットの時刻、ローカル時刻 |
ディスク上で FILETIME はリトルエンディアンです — LSB が先頭です。xxd はバイトを左から右に表示します。手で読む場合は、解釈する前にバイト順を反転させてください。ツールはこれを自動的に処理します。
実際に時間を奪った落とし穴
生のダンプにおけるエンディアン。 ヘックスビューアはメモリ順で表示します。変換演算は整数値を持っていることを前提としています。まずバイトを反転させる必要があります。
ゼロセンチネル。 FILETIME = 0 は技術的には 1601-01-01 00:00:00 UTC です。実際には Windows はそれを「未設定」を意味するために使用します。プレゼンテーションレイヤでゼロを null として扱ってください — さもないと、ファイルが 1601 年に最後にアクセスされたと主張するレポートを公開することになります。NTFS が未設定のままにする特定の $FILE_NAME タイムスタンプにも同じ罠が当てはまります。
符号あり対符号なし。 一部の Windows 内部 (およびそれらの上に構築された一部のパーサ) は FILETIME を符号付き int64 として扱います。およそ 30828 年以降の値はマイナスに巻き戻ります。これは実用上は問題ありませんが、意図的に改変された値がその範囲に着地して、バグのあるコンバータを壊すことがあります。符号なしを維持してください。
ローカル対 UTC。 FILETIME は常に UTC です。ツールがローカル時刻を表示するなら、出力時に変換しています。DFIR ではほぼ常にストレージレイヤで UTC が望ましいです — ステークホルダー向けに描画するときだけローカルゾーンに変換します。さもないと、タイムゾーンをまたぐホスト間の相関が崩れます。
$STANDARD_INFORMATION 対 $FILE_NAME。 どちらもすべての $MFT エントリに存在し、どちらも FILETIME の M/A/C/B 時刻を保存します。SI の値はユーザ空間で書き込み可能で、FN のコピーは低頻度で更新され、変更がはるかに困難です。両者の比較が典型的な timestomping 検出であり、timestomping 記事 では USN ジャーナルがその比較をどう補完するかを正確に解説しています。
検証用テーブル
自分でコンバータを書く場合 — そして言語が変換をネイティブに扱わないために誰もがいつかは書きます — 私は次の値で照合しています:
| FILETIME | UTC 日付 |
|---|---|
0 | 1601-01-01 00:00:00 (慣例により null) |
116444736000000000 | 1970-01-01 00:00:00 |
132923520000000000 | 2022-02-23 16:00:00 |
133593600000000000 | 2024-08-09 12:00:00 |
コンバータがこれら 4 つを正しく出力すれば、他のすべてについても正しいものを出力します。
参考資料
- Microsoft Learn — FILETIME 構造体 と関連する FileTimeToSystemTime 関数。
- Joachim Metz の libfwnt — ほぼすべての libyal パーサが使用する FILETIME 変換のリファレンス C 実装です。
- SANS DFIR — Windows 時刻ルールチートシート は、異なる操作下で
$STANDARD_INFORMATIONと$FILE_NAMEのタイムスタンプがどう更新されるかをカバーします。これは FILETIME 形式自体の実践的な補完です。