USN ジャーナル レコードでも、MFT エントリでも、レジストリ ハイブでも、見かけるタイムスタンプはどれも同じ形 — 変換しない限り意味を成さない 64 ビット整数です。その形式が FILETIME で、いったん感覚をつかめば、他のあらゆる Windows 時刻形式が腑に落ちます。
FILETIME とは
FILETIME は符号なし 64 ビット整数です。値は 1601-01-01 00:00:00 UTC からの 100 ナノ秒間隔の数 です。それだけです。
1601 という起点は恣意ではなく、現代を含むグレゴリオ暦 400 年周期の開始です。これによりカレンダー演算が閏年特例なしで一貫します。Microsoft は型を FILETIME 構造体リファレンス で文書化しています。
簡単な例: FILETIME 133593600000000000 は 2024-08-09 12:00:00 UTC に相当します。
Unix 時刻への変換
二段の算術で済みます:
- 10,000,000 で割って 100 ns ティックを秒に。
- 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、レジストリ、EVTX | 64 ビット LE、1601-01-01 UTC からの 100 ns ティック |
SYSTEMTIME | EVTX、一部の COM API | 16 ビット × 8 フィールド (年、月、日…) |
TIME_T (32 ビット) | 古いレジストリ キー | 1970 からの Unix 秒、32 ビット |
DOSTIME | FAT (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 のメモに置き換わりつつある) が標準的なリファレンスです。
検証用の小さな変換表
自作コンバータを書くなら、確認用に便利な値を:
| FILETIME | UTC 日付 |
|---|---|
0 | 1601-01-01 00:00:00 |
116444736000000000 | 1970-01-01 00:00:00 |
132923520000000000 | 2022-02-23 16:00:00 |
133593600000000000 | 2024-08-09 12:00:00 |
これらが出るなら、正しく動いています。