← ブログに戻る

Windows FILETIME を理解する — NTFS タイムスタンプを人間が読める形に変換する

FILETIME は $MFT、$UsnJrnl、レジストリ、$Recycle.Bin など、ほぼすべての Windows アーティファクトで遭遇するタイムスタンプ形式です。短く完結したリファレンス: 何か、どう変換するか、よくある落とし穴。

約 2 分で読了

USN ジャーナル レコードでも、MFT エントリでも、レジストリ ハイブでも、見かけるタイムスタンプはどれも同じ形 — 変換しない限り意味を成さない 64 ビット整数です。その形式が FILETIME で、いったん感覚をつかめば、他のあらゆる Windows 時刻形式が腑に落ちます。

FILETIME とは

FILETIME は符号なし 64 ビット整数です。値は 1601-01-01 00:00:00 UTC からの 100 ナノ秒間隔の数 です。それだけです。

1601 という起点は恣意ではなく、現代を含むグレゴリオ暦 400 年周期の開始です。これによりカレンダー演算が閏年特例なしで一貫します。Microsoft は型を FILETIME 構造体リファレンス で文書化しています。

簡単な例: FILETIME 1335936000000000002024-08-09 12:00:00 UTC に相当します。

Unix 時刻への変換

二段の算術で済みます:

  1. 10,000,000 で割って 100 ns ティックを秒に。
  2. 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:

// filetime は 64 ビット精度を保つため BigInt
const unixMs = Number((filetime - 116444736000000000n) / 10000n);
const date = new Date(unixMs);

SQL (Postgres):

SELECT TIMESTAMP '1601-01-01 00:00:00' + (filetime / 10000000) * INTERVAL '1 second';

秒未満精度

100 ns の完全解像度がフォレンジックで意味を持つことは稀です — ディスク上のタイムスタンプは OS が記録した時点で量子化されており、$STANDARD_INFORMATION はクロックではなく syscall で更新されます。それでも意味がある場面 (パケット キャプチャとの相関など) では、最後の段階まで値を 100 ns ティックのまま保持します:

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 のマイクロ秒解像度は、ticks_100ns の最下位桁を除くすべてを覆います。多くのアナリストはミリ秒で切り捨てても実質的な精度を失いません。

出会う派生形

複数の Windows アーティファクトが同じ考え方の少しずつ違うエンコードを使います:

形式現れる場所エンコード
FILETIME$MFT$UsnJrnl、レジストリ、EVTX64 ビット LE、1601-01-01 UTC からの 100 ns ティック
SYSTEMTIMEEVTX、一部の COM API16 ビット × 8 フィールド (年、月、日…)
TIME_T (32 ビット)古いレジストリ キー1970 からの Unix 秒、32 ビット
DOSTIMEFAT (NTFS メタデータに紛れ込むこともあり)16 ビット日付 + 16 ビット時刻、現地時刻でパック

バイナリ構造をパースする際、バイト順は リトル エンディアン — FILETIME の LSB が先に来ます。

よくある落とし穴

  • 生ダンプでのエンディアン: xxd は左から右へ表示しますが、FILETIME のバイトは解釈前に並びを反転する必要があります。ツールやパーサは自動で行いますが、手作業の解析では先に並びを変えるのが普通です。
  • ゼロ値: FILETIME = 0 は実値 (1601-01-01) でもあり、「未設定」を示すセンチネルでもあります。表示時は null として扱うのが妥当です。
  • 2 の補数の癖: 一部の実装 (Windows 内部の一部を含む) は FILETIME を符号付き int64 として扱うため、~30828 年以降は負へ巻き戻ります。迷ったら符号なしを使うこと。
  • ローカル vs UTC: FILETIME は 常に UTC です。ツールがローカル時刻を表示しているなら、出力時に変換しているだけです。DFIR ではホスト間相関のため、ほぼ常に UTC が欲しい場面です。
  • $STANDARD_INFORMATION$FILE_NAME のタイムスタンプ: 両者は $MFT 内に存在し、ともに FILETIME、ともに M/A/C/B 時刻を保持します。$FILE_NAME 側の写しは更新頻度が低く、タイムスタンプ改ざんの検出に両者を比較するのはそのためです。Brian Carrier のメモと Mandiant の timestomp ペーパー (現在は X-Ways のメモに置き換わりつつある) が標準的なリファレンスです。

検証用の小さな変換表

自作コンバータを書くなら、確認用に便利な値を:

FILETIMEUTC 日付
01601-01-01 00:00:00
1164447360000000001970-01-01 00:00:00
1329235200000000002022-02-23 16:00:00
1335936000000000002024-08-09 12:00:00

これらが出るなら、正しく動いています。