Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第五章 · 实验 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)核心代码
2Option ROM 与固件驱动
4引导加载程序(bootloader)及其配置
7Secure Boot 状态与策略变量
9内核、initrd 等(事件方式度量)
11systemd 启动阶段标记
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 的安全相关变量:SecureBootPK(Platform Key)、KEKdbdbx。你会看到连续好几条,每条度量一个变量的名字和内容。

这解释了一个重要现象:开关一次 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 '✗'}")

脚本的结构和日志格式一一对应,值得对照着读一遍:

  1. 读 Spec ID Event03 头(日志第一条记录)。它列出本日志用到的算法 ID 和摘要长度,我们据此建立“算法 → 摘要长度“的映射。不做这一步,后面切分变长的哈希字段就无从下手。
  2. 主循环逐条解析事件。每条记录的布局是:PCR 序号(4 字节)、事件类型(4 字节)、摘要个数(4 字节)、若干“算法 ID + 摘要“、事件数据长度、事件数据。注意我们没有用事件数据做任何计算——重放只需要摘要。事件数据是给人类看的审计明细。
  3. 逐摘要 extend。对每个摘要,取出该 (算法, PCR) 当前的累积值(第一次是全零),拼接后哈希。和实验 A 手动 extend 的公式一字不差,区别只是这次跑的是真启动留下的几十条记录。
  4. 比对。解析 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-initrdsysinit,系统就绪时 extend ready。这是运行时度量,发生在固件日志的边界之外,所以重放不出来。

PCR 9 同理:内核和 initrd 的度量记录在 TCG2 的 final events 表里,不在 binary_bios_measurements 里。

由此分清两类 PCR

这两个叉逼着我们画一条重要的线:

固件阶段度量运行时度量
典型 PCR0、2、4、711、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)**的全部骨架。把场景从“你自己在本机比对“换成“一台远程服务器验证你的机器“,验证方做的事情只有三件:

  1. 让被验机用 TPM 里的证明密钥(AIK, Attestation Identity Key)对当前 PCR 值签名,把签名值和日志一起发过来(签名保证 PCR 值不是软件随口撒谎);
  2. 重放日志,核对算出来的值和签名里的 PCR 是否一致——就是你这个脚本做的事,一行不多;
  3. 剩下的唯一一步是策略判断:这些度量值对应的固件、内核、配置版本,我信不信?

第 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 自动解锁,并且只在“环境没被改动“时才能解锁。