第五章 · 实验 C:解剖真实启动链,重放度量日志
本章目标
前两个实验里,PCR 是我们自己 extend 的,日志也是我们自己记的。这一次来点真的:把你虚拟机上一次真实开机时固件留下的度量日志翻出来,一笔一笔重新算一遍,再和 TPM 芯片里此刻的真实 PCR 值逐个比对。
做完这一章,你会:
- 看懂 TPM 度量日志(event log)里记的都是什么;
- 用一个 60 行的 Python 脚本把日志从头重放,验证“日志“和“PCR“这两份证据严丝合缝;
- 发现两个对不上的 PCR,并且搞清楚这不是 bug,而是度量体系的真实架构——这个发现比全部对上更有价值;
- 理解远程证明(Remote Attestation)的验证方到底在干什么——就是你这个脚本干的事。
背景知识
日志与 PCR 是同一枚硬币的两面
第一章说过,PCR 里只存一个滚动的哈希值,它本身回答不了“我到底度量了什么“。回答这个问题的是度量日志:每次 extend 之前,度量者(固件、bootloader)会先把一条记录追加到日志里,格式大致是“我要把 XX 的哈希 YY extend 进 PCR Z“。
TPM 芯片不管日志,日志存放在普通内存和磁盘上,由操作系统导出。所以这套体系里有两份证据:
- PCR:硬件持有,不可篡改,但信息高度压缩(只有一个哈希);
- 日志:软件产生,内容详尽,但理论上可以被伪造。
可信启动的赌注是:这两份证据在密码学上咬死了。如果日志被人改过一个字节,重放日志算出来的值就不可能等于芯片里的 PCR——除非攻击者能破解 SHA-256 的抗原像性(preimage resistance)。本章就是亲手验证这个赌注。
日志存在哪,长什么样
Linux 内核通过 TPM 的 ACPI 表拿到固件日志的内存地址,把它导出到 securityfs:
/sys/kernel/security/tpm0/binary_bios_measurements
这是一个二进制文件,采用 TCG 规范里的 crypto-agile 格式(也叫 EFI TCG2 格式)。结构很简单:
┌─────────────────────────────┐
│ 第一条记录:Spec ID Event03 │ ← 声明本日志用到哪些哈希算法、各多长
├─────────────────────────────┤
│ 事件记录 1 │ ← 每条 = PCR序号 + 事件类型 + N个哈希 + 事件数据
│ 事件记录 2 │
│ ... │
└─────────────────────────────┘
注意“N 个哈希“:现代固件对每个事件同时算 SHA-1 和 SHA-256(有的还有 SHA-384),分别 extend 进对应的 PCR bank(每个哈希算法一套独立的 24 个 PCR)。所以等下重放的时候,我们要维护两套累积值。
思考:为什么固件连日志格式都要设计成“先声明算法表“?因为 TPM 1.2 时代日志只支持 SHA-1,写死在格式里,后来升级算法时吃了大亏。crypto-agile 的意思是“算法可以换,格式不用变“。这个教训在密码学工程里反复出现。
第一步:看看芯片里的真实 PCR
先读一下此刻 TPM 里的 PCR,这是待会儿的“标准答案“:
sudo tpm2_pcrread
sha1:
0 : 0xA3F1...
1 : 0x0000000000000000000000000000000000000000
2 : 0xB2C9...
3 : 0x0000000000000000000000000000000000000000
4 : 0xE7D0...
...
7 : 0x9B44...
...
9 : 0x5C1E...
...
11: 0x7A02...
12: 0xD3F8...
...
sha256:
0 : 0x6E1B...
2 : 0x8F47...
4 : 0xC59A...
7 : 0x2D61...
9 : 0x41B7...
11: 0xF0E3...
12: 0x9AC5...
...
(输出较长,这里只保留了有代表性的行。)
观察一下:PCR 0、2、4、7、9、11、12 是非零的,其余全是零。回忆第一章的 PCR 约定:
| PCR | 度量内容 |
|---|---|
| 0 | 固件(BIOS/UEFI)核心代码 |
| 2 | Option ROM 与固件驱动 |
| 4 | 引导加载程序(bootloader)及其配置 |
| 7 | Secure Boot 状态与策略变量 |
| 9 | 内核、initrd 等(事件方式度量) |
| 11 | systemd 启动阶段标记 |
| 12 | 内核命令行等配置 |
PCR 3 全零是因为没接外设;如果你给虚拟机挂载了带 Option ROM 的设备,它也会有值。这些非零值到底对应哪些度量?答案全在日志里。
第二步:把日志翻译成人类可读的样子
直接 cat 那个二进制文件当然是一堆乱码。tpm2-tools 自带解析器:
sudo tpm2_eventlog /sys/kernel/security/tpm0/binary_bios_measurements | less
输出是 YAML,每个事件长这样(有删减):
- EventNum: 0
PCRIndex: 0
EventType: EV_S_CRTM_VERSION
DigestCount: 2
Digests:
- AlgorithmId: sha1
Digest: "..."
- AlgorithmId: sha256
Digest: "..."
EventSize: ...
Event: ...
我们的虚拟机一次典型启动大约产生几十条事件。不用逐条看,先认识四种最有代表性的,它们各自解释了“为什么那个 PCR 是那个值“。
EV_S_CRTM_VERSION → PCR 0:信任链的起点
日志的第一条几乎总是它。CRTM 是可信度量根核心(Core Root of Trust for Measurement)——开机后第一段执行的固件代码,它是整个信任链的祖宗:CRTM 度量固件的其余部分,固件度量 bootloader,bootloader 度量内核……一环扣一环。CRTM 自身没人度量(谁来度量度量者?),所以它必须固化在只读存储里,被当作信任公理接受。
注意:这就是为什么“可信启动“的准确说法是“度量的启动(Measured Boot)“。TPM 从不判断固件是好是坏,它只保证:如果固件换了,PCR 0 一定变。判断好坏是验证方的活(第八章的 Evil Maid 攻击会把这个区别讲到骨头里)。
EV_EFI_VARIABLE_DRIVER_CONFIG → PCR 7:Secure Boot 的账本
这类事件度量的是 UEFI 的安全相关变量:SecureBoot、PK(Platform Key)、KEK、db、dbx。你会看到连续好几条,每条度量一个变量的名字和内容。
这解释了一个重要现象:开关一次 Secure Boot,或者更新主板固件重刷了 PK/KEK,PCR 7 就会变。如果你把 LUKS 密钥绑在 PCR 7 上,进一次 BIOS 设置界面就得重绑。这也是为什么很多发行版默认不绑 PCR 7——第六章会展开。
EV_SEPARATOR → PCR 0-7:每个阶段的“分界章“
EV_SEPARATOR 的事件数据固定是 4 字节,固件在即将把控制权交给下一棒(比如从 DXE 阶段进入 BDS 阶段、从固件进入 bootloader)时,往一批 PCR 里各 extend 一个分隔符。它的作用类似账本里的“本页到此为止“:有了它,重放者能清楚看到阶段的边界,篡改者也没法在阶段之间偷偷插入或拼接事件。
EV_EFI_BOOT_SERVICES_APPLICATION → PCR 4:bootloader 本人
固件加载 bootloader(我们环境里是 Limine,经 UKI 打包)时,把这个 PE 镜像的哈希度量进 PCR 4。这条事件的 Event 字段里通常能看到镜像路径或设备路径。换了内核、改了 UKI,PCR 4 就会变。
第三步:写重放脚本
光看解析结果不过瘾。下面这个脚本把二进制日志逐条读出来,用实验 A 里那个公式 new = Hash(old || digest) 重新累积,最后调 tpm2_pcrread 拿真实 PCR 对比。完整代码如下:
#!/usr/bin/env python3
"""重放 TPM 启动度量日志,验证重算值与 TPM 芯片中的真实 PCR 一致"""
import hashlib, struct, subprocess
LOG = "/sys/kernel/security/tpm0/binary_bios_measurements"
ALG_NAME = {0x0004: "sha1", 0x000B: "sha256", 0x000C: "sha384", 0x000D: "sha512"}
data = open(LOG, "rb").read()
u16 = lambda b, o: struct.unpack_from("<H", b, o)[0]
u32 = lambda b, o: struct.unpack_from("<I", b, o)[0]
# 第一条记录 Spec ID Event03,声明日志用到哪些哈希算法及长度
esize = u32(data, 28)
event = data[32:32 + esize]
assert event[:16] == b"Spec ID Event03\x00", f"不支持的日志格式: {event[:16]!r}"
nalg = u32(event, 24)
alg_size, o = {}, 28
for _ in range(nalg):
alg_size[u16(event, o)] = u16(event, o + 2)
o += 4
pos = 32 + esize
pcrs = {} # (算法, PCR序号) -> 当前累积值
while pos < len(data):
pcr, _, ndig = u32(data, pos), u32(data, pos + 4), u32(data, pos + 8)
o = pos + 12
digests = []
for _ in range(ndig):
alg = u16(data, o); o += 2
digests.append((alg, data[o:o + alg_size[alg]])); o += alg_size[alg]
evsz = u32(data, o)
o += 4 + evsz
for alg, dg in digests: # 和实验 A 一样的公式: new = Hash(old || digest)
old = pcrs.get((alg, pcr), b"\x00" * alg_size[alg])
h = hashlib.new(ALG_NAME[alg]); h.update(old); h.update(dg)
pcrs[(alg, pcr)] = h.digest()
pos = o
# 读出 TPM 里的真实 PCR 值进行对比
out = subprocess.run(["sudo", "tpm2_pcrread"], capture_output=True, text=True).stdout
actual, bank = {}, None
for line in out.splitlines():
line = line.strip()
if line.endswith(":"):
bank = line[:-1]
elif bank and ":" in line:
idx, val = line.split(":", 1)
actual[(bank, int(idx.strip()))] = val.strip().lower().replace("0x", "")
print(f"{'bank':8} {'PCR':>3} {'日志重放计算值':<64} {'TPM 实际值':<64} 一致")
for (alg, pcr), val in sorted(pcrs.items(), key=lambda kv: (kv[0][1], kv[0][0])):
name = ALG_NAME[alg]
got = actual.get((name, pcr), "")
print(f"{name:8} {pcr:>3} {val.hex():<64} {got:<64} {'✓' if got == val.hex() else '✗'}")
脚本的结构和日志格式一一对应,值得对照着读一遍:
- 读 Spec ID Event03 头(日志第一条记录)。它列出本日志用到的算法 ID 和摘要长度,我们据此建立“算法 → 摘要长度“的映射。不做这一步,后面切分变长的哈希字段就无从下手。
- 主循环逐条解析事件。每条记录的布局是:PCR 序号(4 字节)、事件类型(4 字节)、摘要个数(4 字节)、若干“算法 ID + 摘要“、事件数据长度、事件数据。注意我们没有用事件数据做任何计算——重放只需要摘要。事件数据是给人类看的审计明细。
- 逐摘要 extend。对每个摘要,取出该
(算法, PCR)当前的累积值(第一次是全零),拼接后哈希。和实验 A 手动 extend 的公式一字不差,区别只是这次跑的是真启动留下的几十条记录。 - 比对。解析
tpm2_pcrread的文本输出,按(bank, PCR序号)对齐,逐项打勾或打叉。
坑:读
binary_bios_measurements需要 root 权限,所以运行时要sudo python3 replay_pcrs.py,而不是先chmod。另一个坑在脚本内部:事件记录里“事件类型“那个字段重放时用不上,但不能跳过不读——偏移量错一个字节,后面全盘皆输,而且错得毫无提示,只是最后一列全是叉。这个坑我踩过。
第四步:运行,然后发现两个叉
sudo python3 replay_pcrs.py
输出(哈希过长,中间以 … 省略):
bank PCR 日志重放计算值 TPM 实际值 一致
sha1 0 a3f1... a3f1... ✓
sha1 2 b2c9... b2c9... ✓
sha1 4 e7d0... e7d0... ✓
sha1 7 9b44... 9b44... ✓
sha1 9 0000... 5c1e... ✗
sha1 11 0000... 7a02... ✗
sha1 12 d3f8... d3f8... ✓
sha256 0 6e1b... 6e1b... ✓
sha256 2 8f47... 8f47... ✓
sha256 4 c59a... c59a... ✓
sha256 7 2d61... 2d61... ✓
sha256 9 0000... 41b7... ✗
sha256 11 0000... f0e3... ✗
sha256 12 9ac5... 9ac5... ✓
PCR 0、2、4、7、12,两个 bank,全部对上了。但 PCR 9 和 PCR 11 是叉:日志里根本没有针对它们的任何记录(重放值还是全零的初始值),芯片里却有非零的真实值。
第一次看到这两个叉,我的第一反应是脚本解析错了——但几十条记录、两个 bank 全都严丝合缝,偏偏这两个 PCR 失败,说明解析本身没问题。这不是脚本 bug,是度量体系的边界。
边界一:固件日志只记到 ExitBootServices
binary_bios_measurements 是固件的度量日志。固件把控制权交给操作系统(调用 UEFI 的 ExitBootServices)之后,它的记录就结束了。验证一下日志的尾巴:
sudo tpm2_eventlog /sys/kernel/security/tpm0/binary_bios_measurements | tail -40
最后几条会是一个 EV_SEPARATOR,跟着两条 EV_EFI_ACTION,事件字符串分别是:
"Exit Boot Services Invocation"
"Exit Boot Services Returned with Success"
——固件的遗言:“我走了,后面不归我管了。“操作系统启动之后发生的一切度量,自然不在这份日志里。
边界二:PCR 11 是 systemd 的地盘
那 PCR 11 里的值是谁 extend 的?查一下本次启动的日志:
journalctl -b | grep -i pcrphase
... systemd-pcrphase[...]: Extended PCR 11 with 'enter-initrd'
... systemd-pcrphase[...]: Extended PCR 11 with 'leave-initrd'
... systemd-pcrphase[...]: Extended PCR 11 with 'sysinit'
... systemd-pcrphase[...]: Extended PCR 11 with 'ready'
是 systemd-pcrphase。它在启动的各个阶段往 PCR 11 里 extend 一串固定的阶段名字符串:initrd 里 extend enter-initrd,切到真实根文件系统后依次 extend leave-initrd、sysinit,系统就绪时 extend ready。这是运行时度量,发生在固件日志的边界之外,所以重放不出来。
PCR 9 同理:内核和 initrd 的度量记录在 TCG2 的 final events 表里,不在 binary_bios_measurements 里。
由此分清两类 PCR
这两个叉逼着我们画一条重要的线:
| 固件阶段度量 | 运行时度量 | |
|---|---|---|
| 典型 PCR | 0、2、4、7 | 11、15 等 |
| 度量者 | 固件(UEFI/BIOS) | 操作系统(systemd、内核) |
| 值由什么决定 | 固件版本、Secure Boot 状态、bootloader 镜像 | 固定的阶段字符串、可预测的文件哈希 |
| 怎么验证 | 重放固件日志(本章做的事) | 预先计算期望值,再和芯片比对 |
两类 PCR 的验证方式完全不同:固件 PCR 的值取决于你机器里固件的二进制内容,外人无法预知,只能靠日志重放还原;而 PCR 11 的值是一串公开字符串哈希的链式累积,任何人在知道启动阶段序列的前提下都能提前算出来。
思考:这个区别有非常实际的后果。systemd 系把 LUKS 绑定到 PCR 11(
systemd-pcrlock就是这么干的),恰恰因为它的值能提前预测:升级内核之前,就可以算出“下次开机 PCR 11 会是多少“,预先完成重绑,而不是重启后站在 LUKS 密码提示符前才发现自己解不开盘。第六章实验 D 会亲手走一遍这个流程。
顺带一提,如果你想知道 PCR 11 的“正确值“,不用自己重放,systemd-pcrlock 直接帮你算好了:
sudo systemd-pcrlock
它会列出各 PCR 的预测值与实际值,本质上是把我们脚本对 PCR 11 该做的事做全了。
这说明什么
回头看那些绿色的勾。你刚才亲手证明的是:
TPM 芯片里的 PCR(硬件持有、不可篡改的证据)和磁盘上的日志(软件产生、可审查的明细)在密码学上咬合。 任何人拿到这份日志,不需要信任你的机器,不需要信任你,就能独立重放、独立得出结论。反过来,攻击者想伪造一份“看起来干净“的日志去匹配被篡改后的 PCR,等于要为一条几十环的哈希链找到 SHA-256 的原像——以现有密码学认知,这做不到。
这也正是**远程证明(Remote Attestation)**的全部骨架。把场景从“你自己在本机比对“换成“一台远程服务器验证你的机器“,验证方做的事情只有三件:
- 让被验机用 TPM 里的证明密钥(AIK, Attestation Identity Key)对当前 PCR 值签名,把签名值和日志一起发过来(签名保证 PCR 值不是软件随口撒谎);
- 重放日志,核对算出来的值和签名里的 PCR 是否一致——就是你这个脚本做的事,一行不多;
- 剩下的唯一一步是策略判断:这些度量值对应的固件、内核、配置版本,我信不信?
第 1 步由硬件保证,第 2 步是纯数学,唯一需要“信任“和“智慧“的是第 3 步。可信计算的全部难点,最后都收敛到这一步上——而这是后面章节的话题。
小结
- TPM 度量日志存放在
/sys/kernel/security/tpm0/binary_bios_measurements,采用 crypto-agile 二进制格式:一条 Spec ID Event03 头声明算法表,之后每条事件 = PCR 序号 + 事件类型 + 若干摘要 + 事件数据。 tpm2_eventlog可以把它解析成 YAML;四类关键事件(CRTM 版本、Secure Boot 变量、SEPARATOR、bootloader 镜像)分别解释了 PCR 0、7、4 的来历。- 重放日志就是逐条套用 extend 公式。60 行 Python 足够,而且和实验 A 的手动 extend 是同一个公式。
- PCR 9、11 对不上不是 bug:固件日志终止于 ExitBootServices,之后的运行时度量(systemd-pcrphase 对 PCR 11、final events 表对 PCR 9)不在这份日志里。
- 固件 PCR 靠日志重放验证,运行时 PCR 靠预先计算验证。PCR 11 的可预测性正是 systemd 系偏爱用它绑 LUKS 的原因。
- 远程证明 = PCR 签名 + 日志重放 + 策略判断。你已经掌握了三件套里的第二件,而且知道它为什么可信。
下一章把前面积累的一切落到最实用的场景:让 LUKS 全盘加密在开机时由 TPM 自动解锁,并且只在“环境没被改动“时才能解锁。