时间戳换算与时区陷阱

"该文件于 2024 年 3 月 15 日 14:30 被创建"这句话要成立,必须先回答四个问题:这是什么格式、纪元起点是哪一天、精度是多少、存的是 UTC 还是本地时间。四个问题不回答,那个数字就是废的。

最后更新 2026-10-09版本 v1.0维护 DigiForensics查看历史 0

关键词:FILETIME、WebKit 时间、DOS 时间、OLE 自动化时间、CFAbsoluteTime、时区、ISO 8601 难度:进阶 前置知识:取证流程与原则、十六进制与位运算基础

一、概述

"该文件于 2024 年 3 月 15 日 14:30 被创建"这句话要成立,必须先回答四个问题:这是什么格式、纪元起点是哪一天、精度是多少、存的是 UTC 还是本地时间。四个问题不回答,那个数字就是废的。

错法不止一种。把 WebKit 微秒当 FILETIME 百纳秒读,结果跑到 1600 多年;把 OLE 浮点天当 Unix 秒读,结果落到 1970 年附近;把目标机本地时间当 UTC 读,整条时间线偏移 8 小时,"深夜不可能作案"这类判断随之作废。

根源在于同一台机器上的不同工件存着纪元、单位和精度各不相同的数值,而它们在十六进制或十进制视图里长得毫无区别。换算是分析环节的第一道工序,位置在"拿到元数据"和"开始重建时间线"之间,任何涉及"先后顺序"的结论都经过这一道。

换算的确定性来自四个换算常量。11644473600 秒是 1601-01-01 到 1970-01-01 的间隔,即 134774 天。978307200 秒是 2001-01-01 到 1970-01-01 的间隔,即 11323 天。25569 天是 1970-01-01 在 OLE 纪元下的计数值。知道 11644473600 等于 134774 天,比背公式管用。

对 2024-03-15 06:30:00 UTC 这个参考时刻,各纪元的取值各不相同。FILETIME 从 1601-01-01 起、单位 100 纳秒,值是 133549578000000000,18 位。WebKit 时间同样从 1601-01-01 起但单位是微秒,值是 13354957800000000,17 位,与 FILETIME 差正好 10 倍。CFAbsoluteTime 从 2001-01-01 起、单位秒,值是 732177000;.NET Ticks 从 0001-01-01 起、单位 100 纳秒,值是 638460810000000000;Unix 与 Unix 毫秒从 1970-01-01 起,值分别是 1710484200 与 1710484200000;OLE 自动化时间从 1899-12-30 起、单位浮点天,值是 45366.27083333;DOS 时间从 1980-01-01 起、2 秒粒度,值是 0x586F73C0。ext4、日志、Date.now() 上分别是 Unix 秒与毫秒,量级差 1000 倍。

浏览器历史分两系,同一个"从 1601 年起算"的说法覆盖了两种单位。WebKit 系(Chrome、Edge 的 Chromium 版、Firefox、Safari)把时间存在 SQLite 的 visit_time 字段,单位微秒。Trident 系(IE 6–11)的 index.dat 里存的就是 FILETIME,单位 100 纳秒。按微秒读 IE 的记录会跑到 2400 多年,反过来按 100 纳秒读 Chrome 会落到 1610 年前后。两种错误都不报错、不溢出,日期库照常输出一个看起来正常的年份。核对位数是唯一不依赖换算结果的判据:Chrome 侧 17 位,IE 侧 18 位。

OLE 自动化时间有两个坑。纪元是 1899-12-30 不是 1970-01-01,Unix 纪元在 OLE 里等于 25569.0;单位是浮点"天",整数部分是日期,小数是一天内已过的时间比例。看到带小数的五位数(4.5e4 量级)先怀疑它是 OLE 日期。存储格式是 IEEE 754 浮点,"减 25569 再乘 86400"会引入误差:实测 45366.27083333 换算得 06:29:59.999712,截断成 45366.270833 则换算得 06:29:59,直接差 1 秒。所以 OLE 字段只用于日期级推断。

DOS 时间是 FAT/exFAT 目录项里的位域打包,创建/修改/访问各占 4 字节(2 字节时间 + 2 字节日期)。时间域 16 位的布局是 秒/2 [0:5] | 分 [5:11] | 时 [11:16],日期域是 日 [16:21] | 月 [21:25] | 年-1980 [25:32]。四个特征:1980-01-01 起算,存"实际年 − 1980",最大 127 即 2107;2 秒精度,秒字段 5 位、单位是 2 秒,奇数秒根本无法表示;无时区字段,存本地时间;秒字段上限 31 × 2 = 62 秒,留 60–62 给闰秒进位。同一 2 秒桶里的两个文件显示值相同但实际最多相差 2 秒,这足以推翻"同时创建""先写后拷"这类顺序结论。解析的正确性用回环自检验证——解包后重新打包与原值比对,0x586F73C0 实测得 2024-03-15 14:30:00、回环一致。

