无论你打开哪个 USN 记录、MFT 表项、注册表 hive、LNK 文件、Prefetch 跟踪、jump list,里面的时间戳都是同样的形状:一个 64 位整数,不换算时看起来像胡言乱语。这个格式就是 FILETIME。学一次,以后整个职业生涯都能读懂每一种 Windows 痕迹。
定义
FILETIME 是一个 64 位无符号整数,计数自 1601-01-01 00:00:00 UTC 起的 100 纳秒间隔。规范就这么多。
选 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 里,需要用 BigInt 来保留 64 位精度:
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 时间戳也有同样陷阱。
有符号 vs 无符号。 部分 Windows 内部(以及在其之上的部分解析器)把 FILETIME 当作有符号 int64 处理。大约公元 30828 年之后的值会回绕成负数,这实务中并不重要 — 但故意篡改的值可能落到那个范围内,把有 bug 的转换器搞坏。保持无符号。
本地时间 vs UTC。 FILETIME 永远是 UTC。如果工具显示本地时间,是在输出时转换的。对 DFIR,你几乎总是想在存储层用 UTC — 只在向干系人渲染时才转本地时区。否则跨时区的跨主机关联会崩。
$STANDARD_INFORMATION vs $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 |
如果你的转换器给出上面这四个,那其他任意值它都会给对。
延伸阅读
- Microsoft Learn — FILETIME 结构体 与相关的 FileTimeToSystemTime 函数。
- Joachim Metz 的 libfwnt — 几乎所有 libyal 解析器都使用的 FILETIME 换算参考 C 实现。
- SANS DFIR — Windows time rules cheat sheet 介绍
$STANDARD_INFORMATION与$FILE_NAME时间戳在不同操作下如何更新,是 FILETIME 格式本身的实务补充。