打开任意一条 USN 日志记录、任意一条 MFT 表项、任意一份注册表 hive,看到的时间戳都是同一形态:一个 64 位整数,如果不做转换就毫无意义。这个格式就是 FILETIME,一旦你对它有了直觉,所有其他 Windows 时间格式就都说得通了。
FILETIME 是什么
FILETIME 是一个无符号的 64 位整数。它的值是 自 1601 年 1 月 1 日 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 用 BigInt 保留 64 位精度
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 的完整解析度在取证中很少用到 — 磁盘上的时间戳被量化到操作系统决定记录的精度,$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 | 8 个 16 位字段 (年、月、日……) |
TIME_T (32 位) | 较旧的注册表键 | 自 1970 起的 Unix 秒,32 位 |
DOSTIME | FAT (有时会泄到 NTFS 元数据里) | 打包的 16 位日期 + 16 位时间,本地时 |
解析二进制结构时,字节序是 小端 — FILETIME 的最低字节排在最前。
常见坑
- 原始 dump 的端序:
xxd从左到右展示字节;FILETIME 的字节需要先反转再解释。工具和解析器会自动处理;手工分析通常需要先翻转字节序。 - 零值:
FILETIME = 0既是一个真实值 (1601-01-01),也是「从未设置」的 sentinel。展示时建议视为 null。 - 二进制补码古怪: 一些实现 (包括 Windows 内部的若干) 把
FILETIME当int64有符号处理,导致 ~30828 年之后的值绕到负数。拿不准就用无符号。 - 本地时 vs UTC: FILETIME 始终是 UTC。如果某个工具显示了本地时间,说明它在输出时做了转换。DFIR 几乎总是希望保留 UTC,以便跨主机相关。
$STANDARD_INFORMATION与$FILE_NAME的时间戳: 二者都在$MFT里,都是 FILETIME,都记录 M/A/C/B 时间。$FILE_NAME的副本更新频率更低,这正是 timestomp 检测对二者做比对的原因。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 |
如果你的转换器算出这些值,它就是对的。