时区要分三层看。存储层:NTFS 存 UTC、FAT/exFAT 存本地时间、ext4 存 Unix 秒。系统层:时区设置要从 SYSTEM hive 的 TimeZoneInformation 键或 /etc/localtime 独立取证,不能假设。显示层:报告统一写 UTC 并标注北京时间。多数应用字段的时区属性构成了一张能否直接转 UTC 的对照表:NTFS/注册表/事件日志、ext4 mtime 可以;FAT/exFAT 目录项、OLE 文档属性不可以。少数应用把本地时间写进了本该存 UTC 的字段,FAT 只是最明显的一类。

位数法只是初筛,18 位既可能是 FILETIME 也可能是 .NET Ticks(纪元差 1601 与 0001,单位相同,位数法区分不了)。tsconv.py 把一个值按所有纪元都算一遍,用"合理性"反推正确纪元——只有落在合理年份的那一行才是答案。这个方法在 OLE 与 WebKit 上都成立,只是后者的错误结果落点不同。

换算正确只是前提不是保证。任意程序都能调 SetFileTime 改 mtime,也能直接改 SQLite 里的 visit_time。时间戳是强关联工具、弱定性工具:它能告诉你先后顺序,不能单独告诉你是谁做的。单个文件内部的时间戳可以互比,跨工件排序需要先统一时区与精度前提。至于亚秒级顺序,FAT 只有 2 秒精度、OLE 有浮点误差,这两类字段上"同一秒内的先后"不可靠。

提取环节有两条操作纪律。SQLite 必须复制到镜像之外再查,在挂载点内执行 sqlite3 会按 journal 模式创建 -journal/-wal/-shm 并可能回放 WAL,而镜像目录在很多挂载方案下可写。三个文件必须一起拷,WAL 里的最近写入不在主文件里,漏掉就丢掉"最近没有活动"这段最需要证明的时间窗;复制后立刻用 PRAGMA integrity_check; 验证副本。注册表 hive 的时间值确实是 FILETIME,但 rip.exe 输出的十六进制偏移是 hive 内部逻辑位置而非文件字节偏移,直接拿去 dd 必然取错。

上游依赖两件事:十六进制与位运算基础,以及对时区基本概念的准确理解。2.6 特别强调系统层的时区要独立取证,报告里必须写"目标机时区由 SYSTEM hive 独立取证确认",而不是隐含在"换算过了"里。

二、核心原理

2.1 纪元(Epoch)对照表

这是本篇最重要的一张表。记错纪元是时间戳分析最常见、也最致命的错误。

名称 纪元起点 单位 典型来源 2024-03-15 06:30:00 UTC 的值
FILETIME 1601-01-01 100 纳秒 NTFS、注册表、事件日志、IE 历史 133549578000000000
WebKit 时间 1601-01-01 微秒 Chrome、Edge、Firefox 等浏览器的 SQLite 13354957800000000
Unix 1970-01-01 秒 ext4、绝大多数 Unix 系统与日志 1710484200
Unix 毫秒 1970-01-01 毫秒 JavaScript Date、大量 Web 日志 1710484200000
CFAbsoluteTime 2001-01-01 秒(Cocoa) iOS/macOS 备份、plist 732177000
.NET Ticks 0001-01-01 100 纳秒 .NET DateTime 638460810000000000
OLE 自动化时间 1899-12-30 浮点「天」 旧版 Office 文档属性 45366.27083333
DOS 时间 1980-01-01 2 秒粒度 FAT/exFAT 目录项 0x586F73C0

⚠️ 最致命的一对:FILETIME 和 WebKit 都从 1601 年起算,但 FILETIME 是 100 纳秒、WebKit 是微秒,相差正好 10 倍——把 FILETIME 当 WebKit 读会跑到 1600 多年,反过来跑到 2400 多年。数值长度也不同:FILETIME 通常 18 位,WebKit 通常 17 位。

⚠️ 第二个坑:OLE 自动化时间不是从 1970 年起算,零点是 1899-12-30,Unix 纪元在 OLE 里等于 25569.0 而不是 0。看到带小数的五位数(45366 这种),先怀疑它是 OLE 日期。

2.2 关键换算常量

