← 返回博客

Windows FILETIME 详解 — 把 NTFS 时间戳转成可读形式

FILETIME 是你会在 $MFT、$UsnJrnl、注册表、$Recycle.Bin 以及几乎所有 Windows 痕迹中遇到的时间戳格式。简短而完整的参考:它是什么、如何换算、有哪些坑。

阅读约 2 分钟

打开任意一条 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 时间

两步算术即可:

  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 用 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、注册表、EVTX64 位 LE,自 1601-01-01 UTC 起的 100 ns 节拍
SYSTEMTIMEEVTX、部分 COM API8 个 16 位字段 (年、月、日……)
TIME_T (32 位)较旧的注册表键自 1970 起的 Unix 秒,32 位
DOSTIMEFAT (有时会泄到 NTFS 元数据里)打包的 16 位日期 + 16 位时间,本地时

解析二进制结构时,字节序是 小端 — FILETIME 的最低字节排在最前。

常见坑

  • 原始 dump 的端序: xxd 从左到右展示字节;FILETIME 的字节需要先反转再解释。工具和解析器会自动处理;手工分析通常需要先翻转字节序。
  • 零值: FILETIME = 0 既是一个真实值 (1601-01-01),也是「从未设置」的 sentinel。展示时建议视为 null。
  • 二进制补码古怪: 一些实现 (包括 Windows 内部的若干) 把 FILETIMEint64 有符号处理,导致 ~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 笔记) 是标准参考。

用于自查的小转换表

如果你写自己的转换器,这几个值适合用来对账:

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

如果你的转换器算出这些值,它就是对的。