Unix 秒 = FILETIME / 10_000_000  - 11_644_473_600
Unix 秒 = WebKit微秒 / 1_000_000  - 11_644_473_600
Unix 秒 = CFAbsoluteTime          + 978_307_200
Unix 秒 = (OLE浮点天 - 25569.0)   × 86400
  • 11644473600 秒 = 1601-01-01 到 1970-01-01 的间隔(134774 天)
  • 978307200 秒 = 2001-01-01 到 1970-01-01 的间隔(11323 天)
  • 25569 天 = 1970-01-01 在 OLE 纪元下的计数值

2.3 浏览器两系:WebKit 与 Trident 不要混

浏览器历史是取证最常碰到的工件,而它分两大阵营。

阵营 代表浏览器 存储格式 纪元 单位
WebKit 系 Chrome、Edge(Chromium)、Firefox、Safari SQLite visit_time 1601-01-01 微秒
Trident 系 Internet Explorer 6–11 index.dat 二进制记录 1601-01-01 100 纳秒

关键点:IE 历史记录里的时间值就是 FILETIME(100 纳秒)。

同一个"1601 年起算"的数字,在 Chrome 里要按微秒读,在 IE 里要按 100 纳秒读。弄错就是 10 倍误差——这一条比"WebKit vs FILETIME"更隐蔽,因为很多人只知道 Chrome 那一半。

SQLite 只能直接处理 Unix 秒,WebKit 时间必须先除以 1000000 再减 11644473600,完整查询见步骤 4。

2.4 OLE 自动化时间:浮点数带来的精度陷阱

OLE 自动化时间(Office 文档属性常见)是浮点「天」——整数部分是日期,小数是一天内已过的时间比例。两个坑。

坑一:纪元是 1899-12-30,不是 1970。 换算必须减 25569.0 再乘 86400。

坑二:它不是精确值。 存储格式是 IEEE 754 浮点,"减 25569 再乘 86400"会引入误差。实测:45366.27083333 换算得 06:29:59.999712;若被截断成 45366.270833,换算得 06:29:59。直接差 1 秒。

取证含义:OLE 字段上"同一秒内的行为先后"不可靠。把 OLE 只用于日期级推断(哪一天),需要秒级精度时改用 NTFS 时间、事件日志交叉验证。

相关历史坑:早期电子表格软件曾把第 60 天当作 1900-02-29(不存在的闰日),导致 1900 年 3 月 1 日前后日期差 1 天。解析那几年的老文档要留意。

2.5 DOS 时间:FAT 文件名的 2 秒精度

FAT/exFAT 目录项里,创建/修改/访问时间各占 4 字节(2 字节时间 + 2 字节日期),位域打包:

时间域 16 位:  秒/2 [0:5] | 分 [5:11] | 时 [11:16]
日期域 16 位:  日 [16:21] | 月 [21:25] | 年-1980 [25:32]

四个关键特征:1980 起算(存"实际年 − 1980",最大 127 即 2107);2 秒精度(秒字段 5 位、单位是"2 秒",奇数秒根本无法表示);无时区字段(存本地时间,目录项里没有 UTC 偏移);秒字段上限 31 × 2 = 62 秒(留 60–62 供闰秒进位)。

时区陷阱在这里最隐蔽。同一个 FAT 时间戳在不同时区的机器上会被解释成不同的 UTC 时刻。所以从 FAT 卷得出的时间只能表述为"目标机本地时间",转 UTC 前无法定论。

2.6 时区问题与时间戳的局限

时区要分三层看。存储层:NTFS 存 UTC、FAT/exFAT 存本地时间、ext4 存 Unix 秒。系统层:时区设置要从 SYSTEM hive 或 /etc/localtime 独立取证,不能假设。显示层:报告统一写 UTC 并标注北京时间。

CST (UTC+8) 无夏令时,标注简单;其他时区需注意夏令时切换。

注意:NTFS、注册表、事件日志虽是 UTC,但少数应用把本地时间写进了本该存 UTC 的字段。

局限同样要认清。文件时间戳可被程序任意修改,访问时间不可靠;FAT 的 2 秒精度和 OLE 的浮点精度都不支持秒级排序;时区错误会整体偏移 8 小时,导致"深夜不可能作案"式的误判。

结论:时间戳是强关联工具,弱定性工具。它能告诉你"先后顺序",不能单独告诉你"是谁做的"。


三、操作步骤

步骤 1:识别未知时间戳

拿到一个整数,先看位数和数量级:

18 位(1.3e17 附近)→ FILETIME(100 纳秒)或 .NET Ticks(需再判纪元)
17 位(1.3e16 附近)→ WebKit 微秒(Chrome/Edge/Firefox)
13 位(1.7e12 附近)→ Unix 毫秒
10 位(1.7e9  附近)→ Unix 秒
9  位(7.3e8  附近)→ CFAbsoluteTime(2001 年后的秒)
带小数五位(4.5e4)→ OLE 自动化时间(浮点天)
FAT 目录项 4 字节  → DOS 时间(1980 起、2 秒精度)
全 0             → 未初始化/空值

位数法只是初筛。18 位既可能是 FILETIME 也可能是 .NET Ticks(纪元差 1601 vs 0001),必须靠合理性判断。

步骤 2:使用完整换算脚本

脚本 tsconv.py(见第七节)已实际运行验证,把一个值按所有纪元都算一遍,用"合理性"反推正确纪元。

把一个真实采集到的 Chrome visit_time 值 13354957800000000 喂给它:

$ python3 tsconv.py 13354957800000000
输入十进制值:13354957800000000

  FILETIME / IE        (100ns, 1601起)    -> 1643-04-28 03:03:00 UTC
  WebKit Chrome/Firefox (us,  1601起)     -> 2024-03-15 06:30:00 UTC
  CFAbsoluteTime       (s,    2001起)     -> (超出 datetime 可表示范围,纪元很可能不对)
  Unix 秒              (s,    1970起)      -> (超出 datetime 可表示范围,纪元很可能不对)
  Unix 毫秒            (ms,   1970起)       -> (超出 datetime 可表示范围,纪元很可能不对)
  .NET DateTime ticks  (100ns, 0001起)    -> 0043-04-28 03:03:00 UTC
  OLE Date             (天,  1899-12-30起) -> (超出 datetime 可表示范围,纪元很可能不对)
--------------------------------------------------------------------------

👆 注意看:只有"WebKit"那一行落在合理年份,其余都跑到 1600/0043 年或直接超范围。

这就是"用合理性反推纪元"的方法——某个纪元算出明显荒谬的年份,它一定不是对的。反过来喂给它真正的 OLE 值 45366.27083333,输出 2024-03-15 06:29:59,此时正确纪元就是 OLE。

步骤 3:解析 DOS 时间(FAT 目录项)

FAT 目录项里的时间是 4 字节位域,按 2.5 节的布局解包即可。最可靠的验证是回环自检——解包后重新打包与原值比对,一致才说明移位与掩码没写反。脚本 dostime.py 见第七节,实测 0x586F73C0 得 2024-03-15 14:30:00、回环"一致"。

⚠️ 回环不一致就说明解析代码有 bug,此时的时间结论一律不可用。

步骤 4:从镜像中提取并查询

先取出工件,再按各自的纪元换算。每种来源的纪元不同,不要套同一条公式:

# 1) 注册表 hive 提取(Windows 时间戳大量存在于其中),提取后搜 QWORD 值按 FILETIME 换算
reged -x DigiForensics.dd "/WINDOWS/system32/config/SYSTEM" /tmp/SYSTEM
rip.exe -p /tmp/SYSTEM -a /tmp/out.txt bcdedit

# 2) 浏览器历史:必须先复制出来,不能在镜像上直接打开
#    WAL/SHM 旁文件必须一并复制,否则最近数据丢失
cp "/mnt/case/.../Default/History" /tmp/History
cp "/mnt/case/.../Default/History-wal" /tmp/ 2>/dev/null
cp "/mnt/case/.../Default/History-shm" /tmp/ 2>/dev/null

# 3) Chrome/Firefox:WebKit 微秒,先除 1000000 再减 11644473600
sqlite3 /tmp/History "
SELECT datetime(visits.visit_time/1000000 - 11644473600,'unixepoch') AS utc, urls.url
FROM visits JOIN urls ON urls.id = visits.url
ORDER BY visits.visit_time DESC LIMIT 20;"

# 4) iOS:plist 的 NSDate(0x33 + 8 字节小端 double);iMessage 用 CFAbsoluteTime
plutil -convert xml1 -o /tmp/info.plist /mnt/case/iOS/Info.plist
sqlite3 /tmp/chat.db "
SELECT text, datetime(date/1000000000 + 978307200,'unixepoch') AS sent_utc
FROM message ORDER BY date DESC LIMIT 10;"

四、常见陷阱

时间戳分析的失败几乎从不是"算错了",而是在算之前就没说清楚这个数字是什么。本章按四个环节展开:先认出纪元与单位,再判断精度能不能支撑你要下的结论,然后是提取与查询时的操作纪律,最后是时区与结论表述。

★ 标记表示该操作会永久改变原始