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

前言

这是一本从真实实验里沉淀出来的 TPM/可信启动入门书。它不打算把你培养成密码学家,而是想让有 Linux 基础的普通工程师,第一次把 TPM 这颗芯片“用起来“——并且知道自己在干什么。

这本书是怎么来的

我手上有一台 deepin Linux 笔记本,带着真实的 TPM 2.0 芯片。TPM 这个词我见过很多年——Windows 11 装不上是因为没有它,BitLocker 背后是它,Secure Boot 总和它一起被提起——但说实话,我一直没搞懂它到底能干嘛、怎么干。

于是我心一横,决定从零开始,把 TPM 的核心能力一个一个亲手验证一遍。

直接在自己每天都在用的笔记本上做实验风险太大(后面会讲,动 Secure Boot 和 LUKS 都是有去无回的操作),所以我在 KVM 里搭了一台虚拟机:Omarchy 发行版(Arch 系),Limine 引导,统一内核镜像(Unified Kernel Image, UKI),LUKS 全盘加密,再挂一个 swtpm 软件模拟的 TPM 2.0。环境从零开始搭,实验一个一个做,坑一个一个踩。

在这台虚拟机里,我完成了四个层层递进的实验:

  • 实验 A:手动 extend PCR,亲眼验证哈希链的工作原理
  • 实验 B:把一个秘密 Seal 到 PCR 状态上,然后模拟“篡改“,看着解封失败
  • 实验 C:解析固件的度量日志(event log),自己写脚本重放,和真实 PCR 逐一比对
  • 实验 D:把 LUKS 根分区绑定到 TPM,实现开机自动解锁——这大概是所有人最想干的那件事

做完实验还没完,我又往下挖了授权策略(PCR policy / PolicyAuthorize)、Evil Maid 攻击与 Secure Boot 的意义、IMA 运行时度量这些进阶话题。

这本书就是整个过程的沉淀。书中所有命令都在真实环境里跑过,所有坑都是我自己真实踩过的(附录 A 专门记录这些坑)。

这本书适合谁

  • 有 Linux 基础,听说过 TPM,但一直没搞懂它到底能干嘛的人
  • 想做全盘加密自动解锁(比如“为什么我的 Arch 不能像 BitLocker 那样开机不用输密码“)的人
  • 想搞明白可信启动、度量启动、远程证明这些概念,但看规范文档看到头疼的工程师

你不需要懂密码学,需要懂的数学知识基本只有“哈希函数是单向的“这一条。但你得会敲命令、会读报错、会折腾虚拟机。

反过来说,如果你只是想找一份“生产环境 TPM 配置手册“,这本书可能帮不了你——它讲的是原理和手感,不是发行版运维清单。但读完它再去看那些手册,你会发现自己能看懂每一行配置在干什么了。

全书结构

概念篇(第 1–2 章):不打地基,实验做不了。第 1 章讲信任链、PCR、Seal 这些核心概念;第 2 章手把手教你搭出和我一样的虚拟机实验环境。

实战篇(第 3–6 章):四个层层递进的实验。从“extend 一个 PCR“这种最小动作开始,到“解析整个启动链的度量日志“,最后到“让加密磁盘开机自动解锁“。每一个实验都能独立跑通,但连起来才是完整的图景。

进阶篇(第 7–10 章):授权策略为什么比“绑死 PCR“灵活、Evil Maid 攻击揭示的 Secure Boot 意义、IMA 如何把度量延伸到运行时,以及接下来往哪走。

附录:踩坑实录(附录 A)、命令速查表(附录 B)、本书用到的脚本(附录 C)。

阅读建议

实战篇强烈建议照着做一遍,不要只看。TPM 这东西的挫败感非常强——命令没几个,报错没提示,一旦某个步骤理解有偏差,后面全错。亲手敲一遍,踩一遍坑,才真的懂。

环境搭建在第 2 章,包括给虚拟机做快照的方法。玩坏了就回滚,这正是我们不用真机的意义所在。

还有一个建议:每个实验做完,先别急着看“这说明什么“那一节,自己试着回答一遍“刚才发生了什么“。对不上再回去看。TPM 的知识结构是链条式的,一环没懂,后面全是空中楼阁。

风险警告

本书涉及的 Secure Boot 配置、LUKS 密钥管理、initramfs 改造,在真机上都可能造成无法启动或数据丢失。书中所有实验默认在虚拟机中进行,请先在虚拟机里练熟,再考虑要不要上真机。上真机之前,确保你有恢复密钥的备份。

约定

  • 命令块会标注语言(bash 等),# 开头的是需要 root 权限执行的命令
  • 输出内容做了必要的排版精简,长输出用 ... 省略,但所有内容都来自真实运行
  • 关键概念第一次出现时给出中英对照,之后只用中文简称
  • 书里的“坑““注意”“思考“提示框不是装饰——那些是我真实踩过的坑,或者读者大概率会困惑的地方

第一章 · 概念地图:信任链、PCR 与度量

本章目标

这一章没有命令,只讲概念。读完后你应该能回答这些问题:

  • 开机时,系统怎么知道自己没被动过手脚?
  • TPM 这颗芯片凭什么可信?
  • PCR 是什么,为什么说它“只能加不能减“?
  • 度量日志和 PCR 是什么关系,为什么要“重放“它?
  • 度量启动(Measured Boot)和 Secure Boot 到底有什么区别?
  • Seal、远程证明、密钥层级这些名词分别对应什么能力?

这些是后面所有实验的地基。我见过太多教程上来就是 tpm2_createprimary,敲完也不知道自己干了什么。我们反过来:先把概念地图画清楚,再动手。

注意

先澄清几个容易混的名词。TPM 是那颗芯片(或固件/软件实现)本身,行为由 TCG(Trusted Computing Group,可信计算组织)的规范定义;TSS(TPM Software Stack)是操作 TPM 的软件栈,Linux 上的事实标准是 tpm2-tss 项目,我们后面用的 tpm2_* 命令都来自它;“可信计算(Trusted Computing)“则是这整套思想的统称。这个领域早年因为可能被用于 DRM 而备受争议,但如今它在全盘加密、可信启动上的价值已经无可替代。

从一个问题开始

假设你怀疑自己的笔记本被人动过——比如出差时把电脑落在了酒店房间。攻击者只要几分钟,就能换掉你的引导程序,装一个看起来一模一样、但会偷偷记录你磁盘解锁密码的恶意版本(这就是第八章要细讲的 Evil Maid 攻击)。

问题来了:下次开机时,系统怎么知道自己没被动过手脚?

靠操作系统自己检查是不行的。如果攻击者已经控制了引导程序,他同样能控制检查结果,让一切看起来“正常“。你需要一个在攻击者够不着的地方做检查的东西。

这就是 TPM 存在的意义。更准确地说,TPM 给出的回答分两步:第一步,开机过程中的每一环都被算成哈希、存进一个攻击者改不了的寄存器;第二步,把重要的秘密(比如磁盘密钥)锁在“这些寄存器必须是这个样子“的条件上。攻击者动了任何一环,寄存器就变了,秘密就取不出来。这一章剩下的内容,就是把这两步里的每个概念讲清楚。

信任链:每一级先量一量下一级

先建立一个直觉:电脑启动是一级一级移交控制权的接力赛——

固件(UEFI) → 引导程序(Bootloader) → 内核 → initramfs → 操作系统

可信启动的思路是:每一级在把控制权交给下一级之前,先给下一级“量一量“——算出它的哈希值,记下来。 这个动作叫度量(Measure)。固件度量引导程序,引导程序度量内核,一环扣一环,形成信任链(Chain of Trust)。

关键在于,度量结果被记在哪里。如果记在普通内存或磁盘里,攻击者改了系统之后顺手把记录也改了,白搭。所以度量结果必须记在一个攻击者改不了的地方——TPM 芯片里的 PCR。

只要链条的起点(第一段代码)是可信的,而每一级都忠实地度量了下一级,那么链条末端的人只要检查这些度量记录,就能知道整条链上跑过的每一段代码是什么。

这个起点有个专门的名字:核心信任度量根(Core Root of Trust for Measurement, CRTM)。它是固件里第一段执行、负责发起度量的代码,被设计为不可更新(或者更新本身就受硬件保护)——因为它是整个链条里唯一“无条件被信任“的环节,它要是能被改,后面所有的度量就都是演戏。PCR 0 里记录的第一条度量,往往就是固件主体对 CRTM 之外部分的度量。

思考

注意这里的一个微妙之处:TPM 并不能“阻止“恶意代码运行,它只能保证“恶意代码跑过之后一定留下抹不掉的证据“。记录和控制是两回事——这正是后面“度量启动 vs Secure Boot“一节要展开讲的。

信任根:TPM 凭什么可信

信任总得有个起点,这个起点叫信任根(Root of Trust)。TPM 作为信任根,靠三样东西:

物理防篡改。 TPM 是一颗独立的芯片(或者以固件形式运行在 CPU 的受保护区域里),它的内部存储普通软件够不着。攻击者控制了操作系统,也读不到 TPM 里保存的密钥,改不了 PCR 的值。要动它,得物理拆片、用显微镜探针这种级别的手段——成本完全不是一个量级。

密钥不出芯片。 TPM 里生成的私钥可以设置为永远不离开芯片。加密、签名这些操作在芯片内部完成,外界只能拿到结果。即使你的系统被 rootkit 完全控制,攻击者也只能“使用“密钥(而且还得过授权这一关),无法“偷走“密钥。

规范约束。 TPM 的行为由 TCG(Trusted Computing Group,可信计算组织)制定的公开规范严格定义。PCR 怎么更新、密钥怎么派生、授权怎么校验,都是白纸黑字,各家厂商(Intel、AMD、英飞凌、STMicro,以及软件实现 swtpm)遵守同一套规范。本书基于 TPM 2.0 规范。

注意

市面上“TPM“有三种形态:独立芯片(dTPM)、CPU 内置固件实现(Intel PTT / AMD fTPM)、纯软件模拟(swtpm)。本书实验用 swtpm,行为与真实 TPM 2.0 一致——规范保证了这一点。

另外,查资料时你还会碰到 TPM 1.2 这个老版本。它和 2.0 的差别不是“升级“而是“重设计“:1.2 只支持 SHA-1、只有一套固定的密钥结构,安全性和灵活性都已过时。2016 年后的机器基本都是 2.0,Windows 11 更是强制要求。网上 1.2 时代的教程(用的是 tpm_sealdata 这类老工具)和本书的命令完全不通用,看到请直接跳过。

PCR:只能加、不能减的寄存器

平台配置寄存器(Platform Configuration Register, PCR)是 TPM 里一组特殊的寄存器,每个存一个哈希值。TPM 2.0 通常提供 24 个 PCR,编号 0 到 23。

PCR 的灵魂在于它的更新方式。你不能往里写新值,唯一的操作叫 extend(扩展)

新值 = Hash(旧值 || 新度量)

|| 是拼接。也就是说,每次度量把“当前的 PCR 值“和“新度量值“拼在一起算哈希,结果覆盖原值。

这个简单到有点简陋的公式,带来三个极其重要的性质:

不可逆。 PCR 的初始值是全零。一旦 extend 过,没有任何办法把它退回去。想回到某个历史值,唯一的方法是重启整机(大部分 PCR 在重启时清零)。软件做不到,root 做不到,攻击者也做不到。

顺序敏感。 Hash(A || B)Hash(B || A) 完全不同。所以 PCR 的值不仅记录了“度量过什么“,还记录了“按什么顺序度量的“。启动链上任何一环被替换、增删、调序,最终的 PCR 值都会变。

历史不可伪造。 想伪造一个“正常“的 PCR 值,攻击者必须找到一段恶意代码,使得 Hash(旧值 || 恶意代码的度量) 恰好等于预期值——这等于对哈希函数做原像攻击,在密码学上不可行。

把这三条连起来,一个只有两次度量的小例子就能看清全过程。假设启动中依次度量了两段代码 M1、M2,PCR 的演化是:

0x000...000                              # 开机初始值
extend(M1) → Hash(0x000...000 || M1)
extend(M2) → Hash(Hash(0x000...000 || M1) || M2)

只要攻击者把 M1 换成 M1’,最终值立刻不同。就算他本事大到连 M2 也一起换成某个 M2’,想让两步之后的结果碰巧撞上原值——那等于找到哈希函数的碰撞,SHA-256 目前做不到。第三章的实验 A,我们会用真实命令把这个过程亲手跑一遍。

注意

真实的 TPM 里,PCR 不是“24 个“,而是“24 个 × 若干组“。同一编号的 PCR 按哈希算法分成多个 bank(常见的有 sha1 和 sha256 两组),每次度量通常同时 extend 所有 bank。本书一律使用 sha256 bank,这是当前的事实标准;sha1 bank 只是历史兼容的摆设,不要依赖它。

“PCR 为什么不能重置“是新手最常见的困惑。答案:如果能重置,前面的一切就毫无意义了——攻击者改完系统,把 PCR 重置回正常值即可。“不可重置“不是缺陷,是整个设计的核心。少数 PCR(如 PCR 16)特意允许在运行中重置,那是留给调试和实验用的后门,本书实验 A 正好用它当道具。

PCR 分工:每个编号管什么

24 个 PCR 不是随便用的,规范给它们分了工:

PCR阶段度量内容
0固件BIOS/UEFI 固件代码(CRTM,信任链起点)
1固件固件配置与平台数据
2固件可选 ROM(如显卡、网卡固件)
3固件可选 ROM 配置
4固件引导管理器(Bootloader)代码
5固件引导管理器配置、GPT 分区表
6固件平台厂商保留
7固件Secure Boot 状态与策略(第八章的主角)
8–9操作系统内核、initramfs、内核命令行等
10操作系统IMA 运行时度量(第九章的主角)
11操作系统systemd 系的度量:UKI 各组成部分、系统状态等
12–15操作系统内核命令行、系统扩展镜像等(具体随发行版而异)
16调试调试用途,运行中可重置(实验 A 的道具)
17–22动态动态信任根(DRTM)相关
23应用应用程序专用,运行中可重置

不需要背。现在只要建立两个印象:PCR 0–7 记录固件阶段发生了什么,8 以后记录操作系统阶段发生了什么。每个实验用到哪个 PCR,到时候再细讲。

一个常见的误解是“TPM 在度量启动过程“。不对——TPM 自己什么都不度量,它只是个忠实的记账员:固件、引导程序、操作系统各自算好哈希,调用 extend 写进 PCR。度量是谁做的、度量了什么、写得对不对,TPM 一概不管。这解释了为什么同一块 TPM 芯片,换个发行版 PCR 内容就完全不同——行为是启动链上的软件决定的,不是芯片决定的。

注意

不同发行版、不同引导方式,往 PCR 里度量的内容和顺序差别很大。这也是第五章实验 C 的价值所在——不看文档,直接从你自己机器的度量日志里把真实过程挖出来。

度量日志:信任链的账本

PCR 里存的是哈希链的“最终结果“,但它有一个天然缺陷:你没法从一个 PCR 值反推出“到底度量了些什么“。好比看到考试总分,却不知道每科成绩。

所以系统在度量的同时,还会把每一条度量记录——度量了什么、算出的哈希是多少、进了哪个 PCR——追加到一份日志里,这就是度量日志(Event Log)。Linux 下它暴露在 /sys/kernel/security/tpm0/binary_bios_measurements

日志存在普通存储里,理论上可以被篡改——但它改不了 PCR。校验方法很妙:把日志从头到尾“重放“一遍,按 extend 公式逐条计算,如果算出的最终值和 PCR 真实值一致,说明日志没有被改动过;不一致,说明日志在撒谎。PCR 是锚,日志是账本,账本的可信度由锚背书。

这份日志是第五章实验 C 的主角:我们会把它解析出来、逐条重放、和真实 PCR 比对。到时候你会看到,启动那几秒钟里,固件和 systemd 到底往 PCR 里记了些什么。

思考

日志本身要不要保护?不用——这正是这套设计漂亮的地方。篡改日志只会让“重放值 ≠ PCR 值“,反而暴露自己。攻击者唯一能做的是连 PCR 一起骗,而我们前面说过,PCR 骗不了。不可信的存储 + 可信的锚 = 可信的账本,这个思路在远程证明里还会再次出现。

度量启动 vs Secure Boot:监控摄像头与门禁

这是 TPM 学习路上最容易混淆的一对概念,必须掰开揉碎。

Secure Boot(安全启动) 是“门禁“:每一级检查下一级的数字签名,签名不对就拒绝执行。它控制的是“允许什么运行“。固件只放行由可信密钥签名的引导程序,引导程序只放行签名正确的内核。攻击者的恶意代码没有合法签名,直接被挡在门外。

度量启动(Measured Boot) 是“监控摄像头“:不管你是谁、签名对不对,只要你跑过,就把你录下来(度量进 PCR)。它不阻止任何事,只保证留下不可抵赖的记录。

两者解决的是不同层面的问题,而且是互补的:

  • Secure Boot 的问题在于“信任是静态的“——签名密钥一旦泄露,或者你自己装了带合法签名但有后门的软件,门禁照开。而且很多 Linux 发行版默认不开 Secure Boot。
  • 度量启动的问题在于“只记录不干预“——恶意代码照样跑完了才留下证据,单看这一点似乎没用。

但两者结合,化学反应就发生了:Secure Boot 尽量让坏事不发生,度量启动保证漏网之鱼无所遁形。而 PCR 里这些不可抵赖的记录,配合下面要讲的 Seal 和远程证明,就能变成实实在在的安全能力。

补一句 Linux 世界的现状:Secure Boot 在 Windows 上是默认开启的标配,在 Linux 上却长期是“可选的麻烦“——要自己处理签名密钥、和硬件厂商预置密钥的兼容等问题,所以很多发行版装完默认是关的。与之相对,度量启动只要有 TPM 就天然在工作,不需要任何配置。这意味着大多数 Linux 机器处在“摄像头开着、门禁没装“的状态——这既是现状,也是第八章要解决的遗憾。

思考

一台没开 Secure Boot 的 Linux 机器,度量日志照样在忠实记录一切(本书的实验环境正是如此)。这解释了为什么第八章要专门讨论 Secure Boot——度量再完整,如果引导程序本身可以被随意替换而不受任何约束,攻击者仍有可乘之机。个中细节,第八章展开。

Seal / Unseal:把秘密绑在环境状态上

有了 PCR 这个“不可伪造的环境指纹“,一个极其实用的能力出现了:Seal(密封)

思路很直白:把一个秘密(比如 LUKS 的磁盘解锁密钥)加密存储,解密的条件不是“知道口令“,而是“PCR 处于指定状态“。也就是说:

只有当系统按照预期的方式、用预期的组件启动到这个状态时,秘密才解得开。启动过程里任何一个环节变了——换了内核、改了命令行、动了引导程序——PCR 值就变了,秘密就永远解不开了。

这个过程叫 Seal(加密绑定)和 Unseal(解封)。校验在 TPM 芯片内部完成:解封时 TPM 比较当前 PCR 和策略要求的值,不一致就直接拒绝,密钥连内存都不会进。

Windows 的 BitLocker 就是这个原理:磁盘密钥被 Seal 在“PCR 0/2/4/7 等寄存器的预期值“上。正常开机,PCR 对得上,自动解锁,你感觉不到 TPM 的存在;有人换了引导程序想套取密码,PCR 对不上,BitLocker 立刻转入恢复模式,要求输入 48 位恢复密钥。

第六章的实验 D,就是要在 Linux 上亲手复刻这套机制。

思考

敏锐的读者这里应该已经发现问题了:内核是要升级的。升级之后 PCR 值变了,秘密不就永远解不开了?确实——这是“把秘密绑死在某几个 PCR 的具体值上“的天然缺陷。工程上有几种解法:升级后重新 Seal、改用对未来 PCR 值的签名授权(PolicyAuthorize,第七章的主题)、或者只绑 Secure Boot 状态而不绑具体度量值。这个话题是 TPM 实践中最核心的权衡,我们先记住问题,第七章再解开它。

远程证明:向另一台机器证明“我是我“

Seal 解决的是“本地解锁“,同一套思路延伸到网络世界,就是远程证明(Remote Attestation)。

场景:一台服务器接入内网前,网络策略服务器想确认“这台机器跑的是不是我们认可的固件、内核和配置,没被改过“。做法是 TPM 用专门的证明密钥(Attestation Key, AK)对当前 PCR 值做一份签名声明——这个操作叫 quote。对方拿到 quote,先验证签名确实来自一颗真实 TPM(AK 的证书链可以追溯到厂商预置的背书密钥),再比对 PCR 值是否符合预期,就知道这台机器的启动链干不干净。配合上一节的度量日志,对方甚至能看到每一个启动组件的具体身份,而不只是“合格/不合格“。

本书不深入远程证明的协议细节(那是进阶之后的路),但你要记住:quote 和 Seal 是同一枚硬币的两面——都是“用 PCR 状态作为信任的证据“。第十章会告诉你接下来怎么学。

注意

远程证明有个绕不开的隐私问题:如果每次 quote 都直接用厂商预置的背书密钥(Endorsement Key, EK)签名,验证方就能把“这颗 TPM“跨场合关联起来,等于给机器发了一张全球追踪的身份证。实际部署里用 AK 加隐私 CA 的间接证明来缓解这一点。知道有这回事就够了,细节属于进阶内容。

密钥层级:种子、主密钥与子密钥

最后讲 TPM 怎么管理密钥。新手常有的疑问是:一颗芯片能存多少个密钥?答案是:基本不用存

TPM 内部有一组出厂或初始化时生成的种子(Seed),几十字节,永不离开芯片。从种子出发,用确定性算法派生出主密钥(Primary Key)——“确定性“意味着同样的种子每次派生出完全相同的主密钥,所以主密钥不需要保存,用的时候重新派生就行。

主密钥再作为父密钥,加密保护任意数量的子密钥(Child Key)。子密钥的私钥部分被父密钥加密后,可以导出成文件随便存放——离开 TPM 也是密文。要用的时候加载回 TPM,在芯片内部解密、使用,私钥全程不以明文出现在芯片外。

这个“种子 → 主密钥 → 子密钥“的三层结构,让一颗只有几十 KB 存储的芯片能管理理论上无限多的密钥,而且保证了“密钥不出芯片“这条铁律。实验 B 里我们会创建主密钥、在树下挂载 Seal 对象,到时你会对这套机制有手感。

注意

TPM 2.0 里其实不止一颗种子,而是按用途分成了几棵独立的“层级(Hierarchy)“:背书层级(Endorsement Hierarchy,厂商相关,远程证明用)、存储层级(Storage Hierarchy,用户日常使用,实验 B 就用它)、平台层级(Platform Hierarchy,固件专用)。新手阶段只需知道:日常创建密钥时选存储层级即可,不同层级的种子互不相干,Clear(清空)一个层级不会动到另一个。

每个密钥和对象还带有一套**授权(Authorization)**设置:谁可以用它、在什么条件下用。最朴素的是口令授权,最常用的是 PCR 策略授权(“PCR 是这个值才允许用”),最灵活的是第七章要讲的 PolicyAuthorize。授权体系是 TPM 的另一半功力,后面会专门展开。

口令授权有个反直觉的保护机制:字典攻击防护(Dictionary Attack, DA)。连续输错几次口令后,TPM 会进入锁定状态,一段时间内拒绝一切授权尝试——哪怕口令是对的。不少人第一次配置时输错口令,之后怎么试都失败,以为 TPM 坏了,其实只是被锁了,等锁定计时器归零(或重启 TPM)即可。实验章节里如果撞上这个坑,别慌。

最后泼点冷水:TPM 不能做什么

概念地图的最后,划一下边界,避免不切实际的期待:

  • TPM 不是杀毒软件。它不扫描、不拦截任何恶意代码的运行,只做度量和保管。
  • TPM 很慢。签名、解封都是毫秒到百毫秒级的操作,不适合做高频加解密——磁盘全盘加密仍然是 CPU 干的活,TPM 只负责保管那把密钥。
  • TPM 保护不了运行时。恶意代码如果已经在跑,照样能读内存里的明文数据。TPM 管的是“启动时放行不放行密钥“,管不了“放行之后的事“(第九章的 IMA 把这条边界往外推了一步)。
  • TPM 防不了拿到你恢复密钥的人。BitLocker 的 48 位恢复密钥一旦泄露,TPM 形同虚设。安全链条的强度永远由最弱一环决定。
  • TPM 不替你做决策。PCR 里记了什么、哪些值算“正常“、解封条件怎么设,全是配置问题。配置错了,TPM 会忠实地执行错误的策略。

记住这些边界,后面的实验里你会反复体会到它们。

小结:全书地图

这一章信息量不小,我们回收一下。核心其实就一条主线:TPM 用 PCR 把启动过程变成不可伪造的证据,再用 Seal 和 quote 把这份证据变成“满足条件才给密钥“的实际能力。 其余的——信任链、密钥层级、授权——都是围绕这条主线的支撑结构。

这些概念会在后面的章节里逐个变成你亲手跑过的命令:

概念对应章节你将亲手做的事
PCR 哈希链第三章 · 实验 A手动 extend PCR,验证顺序敏感和不可逆
Seal / Unseal第四章 · 实验 B把秘密绑到 PCR,模拟篡改后解封失败
信任链的度量记录第五章 · 实验 C解析固件度量日志,重放并与真实 PCR 比对
Seal 的实际应用第六章 · 实验 DLUKS 密钥绑 TPM,开机自动解锁
PCR Policy / 授权第七章为什么“绑死 PCR“不够用,PolicyAuthorize 怎么解
Secure Boot 的意义第八章Evil Maid 攻击:PCR 7 的盲区与对策
IMA 运行时度量第九章PCR 10:度量延伸到文件执行层面

下一章我们先把实验环境搭起来——一台带软件 TPM 的虚拟机,玩坏了快照回滚。之后就可以放心大胆地动手了。

第二章 · 搭建零风险实验环境:虚拟机 + 软件 TPM

上一章我们把信任链、PCR、度量这些概念过了一遍。这一章动手搭环境。整个搭建过程不碰任何真实 TPM 芯片,所有实验都在虚拟机里完成,玩坏了删掉重建就行。

本章目标

  • 说清楚为什么用“虚拟机 + 软件 TPM“而不是直接用真机
  • 检查宿主机前提,装齐软件包
  • virt-install 创建一台带 UEFI 固件和 TPM 2.0 的虚拟机
  • 验证虚拟机里的 TPM 能正常工作,并拍一个快照作为“后悔药“

背景知识:为什么用虚拟机 + swtpm

我学习的宿主机是一台 deepin Linux 笔记本,本身就有真实的 TPM 2.0 芯片:

$ ls /dev/tpm*
/dev/tpm0  /dev/tpmrm0
$ cat /sys/class/tpm/tpm0/tpm_version_major
2

按理说我可以直接在这台机器上做实验,但我没有。原因有三个。

第一,玩坏了代价太高。 TPM 实验会改 PCR、写 NVRAM、绑 LUKS 解锁。真机上翻车,轻则系统起不来,重则加密数据解锁不了。虚拟机玩坏了,删掉重建,几分钟的事。

第二,TPM 直通(passthrough)体验很差。 可能有人会想:把宿主机的真 TPM 直通给虚拟机,不就两全了?行不通。一个 TPM 设备同一时刻只能给一个系统用,直通给虚拟机后宿主机自己就失去 TPM 了,而且 PCI 资源直通涉及 IOMMU 分组、驱动解绑等一堆配置,折腾半天还没法在多虚拟机间共享。

第三,软件 TPM 是行业标准做法。 swtpm 是一个用软件完整实现 TPM 2.0 规范的模拟器,由 QEMU/libvirt 生态原生支持。Keylime(远程证明框架)、systemd 的测试套件,CI 里跑的都是 swtpm。对上层软件来说,虚拟机里的 swtpm 和真实的 TPM 2.0 芯片行为一致——内核看到的是同一个 /dev/tpm0,tpm2-tools 的每一条命令表现相同。我们学的是 TPM 的语义和行为,不是芯片电气特性,软件 TPM 完全够用。

注意:swtpm 模拟的是 TPM 的功能行为,不能用来评估真芯片的安全属性(比如防物理攻击)。生产环境的威胁模型另说,学习环境用它没有水分。

检查宿主机前提

只需要两样东西:KVM 支持和磁盘空间。

$ ls /dev/kvm
/dev/kvm

没有 /dev/kvm 的话,去 BIOS/UEFI 设置里把虚拟化(Intel VT-x 或 AMD-V)打开。

磁盘方面,虚拟机会用一个 qcow2 格式的磁盘文件,上限 40G,但 qcow2 按需增长,刚装完系统实际只占几个 G,宿主机留出 10G 以上富余就够了。

安装软件包

Debian / deepin / Ubuntu 系:

sudo apt update
sudo apt install -y virt-manager qemu-system-x86 qemu-utils \
    swtpm swtpm-tools ovmf tpm2-tools

各包的作用:

  • qemu-system-x86:虚拟机本体
  • virt-manager:图形管理界面,附带 virt-installvirsh 等命令行工具
  • qemu-utils:提供 qemu-img 等磁盘工具
  • swtpm / swtpm-tools:软件 TPM 模拟器及管理工具
  • ovmf:开源 UEFI 固件(Open Virtual Machine Firmware),后面详细说
  • tpm2-tools:TPM 2.0 命令行工具集,装一份在宿主机上没坏处

注意:其他发行版的对应包名——Arch:virt-manager qemu-full swtpm edk2-ovmf tpm2-tools;Fedora:virt-install qemu-kvm swtpm swtpm-tools edk2-ovmf tpm2-tools。装完后确认 swtpm 命令存在即可,libvirt 启动虚拟机时会自动拉起它,不需要你手动起服务。

创建虚拟机

本书实验用的系统是 Omarchy(基于 Arch,Limine 引导 + UKI 统一内核镜像 + LUKS 全盘加密),但你用任何支持 UEFI 安装的 Linux 发行版都行。先下载好安装 ISO,然后:

virt-install --connect qemu:///system \
  --name omarchy-tpm-lab \
  --memory 4096 --vcpus 4 \
  --disk size=40,format=qcow2,bus=virtio \
  --cdrom /path/to/os.iso \
  --boot loader=/usr/share/OVMF/OVMF_CODE_4M.fd,loader.readonly=yes,loader.type=pflash,loader_secure=no,nvram.template=/usr/share/OVMF/OVMF_VARS_4M.fd \
  --tpm backend.type=emulator,backend.version=2.0,model=tpm-crb \
  --network network=default,model=virtio \
  --graphics spice,listen=none \
  --video virtio \
  --osinfo detect=on,name=linux2022 \
  --noautoconsole

逐个解释关键参数:

  • --connect qemu:///system:连系统级 libvirt 守护进程(权限完整),而不是用户级的 qemu:///session
  • --disk size=40,format=qcow2,bus=virtio:40G 上限的 qcow2 磁盘,virtio 总线性能最好
  • --cdrom:指向你下载的安装 ISO,装完系统后 libvirt 会自动处理
  • --boot loader=...,nvram.template=...:显式指定 UEFI 固件,这是整条命令里最容易踩坑的部分,下面细讲
  • --tpm backend.type=emulator,backend.version=2.0,model=tpm-crb:核心参数。emulator 表示用 swtpm 模拟;2.0 指定 TPM 版本;tpm-crb 是 CRB(Command Response Buffer)接口模型,比老的 TIS 接口快,现代系统都认它
  • --graphics spice,listen=none:SPICE 显示协议,只监听本地套接字
  • --osinfo detect=on,name=linux2022:让 libvirt 按通用现代 Linux 优化默认配置(比如默认启用 virtio)
  • --noautoconsole:创建完不要自动弹控制台,我们手动连(原因见坑 2)

为什么必须 UEFI:本书后面所有内容——度量启动(Measured Boot)、PCR 7、Secure Boot——全部建立在 UEFI 固件之上。传统 BIOS 引导的虚拟机没有 UEFI 度量日志,实验 C 直接没法做。所以固件必须是 OVMF,这一点没有商量余地。

关于 Secure Bootloader_secure=no 表示 Secure Boot 默认关闭。这不是偷懒,是刻意安排——先关掉它,看清没有 Secure Boot 时度量在做什么,第八章再打开它,对比才深刻。

坑 1:为什么不用简单的 --boot uefi

virt-install 其实支持一个更省事的写法:--boot uefi,让它自动探测 OVMF 固件。网上很多教程就这么写。但它在 deepin 上翻车了:

ERROR    无法完成安装:'内部错误:process exited while connecting to monitor:
... 打开文件失败 /usr/share/OVMF/OVMF_VARS_4M.ms.fd: 没有那个文件或目录'

排查过程是这样的:--boot uefi 时,libvirt 会去读 /usr/share/qemu/firmware/*.json 里的固件描述符,按特性匹配选一个。它选中了带 .ms 后缀的变体(OVMF_VARS_4M.ms.fd,微软签名版,为 Secure Boot 预置了密钥)。问题是 deepin 的 ovmf 包根本没打包这个文件,于是 QEMU 启动时打不开固件 NVRAM,直接挂掉。

解法就是上面命令里那样,绕开自动探测,把 loader(固件代码,只读)和 nvram.template(NVRAM 模板,每虚拟机可写)两个文件路径都显式写死。写之前可以确认一下它们确实存在:

$ ls /usr/share/OVMF/
OVMF_CODE_4M.fd  OVMF_VARS_4M.fd  ...

:这个教训值得记住——发行版打包的内容和 libvirt 的期望不一致时,自动探测选出来的东西可能根本不存在。显式指定比自动探测可靠,报错信息也更好懂。

坑 2:连不上控制台

虚拟机创建好之后会自己启动进入安装界面,但如果你直接敲 virt-viewer omarchy-tpm-lab,大概率会收到:

根据错误信息,只能通过 libvirt 使用 --attach 连接显示器

正确姿势:

virt-viewer --connect qemu:///system --attach omarchy-tpm-lab

或者干脆打开 virt-manager 图形界面,双击虚拟机名字,效果一样。接下来就是在安装界面里正常装系统,分区、LUKS 加密、引导器这些按发行版的安装流程走即可。

装好系统后的第一件事:验证 TPM,拍快照

系统装好、重启进系统之后,先做两件事。

验证 TPM 在线:

$ cat /sys/class/tpm/tpm0/tpm_version_major
2
$ tpm2_pcrread
sha1:
  0 : 0xD2432A6266FB4F7C129003242D452DDEF729674C
  1 : 0x53E70DD1F90743A8CFF06256E0CFACF0C4F2AFBB
  ...
sha256:
  0 : 0x2853F792C876EA7AEBAAC4D86002BDF3A10C97C05A0830985F869F807BBD02E3
  1 : 0x51825B1F11600E6E4B89B32A54EA5E20E16C66D50E8CBE28A0719BC355E23821
  2 : 0x3D458CFE55CC03EA1F443F1562BEEC8DF51C75E14A9FCF9A7234A13F198E7969
  3 : 0x3D458CFE55CC03EA1F443F1562BEEC8DF51C75E14A9FCF9A7234A13F198E7969
  ...

(输出很长,这里只留几行示意,你的具体数值会和我的不同。)

两个观察点:

  1. tpm_version_major 输出 2,说明内核认到了 TPM 2.0,swtpm 工作正常。
  2. tpm2_pcrread 里 PCR 0-9 左右已经有值,而且不为全零。这说明还没等我们做任何事,固件和引导器已经在往 PCR 里写度量了——这就是第一章说的度量启动,它一直在发生,只是平时没人看。如果所有 PCR 都是全零,那才说明有问题。

拍快照:

virsh snapshot-create-as omarchy-tpm-lab clean-install "全新安装" --disk-only --atomic

这个快照就是后悔药。后面任何实验把系统搞挂了,一条命令回到刚装完的状态:

virsh snapshot-revert omarchy-tpm-lab clean-install

建议每章实验开始前都确认一下自己能回到这个点。

思考:快照只管虚拟机的磁盘。swtpm 的状态存在宿主机 /var/lib/libvirt/swtpm/ 下对应的目录里,随虚拟机的 libvirt 快照一起保存和恢复(--atomic 保证这个操作是原子的)。这就是为什么整套方案能做到“零风险“——连 TPM 的内部状态都有备份。

关于发行版的选择

本书所有命令都是在 Omarchy(Arch 系)里跑的。你用 Ubuntu、Fedora、Debian 的虚拟机跟着做也完全没问题,第三章到第五章的实验(PCR、Seal、度量日志)在任何发行版上命令都一样,只要装好 tpm2-tools

唯一的差异点在第六章:LUKS 绑定 TPM 后需要改造 initramfs(初始内存文件系统,initial ram filesystem),Arch 系用 mkinitcpio,Debian/Ubuntu 用 initramfs-tools,Fedora/RHEL 用 dracut,三家的钩子和配置方式都不一样。到时候我会给出各家的差异说明。

常用管理命令速查

virsh list --all                      # 列出所有虚拟机(含关机的)
virsh start omarchy-tpm-lab           # 开机
virsh shutdown omarchy-tpm-lab        # 优雅关机
virsh destroy omarchy-tpm-lab         # 强制断电(相当于拔电源,慎用)
virsh snapshot-list omarchy-tpm-lab   # 查看快照

小结

这一章我们确定了“虚拟机 + swtpm“的实验方案:安全、可重建、和真芯片行为一致,也是业界标准做法。宿主机上装好 KVM、QEMU、swtpm、OVMF 和 tpm2-tools 之后,用一条 virt-install 命令创建了带 UEFI 固件和 TPM 2.0 的虚拟机,踩过了 OVMF 固件文件缺失和控制台连接两个坑,最后在虚拟机里确认 TPM 在线、PCR 已有度量值,并拍下了 clean-install 快照。

环境已经就绪。下一章实验 A,我们直接对 PCR 下手:手动 extend 一个值进去,亲眼看哈希链怎么滚动,验证“PCR 不能重置、只能单向演进“这条规则是不是真的。

第三章 · 实验 A:亲手验证 PCR 哈希链

本章目标

读概念的时候,PCR(平台配置寄存器,Platform Configuration Register)最容易让人产生“听懂了但不知道它到底是什么“的错觉。这一章我们直接上手:手动往一个 PCR 里写数据,然后用纯 shell 命令把 TPM 内部的计算过程一步步复算出来,和芯片给出的结果逐字节比对。

做完这个实验,你应该能建立起三个直觉:

  1. PCR 不是普通内存,不能被直接写入,只能被 extend(扩展)
  2. extend 的本质就是一次哈希:新值 = SHA256(旧值 ‖ 事件哈希)
  3. 这个结构让“历史“变得不可伪造——你没法悄悄改掉过去的某一步

背景知识:extend 到底在干什么

PCR 是一组存放在 TPM 芯片内部的寄存器,每个银行(bank)通常有 24 个。以 SHA-256 银行为例,每个 PCR 就是一个 32 字节的值,复位后全为零。

普通寄存器可以被覆写,PCR 不行。你唯一能对它做的写操作叫 extend

PCR_new = Hash(PCR_old ‖ digest)

注意是拼接()后再哈希,不是替换。这个设计有三个后果:

  • 不可逆:知道了新值,倒推不出旧值。
  • 顺序敏感:先 extend A 再 extend B,和先 B 再 A,结果完全不同。
  • 历史包含一切:当前值是之前每一次 extend 的函数,缺一不可。

真实启动时,固件把引导加载器的哈希 extend 进 PCR,引导加载器再把内核的哈希 extend 进去——一条哈希链就这么串起来了。而我们这一章要做的,就是手动扮演一次“固件“。

思考:为什么 TPM 要设计成“只能 extend 不能写“?如果允许直接写,任何能访问 PCR 的程序(包括恶意软件)都能把寄存器改回“干净“的值,度量就失去了意义。extend-only 是整件事的地基。

准备工作

实验环境是第二章搭好的虚拟机(Arch 系发行版 + swtpm)。装一下工具:

sudo pacman -S --needed tpm2-tools

确认系统能看到 TPM:

ls /dev/tpm*

预期输出:

/dev/tpm0  /dev/tpmrm0

tpm0 是字符设备,tpmrm0 是内核的资源管理器(resource manager),tpm2-tools 默认走后者。能看到这两个文件,说明 swtpm 已经正确挂上了。

选一个安全的 PCR

TPM 2.0 规范给 PCR 分配了用途:0–7 归固件和启动链用,8–15 归操作系统用,16–23 是调试用途,系统不会动它们。我们选 PCR 16 当试验田,随便折腾不影响系统。

先读一下当前值:

sudo tpm2_pcrread sha256:16

预期输出:

sha256:
  16 : 0x0000000000000000000000000000000000000000000000000000000000000000

全零。这是一张白纸。

第一次 extend

先算事件哈希。假设我们“度量“的对象是字符串 hello

echo -n "hello" | sha256sum

输出:

2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824  -

echo 默认会在末尾加一个换行符,哈希出来的就不是你以为的东西了。echo -n 这个 -n 不能省。真实启动链里度量的是二进制文件,没有这个问题,但我们手工模拟时必须小心。

把这个哈希 extend 进 PCR 16:

sudo tpm2_pcrextend 16:sha256=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

没有任何输出就是成功。再读一次:

sudo tpm2_pcrread sha256:16
sha256:
  16 : 0x9851312028952521510E8EAAB5BE94E7DC24B5FC292B2E9781173CF11FFA9878

值变了。这个值既不是 hello 的哈希,也不是全零——它是两者的某种组合。什么组合?下一节见分晓。

核心环节:手工复算 TPM 的算法

这是我们整章的重头戏。TPM 内部做的计算,规范里写得很清楚:

PCR_new = SHA256(PCR_old ‖ digest)

旧值是全零(32 个零字节),事件哈希我们已经有了。用纯 shell 把这个拼接做出来:

{ head -c 32 /dev/zero; echo "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" | xxd -r -p; } | sha256sum

拆解一下这条命令:

  • head -c 32 /dev/zero:产出 32 个零字节——这就是 PCR 的旧值;
  • echo "..." | xxd -r -p:把十六进制字符串转成原始字节(哈希计算作用在字节上,不是作用在十六进制字符上,这一步少了结果就全错);
  • 大括号把两段输出拼在一起,一共 64 字节,送进 sha256sum

输出:

9851312028952521510e8eaab5be94e7dc24b5fc292b2e9781173cf11ffa9878  -

把它和上面 tpm2_pcrread 读到的 0x9851312028...ffa9878 对比:一字不差

这一刻值得停下来体会一下。我们手里没有任何 TPM 的私钥、没有调用任何特权接口,就用 /dev/zerosha256sum,完整复现了芯片内部的运算。PCR 的机制没有任何魔法——它就是一条公开的、可验证的哈希链。TPM 的价值不在于计算本身(这个谁都能算),而在于芯片内部的那个中间值你无法绕过、无法篡改、无法伪造

第二次 extend:感受“链“

再 extend 一个 world,看看链式结构:

W=$(echo -n "world" | sha256sum | cut -d' ' -f1)
sudo tpm2_pcrextend 16:sha256=$W

复算这一次的新值,注意这次的旧值不再是零,而是上一步的结果:

P=9851312028952521510e8eaab5be94e7dc24b5fc292b2e9781173cf11ffa9878
{ echo "$P" | xxd -r -p; echo "$W" | xxd -r -p; } | sha256sum

输出:

98d128df384d428ffe76af3c0198ff1e8945ef71e741ba440bafff0510da8f22  -

读 PCR 验证:

sudo tpm2_pcrread sha256:16
sha256:
  16 : 0x98D128DF384D428FFE76AF3C0198FF1E8945EF71E741BA440BAFFF0510DA8F22

又对上了。现在 PCR 16 的值同时“包含“了 hello 和 world 两次度量,而且顺序锁死在里面:如果你反过来先 extend world 再 extend hello,得到的会是完全不同的值。可以自己试一下——顺序错一位,结果面目全非。

重置:有的 PCR 可以,有的不行

PCR 16 是调试寄存器,允许在运行时复位:

sudo tpm2_pcrreset 16
sudo tpm2_pcrread sha256:16
sha256:
  16 : 0x0000000000000000000000000000000000000000000000000000000000000000

回到全零了。现在试试 PCR 0:

sudo tpm2_pcrreset 0
ERROR: Tss2_Sys_PCR_Reset(0x184) - tpm:error(2.0): locality is not the correct locality to perform the command

被拒绝。错误信息里的 locality(局部性)先不用深究,结论记住就行:PCR 0–15 只允许在平台复位(重启)时清零,运行中的操作系统——哪怕是 root——也无权重置它们。

这说明什么

这个实验动的是无关紧要的调试寄存器,但它演示的机制就是真实可信启动的全部核心:

第一,历史不可伪造。 想篡改 PCR 的当前值,只有两条路:extend 一个能“凑“出目标值的新哈希(哈希原像攻击,计算上不可行),或者重置重来(对 PCR 0–15 做不到)。改历史这条路被从物理上堵死了。

第二,真实启动时固件做的事,和你手动做的事一模一样。 开机后固件度量下一级组件,算出哈希,extend 进 PCR 0;引导加载器度量内核,extend 进 PCR 4……全是同一套 新值 = Hash(旧值 ‖ 摘要)。等你登录系统后 tpm2_pcrread 看到的 PCR 0–9 那些非零值,就是这么一步步长出来的。没有任何神秘环节。

第三,root 也清不掉 PCR 0–7。 这意味着哪怕攻击者拿到了最高权限,也无法抹除启动链留下的度量痕迹。唯一能改变这些 PCR 的办法是重启、重走一遍启动链——而重启后的每一次度量又会留下新的痕迹。篡改不是“会不会被发现“的问题,是“必然留下证据“的问题。

小结

  • PCR 只能 extend,不能直接写;新值 = Hash(旧值 ‖ 事件哈希)
  • 我们用 /dev/zero + xxd + sha256sum 手工复算了 TPM 的内部运算,逐字节吻合
  • 链式结构让顺序敏感、历史不可伪造
  • PCR 16 可复位,适合当试验田;PCR 0–15 运行时不可复位,root 也不行

读到这里你可能会问:既然 PCR 记录的是启动历史,那它除了“看看“还有什么用?问得好——光记录不拦人确实没用。下一章我们就把 PCR 变成一把锁。

第四章 · 实验 B:Seal/Unseal——把秘密绑在环境状态上

本章目标

上一章验证了 PCR 哈希链的真实性,但留下了一个尖锐的问题:记录历史有什么用?如果篡改者照样能进系统、照样能读数据,度量岂不成了摆设?

这一章回答这个问题。我们要用 TPM 最实用的能力——封存(Seal)与解封(Unseal):把一个秘密加密存放在 TPM 的保护之下,并且附加一个条件——只有当指定的 PCR 还保持封存时的值,秘密才取得出来。启动链被改动哪怕一个字节,PCR 值就会变,秘密就永久锁死。

这就是 Windows BitLocker、Linux 下 systemd-cryptenroll --tpm2 自动解锁全盘加密背后的原理。走完本章流程,你对“开机自动解锁但又防得住偷硬盘“这件事就不会再觉得神秘了。

背景知识:Seal 是怎么回事

封存的输入有三样:

  1. 要保护的秘密:一段不超过 128 字节的数据(TPM 内部对象的上限)。实际系统里封存的一般不是文件本体,而是一个对称密钥——比如 LUKS 的卷密钥。
  2. 一个父密钥:秘密最终被 TPM 里的密钥加密保护。我们通常先创建一个主密钥(primary key)当父密钥。
  3. 一个策略(policy):本章用的是 PCR 策略——“只有 PCR 的值等于某某,才允许解封”。

产出的密封信封(sealed object)是两个普通文件,里面装着加密后的秘密和元数据。信封本身可以随便拷贝、随便备份,偷走也没用——没有“正确的 PCR 状态 + 原来那颗 TPM“,它就是一堆随机数。

解封时,TPM 会先检查策略:当前 PCR 值算出的策略摘要和信封里记录的摘要比对,一致才放行密钥材料。注意,检查发生在芯片内部,操作系统只能问“行不行“,无法绕过去。

有人第一次接触 Seal 时会问:这和普通加密有什么区别?区别在于解密的条件。普通加密的条件是“你有没有密钥“——密钥偷走了,防护就没了。封存的条件是“你是不是在那颗芯片上、运行着那个没被动过的环境“——密钥本身根本不以文件形式存在,偷无可偷。TPM 把“密钥保管“这个最难的环节,替换成了“环境度量“这个可验证的环节。

思考:这里要分清两件事。度量(第三章)是“记录“,它本身不拦任何人;封存是“执行“,它把秘密的可用性押在度量结果上。可信启动的防护力,全部来自这两者咬合在一起。

准备工作

建个工作目录,回到 PCR 16 的“干净状态“(全零):

mkdir -p ~/tpm-lab && cd ~/tpm-lab
sudo tpm2_pcrreset 16
sudo tpm2_pcrread sha256:16
sha256:
  16 : 0x0000000000000000000000000000000000000000000000000000000000000000

这里把 PCR 16 当道具用:它是可复位的调试寄存器,我们可以随时手动 extend 它来模拟“启动环境被篡改“,不用真的去改引导文件重启机器。真实系统绑定的是 PCR 7、PCR 11 这类不可复位的寄存器,道理完全一样,最后再说区别。

思考:为什么这就算“模拟篡改“?回忆第三章:启动链上任何一环被改动,对应的 PCR 就会被 extend 进一个不同的哈希,最终值必然不同。我们手动 extend 一次,制造的效果和“恶意程序混进启动链“在 TPM 眼里一模一样——都是 PCR 值偏离了封存时的状态。TPM 不需要知道恶意程序长什么样,这正是这套机制省力的地方。

第一步:创建主密钥

sudo tpm2_createprimary -C o -c primary.ctx

-C o 表示在 owner 层级(hierarchy)下创建,-c primary.ctx 把密钥上下文存成文件。输出较长,关键部分:

name-alg:
  value: sha256
  raw: 0xb
...
sym-keybits: 128
...

这个命令有个反直觉的性质值得讲透:你在同一颗 TPM 上重复执行它一万次,得到的都是同一把主密钥。主密钥不是随机生成的,而是从 TPM 出厂时烧进去的种子(seed)确定性派生出来的。这意味着:

  • 主密钥不需要备份——它永远在芯片里,想要的时候重新派生一次就行;
  • 换一颗 TPM,种子不同,主密钥就完全不同,旧芯片上封存的信封立刻作废。这是特性不是缺陷:秘密跟着芯片走。

primary.ctx 文件里存的只是这把密钥的句柄和公钥信息,不是私钥本体。私钥从生到死不出芯片。

顺带解释 -C o 里的“层级“:TPM 内部有几个权限域,owner 层级是日常使用的一个(还有 endorsement、platform 等,各有用途,第七章会展开)。现阶段只要知道“在 owner 层级下创建“是常规操作即可,不影响理解本章流程。

第二步:创建 PCR 策略

读出 PCR 16 的当前值(现在是全零),基于它创建策略:

sudo tpm2_createpolicy --policy-pcr -l sha256:16 -L policy.pcr16

-l sha256:16 指定锁定哪个 PCR,-L policy.pcr16 把策略摘要存成文件。这条命令做的事情是:读取 PCR 16 此刻的值,算出“只有它还是这个值才放行“的策略摘要。

policy.pcr16 里存的本质上是一个哈希值,可以叫它策略摘要(policy digest)。它由两部分信息决定:选了哪些 PCR、以及这些 PCR 此刻的值。之后封存时这个摘要被烙进信封,解封时 TPM 用同样的算法拿当时的 PCR 值重新算一遍摘要,两个摘要一致才放行。整个过程中信封里并不保存 PCR 值本身,只保存它的“指纹“——这也是为什么策略文件可以公开,它推不出任何秘密。

注意:策略绑定的是“值“,不是“数字 16 这个编号“。此刻 PCR 16 是全零,策略的含义就是“只有 PCR 16 还是全零才允许操作“。它什么时候变成别的值,策略就什么时候失效——至于为什么变、谁改的,TPM 不关心也不判断,它只认值。

第三步:封存秘密

echo -n "这是我的秘密" | sudo tpm2_create -C primary.ctx -L policy.pcr16 -u seal.pub -r seal.priv -i-

参数含义:

  • -C primary.ctx:用主密钥当父密钥保护这个信封;
  • -L policy.pcr16:把上一步的策略烙进信封;
  • -i-:从标准输入读秘密内容(echo -n-n 同样不能省);
  • -u seal.pub -r seal.priv:输出信封的公钥部分和加密部分。

成功后会看到类似输出:

name-alg:
  value: sha256
  raw: 0xb
...

现在目录里的 seal.pubseal.priv 就是密封信封。可以打开看一眼——十六进制的乱码,秘密本身一个字节都看不见。

tpm2_create 能封存的秘密上限是 128 字节,超过会直接报 size mismatch 之类的错误。别想着把整份文件塞进去——正确姿势是封一个随机生成的对称密钥,再用这个密钥在 TPM 之外加密数据本体。真实系统里封存的就是 LUKS 卷密钥这类东西,第六章会完整演示。

第四步:正常解封

解封分两步:先把信封加载进 TPM,再请求解封:

sudo tpm2_load -C primary.ctx -u seal.pub -r seal.priv -c seal.ctx
sudo tpm2_unseal -c seal.ctx -p pcr:sha256:16

第二条的 -p pcr:sha256:16 是声明授权方式:“这次操作我要用 PCR 16 策略来授权”。TPM 收到后检查当前 PCR 16 的值——还是全零,和策略吻合——放行。

注意-p 传的不是密码,是“授权方式的声明“。它告诉 TPM 这次会话用哪套规则来验证;真正的验证数据(PCR 当前值)由 TPM 自己去芯片里读,你给不了也给不了假的。这和传统口令认证“你说什么就信什么“的思路完全不同。

输出:

这是我的秘密

秘密原样吐出来了。环境没变,锁就开。

第五步:模拟篡改

现在扮演攻击者。假设有人在启动链里动了手脚——往引导环节植入了一段恶意程序。在我们的道具实验里,这等价于 PCR 16 被 extend 了一次:

sudo tpm2_pcrextend 16:sha256=$(echo -n "被植入恶意引导程序" | sha256sum | cut -d' ' -f1)

再试解封:

sudo tpm2_load -C primary.ctx -u seal.pub -r seal.priv -c seal.ctx
sudo tpm2_unseal -c seal.ctx -p pcr:sha256:16

这次 TPM 的回答是:

ERROR: Esys_Unseal(0x99D) - tpm:session(1):a policy check failed

a policy check failed——策略检查失败,拒绝。没有提示“是不是只改了一个字节“,没有“是否强制继续“的选项。TPM 不做善意推断,值不对就是不对。信封还在你手里,-p 参数你也照样传了,但秘密就是出不来。

:第一次踩这个报错时,我对着 0x99D 查了半小时,以为是参数传错了。其实就是字面意思:PCR 值和策略不一致。这个错误码在后续的实验和真实排障中会反复出现,记住它的含义能省很多时间。

第六步:恢复环境,再次解封

把 PCR 16 复位回全零(恢复“干净的启动环境“),再试:

sudo tpm2_pcrreset 16
sudo tpm2_load -C primary.ctx -u seal.pub -r seal.priv -c seal.ctx
sudo tpm2_unseal -c seal.ctx -p pcr:sha256:16
这是我的秘密

又成功了。策略检查的是当前值,值回来了,锁就开——TPM 不记仇。

这说明什么

流程走完了,但有几个推论必须掰开说清楚,它们直接关系到你怎么理解真实系统的安全性。

第一,我们能“恢复“,纯属道具的特殊照顾。 PCR 16 是可复位的调试寄存器,所以我们篡改完还能按回去。真实系统绑定的是 PCR 7、PCR 11 这类运行时不可复位的寄存器(第三章已经验证过,root 也清不掉它们)。攻击者改了启动链之后,没有任何手段把 PCR 改回原值——extend 不可逆,重置被拒绝,重启会重走一遍被篡改的启动链、留下一样的痕迹。等待他的不是“解封失败,请重试“,而是秘密永久锁死

第二,信封文件丢了也不心疼。 seal.pubseal.priv 可以任意拷贝、备份、放在 U 盘里随身带。攻击者偷走它们,面对的依然是两道锁:正确的 PCR 状态(得原样复现你的启动链)和原来那颗 TPM(主密钥的种子烧在芯片里)。两个条件缺一不可,而这两个条件他一个都带不走。这就是为什么 BitLocker 加密的硬盘拆下来插到别的机器上就是一块砖头。

第三,主密钥不依赖任何文件。 我们从头到尾没有备份过密钥,因为不需要——主密钥从种子确定性派生,芯片不换它就一直在。这意味着防护体系的根不在操作系统的文件系统里,文件系统被整个替换、被离线改写,都伤不到它。

第四,“自动解锁“和“安全“并不矛盾。 这是很多人初接触时的直觉障碍:开机不输密码就能解锁磁盘,岂不是谁开机都能读数据?现在你能回答了——解锁的前提是 PCR 策略通过,也就是固件、引导器、内核都和绑定时一致。主人正常开机,环境没变,自动解锁;攻击者换个引导 U 盘、改个内核参数、拆掉硬盘挂到别处,PCR 或芯片必有一项对不上,秘密出不来。便利性来自“对主人透明”,安全性来自“对篡改零容忍“,两者由同一把锁实现。

思考:这套机制有一个诚实的边界——它防的是“启动环境被改动“,防不了“合法环境自己做坏事“。如果恶意软件是在度量之后的系统里正常跑起来的,PCR 一切正常,秘密照样能解封。TPM 保证的是“你启动的就是你安装的那套东西“,至于那套东西本身可不可信,是另一个层面的问题(第九章的 IMA 会把度量延伸到运行时,部分回应这个问题)。

小结

  • 封存 = 秘密 + 主密钥保护 + PCR 策略;解封时 TPM 在芯片内部检查策略,不一致就直接拒绝
  • 主密钥从出厂种子确定性派生,不用备份,换芯片即作废
  • 信封文件可自由备份,脱离“正确 PCR + 原 TPM“就是废数据
  • 绑不可复位 PCR 时,篡改的后果是秘密永久锁死,没有回头路

到目前为止,PCR 16 一直是我们手里的道具。真实启动时,固件、引导加载器、内核到底往各个 PCR 里写了什么?那些值背后对应哪些文件、哪些事件?下一章我们解剖真实的度量日志,把启动链的每一步摊开看。

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

第六章 · 实验 D:LUKS 全盘加密绑定 TPM 自动解锁

前面三个实验都是“玩票“性质的:extend 几个 PCR、封一个小文件、读一读日志。这一章动真格的——把虚拟机的全盘加密(LUKS)绑定到 TPM 上,实现开机自动解锁。这基本就是 Windows 上 BitLocker + TPM 的 Linux 对应实现,也是 TPM 在家用场景里最有存在感的一个用法。

本章目标

改造前:每次开机都要在 initramfs 里敲一遍 LUKS 密码。

改造后:开机全程不用敲密码,TPM 自己确认“启动环境没被动过“之后,自动把卷密钥解封给 LUKS,直接进系统。

如果有人在固件层动了手脚——改了 Secure Boot 配置、换了平台密钥——TPM 拒绝交出密钥,机器退回密码提示。

背景知识:这件事为什么成立

第三章我们验证过 PCR 哈希链,第四章玩过 Seal/Unseal。本章做的事,用第四章的话说就是:把 LUKS 的卷密钥 Seal 到 TPM 的 PCR 状态上

  • 卷密钥本身不存进 TPM,而是以加密 blob 的形式放在 LUKS 头的 token 区里;
  • 开机时 initramfs 里的 systemd-cryptsetup 读 token,拿 blob 去找 TPM 解封;
  • TPM 检查当前 PCR 值和 seal 时绑定的 policy 是否一致,一致才吐出密钥。

所以“自动解锁“不是“去掉密码“,而是“多了一把由 TPM 保管的钥匙“。原密码 slot 一直留着,任何时候 TPM 不给面子,都能用密码兜底。理解这一点很重要,下面整个实验的安全性都建立在它上面。

第一步:侦察——我的磁盘到底长什么样

动全盘加密之前,先搞清楚现状。别跳过这一步,改启动链的翻车里有一半是“以为自己知道分区布局“。

lsblk -f
NAME   FSTYPE      FSVER LABEL UUID                                 FSAVAIL FSUSE% MOUNTPOINTS
vda
├─vda1 vfat        FAT32       1234-ABCD                              1019M     0% /boot
└─vda2 crypto_LUKS 2           43bf0994-f67b-4b1c-b247-8b5d0ca269f5
  └─root btrfs                 root-uuid-...                                       /

读法:

  • vda1 是 vfat 格式的 ESP(EFI 系统分区),挂在 /boot明文。UKI、引导器配置都在这,这不奇怪——它们本来就签着名、被度量着,不需要加密。
  • vda2 是 LUKS2 加密容器,UUID 是 43bf0994-f67b-4b1c-b247-8b5d0ca269f5,这个 UUID 后面改 cmdline 要用,抄下来。
  • root 是 LUKS 解开之后露出来的 dm 设备,里面是 btrfs 根文件系统。

插曲:我一度以为 LUKS 根本没生效。

有一次开机输密码时手滑敲错了,结果系统照样进来了。我当时心里一惊:这 LUKS 是摆设吗?

查完才知道不是:busybox 体系的 encrypt 钩子在密码错误时会重试,默认给 3 次机会。我敲错的那次只是第一次,第二次敲对了,自然就进来了——密码提示一闪而过,看起来像“没输也进了“。

教训:判断加密有没有生效,别靠“开机要不要密码“这种表面现象,lsblk -fcrypto_LUKS 下面套着 root 这层结构才是证据。设备层级不会骗你。

第二阶段之一:enroll——给 TPM 开一把新钥匙

侦察完,动手的第一步出乎意料地简单:

sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/vda2

它会提示你输入现有的 LUKS 密码。注意这个密码的用途:不是验证身份那么简单,而是用它解开卷密钥,然后为 TPM 这把“新钥匙“在 LUKS 头里开一个新的 keyslot。

这个阶段是零风险的。 enroll 只是新增一个 keyslot,不碰原有密码 slot。就算后面全搞砸了,密码永远能进。可以放心折腾。

为什么是 PCR 7

--tpm2-pcrs=7 这个参数值得停下来讲清楚,它是整个安全模型的核心选择。

PCR 7 度量的是 Secure Boot(安全启动)状态,具体包括 Secure Boot 开关、以及 PK / KEK / db / dbx 这些密钥和证书库的内容。选它的理由:

  • 稳定:Secure Boot 配置不会天天变。相比之下,PCR 4(bootloader、内核)、PCR 11(UKI 里的内核和 initrd)每次升级内核都会变,绑了它们意味着每次系统更新后都要重新 enroll。
  • 敏感得恰到好处:一旦有人在固件层动手脚——关掉 Secure Boot、换掉平台密钥、往 db 里塞自己的证书——PCR 7 立刻变,TPM 拒绝解封,自动解锁当场失效。这正是我们想要检测的那类攻击。

思考:PCR 7 够吗?

不够,它有盲区——bootloader 和 initramfs 被整个换掉时 PCR 7 可以保持不变。这个盲区本章先不展开,第八章讲 Evil Maid 攻击时会把它彻底讲透。目前先记住:绑 PCR 7 防的是“固件配置被篡改“,不是“启动文件被替换“。

验证:token 真的写进去了

sudo cryptsetup luksDump /dev/vda2 | grep -A10 Tokens
Tokens:
  0: systemd-tpm2
        tpm2-hash-pcrs:   7
        tpm2-pcr-bank:    sha256
        tpm2-primary-alg: ecc
        tpm2-blob:        AJEA...
        tpm2-srk:         gAS6AA....
        tpm2-pin:         false
        Keyslot:    1

逐项解读:

  • systemd-tpm2:这是 LUKS2 的 token 机制,告诉解锁工具“这个 slot 的密钥材料在 TPM 里,去找 systemd 的 TPM2 解锁逻辑“。
  • tpm2-hash-pcrs: 7tpm2-pcr-bank: sha256:绑定 PCR 7,用 SHA-256 bank。
  • tpm2-primary-alg: ecctpm2-srk:blob 是封在存储根密钥(Storage Root Key, SRK)派生的 primary key 之下的——第四章说过,SRK 是存储层的“房梁“,所有 seal 对象的父级。
  • tpm2-blob:这就是第四章里 seal.pub + seal.priv 两个文件的打包形式——公钥部分 + 加密后的私钥部分,存成一段 base64。
  • tpm2-pin: false:没加 PIN。加了的话开机还是要输东西,就违背本章目标了。
  • Keyslot: 1:占用了 keyslot 1。slot 0 还是你的密码,没动。

到这里,“密钥侧“完成了。但故事只讲了一半——LUKS 头里有了 TPM 钥匙,不等于开机时有人会用它。负责解锁的是 initramfs,而我们的 initramfs 现在还不认识 TPM。

第二阶段之二:initramfs 侦察(本章的侦探时间)

按 Arch Wiki 的通用教程,下一步应该是“把 HOOKS 里的 encrypt 换成 sd-encrypt,然后 mkinitcpio -P“。我先没急着照抄,而是把这套启动栈摸了一遍——很庆幸摸了,因为 Omarchy 的启动栈和 wiki 上的标准画法差得相当远。

发现一:明面上的 HOOKS 有问题

grep ^HOOKS /etc/mkinitcpio.conf
HOOKS=(base systemd autodetect microcode modconf kms keyboard keymap consolefont block filesystems fsck)

systemd 体系的钩子列表,但既没有 encrypt 也没有 sd-encrypt。按常理,这样的 initramfs 根本解不开 LUKS——可系统明明每天都在解密启动。矛盾,说明这份配置不是真相。

发现二:真相在 drop-in 里

cat /etc/mkinitcpio.conf.d/omarchy_hooks.conf
HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck btrfs-overlayfs resume)

mkinitcpio.conf.d/ 下的 drop-in 会整体覆盖主配置里的 HOOKS。真实生效的是这一套 busybox 体系:udev 而非 systemd,encrypt 而非 sd-encrypt。这就解释了为什么系统能正常解密——它压根没走 systemd 路线。

发现三:cmdline 是 busybox 风格

cat /proc/cmdline
root=/dev/mapper/root rw cryptdevice=PARTUUID=72afbdf0-...:root resume=/dev/mapper/root ...

cryptdevice=PARTUUID=...:root 是 busybox encrypt 钩子认的参数;systemd 体系认的是 rd.luks.uuid= / rd.luks.name=。后面改 cmdline 时这个对应关系不能搞错。

发现四:启动方式是 UKI + Limine

ls /boot/EFI/Linux/
omarchy_linux.efi

一个文件,内核 + initrd + cmdline 全打包在里面(Unified Kernel Image, UKI),Limine 引导器直接加载它。limine.conf 里的条目路径还带着 #哈希值——那是 Limine 对 UKI 做的完整性校验,UKI 内容变了哈希就得跟着更新,否则拒绝启动。

发现五:cmdline 有三个来源

这是我排查时最绕的一个点。最终拼进 cmdline 的参数来自三层:

  1. 基础层/etc/default/limine 里的 KERNEL_CMDLINE[default],主要参数(cryptdevice=root=resume=)都在这;
  2. drop-in 追加层/etc/limine-entry-tool.d/*.conf,比如 resume.conf 往里面追加 resume= 参数;
  3. 启动时叠加层limine.conf 里的 cmdline: 行,由 Limine 在启动时通过 EFI 的 LoadOptions 追加。

前两层会被打进 UKI,第三层不进镜像、启动时才叠上。改 cmdline 要改第一层,改错了层会百思不得其解“为什么我改了没生效“。

发现六:重建工具不是 mkinitcpio preset

ls /etc/mkinitcpio.d/
(空的)

标准 Arch 的 preset 文件不存在。Omarchy 用的是 limine-mkinitcpio——它背后调 /usr/share/libalpm/scripts/limine-mkinitcpio-install,用 mkinitcpio --uki 构建 UKI,并顺手更新 limine.conf 里的条目路径和哈希。这意味着重建 UKI 必须用 limine-mkinitcpio,直接 mkinitcpio -P 会生成镜像但 limine.conf 的哈希不更新,结果就是启动被拒。

坑:别拿通用教程直接套定制化发行版。

现代发行版的启动栈高度定制:谁生成 UKI、谁更新引导配置、HOOKS 从哪一层生效,每家都不一样。改启动链之前,先花半小时摸清“谁生成什么“,比照着 wiki 抄错之后修砖划算得多。

第三阶段:改造

侦察完毕,方案清晰了:把 busybox 体系换成 systemd 体系(sd-encrypt 原生支持 TPM2 解锁),cmdline 换成 rd.luks.* 风格。

a. 备份

sudo cp /etc/default/limine /etc/default/limine.bak
sudo cp /boot/EFI/Linux/omarchy_linux.efi /boot/omarchy_linux.efi.bak

一份配置备份,一份能启动的 UKI 备份。这是后悔药,别省。

b. 改 cmdline 基础层

编辑 /etc/default/limine,把 KERNEL_CMDLINE[default] 里的

cryptdevice=PARTUUID=72afbdf0-...:root

替换为:

rd.luks.uuid=43bf0994-f67b-4b1c-b247-8b5d0ca269f5 rd.luks.name=43bf0994-f67b-4b1c-b247-8b5d0ca269f5=root

UUID 就是第一步 lsblk -f 里抄下来的 LUKS 容器 UUID。

rd.luks.name=...=root 这一句不能省:它指定解开后的 dm 设备还叫 root。cmdline 里的 root=/dev/mapper/rootresume=/dev/mapper/root 都指着这个名字,改了设备名它们就全断了——休眠恢复(resume)会第一个挂掉。

c. 覆盖 HOOKS

新建 /etc/mkinitcpio.conf.d/zz-tpm2.conf

sudo tee /etc/mkinitcpio.conf.d/zz-tpm2.conf <<'EOF'
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole plymouth block sd-encrypt filesystems fsck sd-btrfs-overlayfs resume)
EOF

文件名前缀 zz- 是为了让它在字母序里排最后,压过 omarchy_hooks.conf 的覆盖——drop-in 链是后来者居上。

钩子映射关系:

busybox 体系(原)systemd 体系(新)说明
udevsystemdinitramfs 的事件处理核心
keymap consolefontsd-vconsole控制台键盘和字体
encryptsd-encryptLUKS 解锁,原生支持 TPM2 token
btrfs-overlayfssd-btrfs-overlayfsOmarchy 的 overlay 根目录机制

最后一条值得注意:sd-btrfs-overlayfs 是 Omarchy 自带的 systemd 变体钩子。发行版作者显然预想到了有人会走这条路,连自家定制钩子都准备了双份——这给了我们很大信心:这条改造路径是官方预留的,不是野路子。

想还原也简单:删掉 zz-tpm2.conf,恢复 /etc/default/limine,重新构建即可。

d. 重建 UKI

sudo limine-mkinitcpio

真实构建输出的关键行:

==> Using drop-in configuration file: zz-tpm2.conf
==> Starting build: '6.x.x-arch'
  -> Running build hook: [base]
  -> Running build hook: [systemd]
  ...
  -> Running build hook: [sd-encrypt]
  -> Running build hook: [filesystems]
  ...
==> Creating unified kernel image: /boot/EFI/Linux/omarchy_linux.efi
==> Image generation successful

盯两件事:Using drop-in configuration file: zz-tpm2.conf 确认我们的覆盖生效了;hook 列表里出现 sd-encrypt没有 encrypt,确认钩子换对了。

e. 重启前验证:TPM2 组件真的进镜像了吗

这是个好习惯,强烈建议养成:重启之前,先从 UKI 里把 initrd 抠出来检查一遍。

sudo objcopy -O binary --only-section=.initrd \
  /boot/EFI/Linux/omarchy_linux.efi /tmp/initrd.img
lsinitcpio /tmp/initrd.img | grep tss2
usr/bin/systemd-cryptsetup
usr/lib/cryptsetup/libcryptsetup-token-systemd-tpm2.so
usr/lib/libtss2-esys.so.0
usr/lib/libtss2-mu.so.0
usr/lib/libtss2-rc.so.0
usr/lib/libtss2-tcti-device.so.0
...

一共约 40 个相关文件。解读一下这份清单:

  • systemd-cryptsetup:systemd 体系里负责解 LUKS 的程序;
  • libcryptsetup-token-systemd-tpm2.so:让 cryptsetup 认识 systemd-tpm2 token 的插件——就是我们 luksDump 里看到的那个 token 类型;
  • libtss2-*:TPM2 软件栈(tpm2-tss)的库,和 TPM 通信全靠它们。

这些文件不是我们手动加进去的——sd-encrypt 钩子检测到系统里装了 tpm2-tss,就自动把它们打进 initramfs。钩子作者把这条路也铺好了。

坑:UKI 是 0600 root 权限,objcopy 要用 sudo。

我第一遍没加 sudo,objcopy 静默失败,/tmp/initrd.img 是个空文件,后面 lsinitcpio 的输出自然也是空的——我差点得出“TPM 组件没打进去“的错误结论。

教训有两层:一,诊断命令失败时先看输入文件是否有效(ls -l /tmp/initrd.img 一眼就能看出来是 0 字节);二,别用 2>/dev/null 把错误吞掉,排查问题时错误信息是你唯一的朋友。

第四阶段:重启验证

深呼吸,重启。

如果一切正常,你会发现:密码提示没有了。屏幕闪过引导画面,直接进系统。

来点日志证据,确认不是“碰巧进去了“:

journalctl -b -u "systemd-cryptsetup@*"
systemd[1]: Starting Cryptography Setup for root...
systemd-cryptsetup[...]: Successfully activated the device.
systemd[1]: Finished Cryptography Setup for root.

systemd-cryptsetup 无交互地完成了解密。它读了 LUKS 头里的 token,拿 blob 找 TPM,TPM 检查 PCR 7 一致,交出密钥,收工。

这说明什么

从按下电源到进入系统,信任是这么传递的:

  1. 固件度量 Secure Boot 状态 → PCR 7;
  2. systemd-cryptsetup 请求 TPM 解封卷密钥;
  3. TPM 核对 PCR 7 == seal 时的值 → 一致,放钥匙;
  4. LUKS 用卷密钥解开根分区,系统启动。

第四章你亲手 seal/unseal 的那个小玩具,和这套生产机制是同一套原语。区别只是:这次 blob 放在 LUKS 头里,解封发生在开机最早的几十秒里。

翻车实录:heredoc 抢了 sudo 的 stdin

这段是我真实翻的车,原样保留,因为它正好演示了这类改造最危险的地方。

我第一次操作时,流程是这样的:先 sed 改了 /etc/default/limine(cmdline 换成 rd.luks.*),然后写 zz-tpm2.conf 用了一条类似这样的命令:

echo 'mypassword' | sudo -S tee /etc/mkinitcpio.conf.d/zz-tpm2.conf <<'EOF'
HOOKS=(...)
EOF

问题:sudo -S 从 stdin 读密码,而 heredoc 也占着 stdin——heredoc 抢了密码的位置,sudo 认证失败,tee 没执行,zz-tpm2.conf 根本没写出来。更要命的是管道把错误吞掉了大半,看起来“命令跑完了“。

紧接着我跑了 sudo limine-mkinitcpio。它忠实地用**旧钩子(busybox encrypt)+ 新 cmdline(rd.luks.*)**这个自相矛盾的组合打了包。如果我当时重启,必然起不来:busybox 的 encrypt 钩子在 cmdline 里找不到 cryptdevice=,会直接放弃解锁。

运气(或者说习惯)救了我:重启前我按 e 步做了 initramfs 验证,发现组件不对,回头 cat /etc/mkinitcpio.conf.d/zz-tpm2.conf——文件不存在,真相大白。重写文件、重建、再验证,第二次就正常了。

三条教训:

  1. 这类改造的危险在中间态:配置已改、镜像未重建(或重建错了)的窗口期里,机器是“必然起不来“的状态。这个窗口越短越好,操作尽量一口气做完。
  2. 每步执行后验证产物:写完配置 cat 一下,重建完看构建日志的 hook 列表。命令“跑完了“和“生效了“是两回事。
  3. 兜底设计要在动手前就位:就算真起不来,sd-encrypt 解不开会自动退回密码提示(密码 slot 还在);虚拟机有快照;UKI 有 .bak 备份。三层兜底,任何一层都能救命。

安全模型讨论:自动解锁之后,谁在守门

改完重启,一切丝滑。但我很快注意到一个此前没想过的问题:系统直接进了桌面,连登录器都没经过

查了 /etc/sddm.conf.d/autologin.conf——Omarchy 默认给 SDDM 配了自动登录。这在改造前是合理的:LUKS 密码就是开机唯一的一道门,进门之后再要一次密码纯属重复劳动。

但 TPM 自动解锁之后,情况变了。现在的开机流程是:按电源 → TPM 放钥匙 → 自动登录 → 直达桌面。全程零输入。

把防护能力摆出来看:

  • 防拔盘离线读取:✓。硬盘被拆走,离开这台机器的 TPM 和正确的 PCR 状态,盘上就是一堆密文。
  • 防整机抱走:✗。别人把整台机器拿走,按下电源键,直达你的桌面。

也就是说,当前配置保护的是“数据在盘里的机密性“,不保护“整机的现场安全“。这个模型和 BitLocker 的默认形态(TPM-only)是一样的,不是缺陷,是一个明确的取舍

如果你想要“自动解锁 + 进系统要密码“——比如笔记本经常带出门——删掉 autologin 即可:

sudo rm /etc/sddm.conf.d/autologin.conf

这样 TPM 负责解开磁盘(防离线读取 + 防固件篡改),登录密码负责拦住当面开机的人。两道门各管各的,互不重复。真机部署时,这个取舍要根据自己的使用场景想清楚。

思考:能不能让 TPM 也要求输点东西?

可以。systemd-cryptenroll 支持 --tpm2-with-pin=yes,解封时要求输一个 PIN。这是“TPM 持有 + 你记忆“的双因素,安全性更高,代价是开机又要输东西了——和本章“零输入“的目标背道而驰。安全性和便利性的天平,永远是你自己来调。

小结

本章干了什么:

  1. lsblk -f 摸清分区布局,确认 LUKS 真的在保护根分区(别靠表面现象判断);
  2. systemd-cryptenroll --tpm2-pcrs=7 给 TPM 开了新 keyslot,原密码 slot 原封不动;
  3. 侦探式排查了 Omarchy 的启动栈:drop-in 覆盖 HOOKS、UKI 打包、cmdline 三层来源、limine-mkinitcpio 重建工具;
  4. 换 systemd 体系钩子 + rd.luks.* cmdline,重建 UKI,重启前验证 initramfs 组件;
  5. 开机零输入进系统,日志确认 TPM 无交互解锁;
  6. 复盘了一次 heredoc 抢 stdin 的翻车,和“中间态最危险“的教训;
  7. 厘清了自动解锁后的安全模型:防拔盘,不防抱机;以及 autologin 的取舍。

到这里,本书的实战篇就完整了:从手动 extend 一个 PCR,到生产级的全盘加密自动解锁,用的始终是同一套原语——度量、PCR、seal。

但还记得本章埋下的那个伏笔吗:我们绑的是 PCR 7,它度量固件和 Secure Boot 配置。如果攻击者不动固件,而是直接替换 /boot 里的 UKI——反正 ESP 是明文的——PCR 7 不会变,TPM 照样放钥匙。这个盲区有多真实、Secure Boot 在其中扮演什么角色,第八章“Evil Maid 攻击“见。

第七章 · 授权体系:口令、PCR Policy 与 PolicyAuthorize

本章目标

  • 搞清楚实验 B 里 -p pcr:sha256:16 这一行参数背后到底发生了什么;
  • 理解 TPM 授权(Authorization)的三种方式,重点吃透策略会话(policy session)的哈希链机制;
  • 亲手跑通 PolicyAuthorize 全流程:封存、解封、模拟升级、重签、再解封,一次成功一次失败都要看到;
  • 顺带把 seal.pub / seal.priv / seal.ctx 这三件套彻底讲明白。

从一个遗留问题开始

回忆实验 B,我们解封时用的是:

tpm2_unseal -c seal.ctx -p pcr:sha256:16

当时我说“-p 指定授权方式“,然后就过去了。现在把这个问题捡起来:这个 -p pcr:sha256:16 到底让 TPM 做了什么?

直觉的回答是“TPM 检查了一下 PCR 16 对不对“。但这个说法太模糊了——怎么检查?和谁比?比对了之后这个结论怎么传递到 unseal 这个动作上?要知道 TPM 的每条命令是独立的,命令之间没有“我刚才检查过了“这种记忆。

答案是:这行参数让工具替你悄悄开了一个会话(session),在会话里执行了一条策略命令,然后带着这个会话去执行 unseal。这一整套机制就是本章的主角。理解了它,第六章 luksDump 输出里那个看不懂的 tpm2-pubkey 字段也就顺手解开了。

TPM 授权的三种方式

TPM 里每个对象(密钥、封存的秘密)都有一个授权字段,你想操作它,就得先通过授权。授权方式一共三种:

方式凭据是什么特点典型场景
口令授权(authValue)一串口令/HMAC简单直接,但每次用都要带口令普通密钥的访问控制
PCR policy一段“路径哈希“绑死环境状态,环境变就废封存秘密到启动状态
Authorize policy一段“路径哈希“绑一把签名密钥,可审批新状态需要在线升级的系统

后两种看起来不同,底层是同一个机制:策略会话(policy session)。区别只在于“路径“里放了哪些策略命令。我们一层层拆开看。

策略会话:又一条哈希链

前面章节我们见过 PCR 的 extend 哈希链:

PCR_new = Hash(PCR_old || 新度量值)

策略会话里的 policyDigest 走的是一模一样的结构:

policyDigest_new = Hash(policyDigest_old || 策略命令名 || 命令参数)

整个机制的运作分三步:

  1. 创建对象时:算出你期望的“走完所有策略命令后的最终 policyDigest“,把它写进对象的 authPolicy 字段。这相当于在对象上钉了一句话:“想动我,必须走一遍我规定的路径,走完的哈希得等于这个值。”
  2. 使用对象时:开一个策略会话,逐条执行策略命令。每执行一条,TPM 就把这条命令按上面的公式 extend 进会话的 policyDigest。
  3. 执行目标命令时(比如 unseal):TPM 拿会话当前的 policyDigest 和对象的 authPolicy 比对,相等才放行,不等直接拒绝。

思考:为什么要把授权设计成哈希链,而不是简单的“检查条件列表“?因为链式结构让顺序也成为断言的一部分。“先满足条件 A 再满足条件 B“和“先 B 再 A“算出来的摘要不同。而且每条策略命令一旦 extend 进去就抹不掉,会话没有“撤销“操作——和 PCR 不能随意重置是同一个设计哲学。

实验 B 的 -p pcr:sha256:16 就是这套机制的快捷方式:工具替你做了一串隐式操作——开策略会话、执行 TPM2_PolicyPCR、带着会话执行 unseal、结束后销毁会话。你在命令行上没看见,但 TPM 那边一步没少。

PCR policy:最常见的一块砖

策略命令有很多种,每条都是路径上的一块砖。最常用的是 TPM2_PolicyPCR

sudo tpm2_createpolicy --policy-pcr -l sha256:16 -L policy.bin

这条命令生成的策略只含一条指令。它的语义值得逐字读:TPM 现场读取 PCR 16 的当前值,把这个值的哈希 extend 进 policyDigest 链。也就是说,这条命令钉下的断言是——“执行到这一步时,PCR 16 必须等于我此刻看到的样子”。

后面的推导就是纯数学了:

  • 创建对象时算 authPolicy 那一刻,PCR 16 的值被固化进了最终摘要;
  • 将来解封时,会话里再执行 PolicyPCR,TPM 又读一次 PCR 16;
  • 如果这期间 PCR 16 被 extend 过,读出来的值不同 → 链上 extend 的内容不同 → 最终 policyDigest 对不上 authPolicy → 拒绝。

实验 B 里我们模拟篡改后解封,看到的报错是:

ERROR: Esys_Unseal(0x99D) - tpm:session(1):policy check failed

现在你知道 0x99D policy check failed 的完整含义了:不是“PCR 不对“这种模糊判断,而是“你的会话走出的哈希,和对象里钉死的哈希,不相等“。

策略命令是可以组合的,几个常见的:

  • PolicyPCR + PolicyAuthValue:既要环境状态对,又要输口令。双因素,防“环境没被改但人不是主人“的场景;
  • PolicyOR:多分支,满足任意一条即可。比如“PCR 是 A 状态或者是 B 状态都行“——听起来很美好,但维护 N 个合法状态就要列 N 个分支,状态多了会失控;
  • PolicyCounterTimer / PolicyNv 等:基于计数器、NVRAM 的条件,本书不展开。

纯 PCR policy 的死穴

实验 D 我们把 LUKS 密钥绑到了 PCR 上,开机自动解锁,很爽。但这里埋着一个迟早要爆的雷:

PCR 值是被写死(更准确说,是被哈希固化)进对象的 authPolicy 里的。 这意味着环境有任何合法变化——内核升级、固件更新、bootloader 换版本——PCR 值就会变,旧对象直接解不开。

补救办法只有一个:先用旧环境解封出秘密,再按新环境的预期 PCR 值重新封存一遍。第六章的实验 D 我们确实就是这么干的(升级前重算 PCR 预期值、重新 enroll)。

但这个流程有个让人不舒服的时序问题:你得先发现解不开,才去补救。万一是远程服务器、自动升级、半夜重启呢?机器卡在 LUKS 密码提示界面等你,你人在睡觉。而且“解封→重封存“这个操作要求秘密的持有方在场,流程上很被动。

:这不是 TPM 的缺陷,这是“把授权绑在具体值上“这种设计的固有代价。TPM 官方早就给出了标准解法,就是下面要讲的 Authorize policy。

Authorize policy:加一层间接

TPM2_PolicyAuthorize 的思路用一句话概括:对象不再绑定具体的 PCR 值,改为绑定“一把签名密钥“。

它的语义是:

我不在乎 PCR 现在是什么值。只要你能拿出一张“票据“,证明审批方(持有指定私钥的人)签名认可过“你这条会话当前走出的策略摘要“,我就认。

拆开看执行流程。会话执行 PolicyAuthorize 时,TPM 做两件事:

  1. 验证票据(ticket):票据是 TPM 自己早前通过 TPM2_VerifySignature 验签后签发的,内容是“公钥为 N 的密钥,确实签名过摘要 D“;
  2. 比对:票据里的 D 必须等于当前会话实际走出的 policyDigest。对得上,就把会话的 policyDigest 重置为一个由审批方公钥 name 决定的新值;对不上,当场拒绝。

妙处在于第 2 步之后的部分:无论被审批的具体策略摘要是什么,只要审批方是同一把公钥,PolicyAuthorize 执行完后会话的 policyDigest 都落在同一个值上。而对象的 authPolicy 里写的就是这个值。于是:

  • 环境状态 A:审批方签了 A 的策略摘要 → 放行;
  • 升级到状态 B:审批方签一张 B 的 → 也放行;
  • 封存的对象从头到尾不用动

授权的判断从“环境是不是我当初封存时的样子“变成了“环境是不是审批方认可的样子“。秘密的持有方和升级的审批方可以是两个人,审批方甚至从头到尾接触不到秘密本身。

纸上谈完了,下面真刀真枪跑一遍。

实验:PolicyAuthorize 全流程

老环境:Omarchy 虚拟机 + swtpm。建个目录开工:

mkdir -p ~/tpm-lab/authz && cd ~/tpm-lab/authz

第 0 步:准备环境状态 A

我们把 PCR 16 当作“环境状态“的载体。先清零,得到一个确定的起点:

sudo tpm2_pcrreset 16

此刻 PCR 16 全零,这就是“状态 A“。

第 1 步:生成审批方密钥

审批方的角色在现实中是 OEM 厂商或系统管理员。用 OpenSSL 生成一把 RSA 密钥:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signer.key
openssl rsa -in signer.key -pubout -out signer.pub

注意:现实里这把私钥应该离线保管(比如放在不联网的机器或 HSM 里),升级审批时拿出来签一下名就走。实验里图方便放在同目录,别学这一点。

第 2 步:把公钥导入 TPM,拿到 name

PolicyAuthorize 认的不是公钥文件,而是 TPM 给这把公钥起的 name——可以理解成公钥的哈希,是 TPM 眼中这把钥匙的身份证号:

sudo tpm2_loadexternal -C o -G rsa -u signer.pub -c signer.ctx -n signer.name

-n signer.name 让工具把 name 存盘,后面每条 PolicyAuthorize 命令都要用它。

第 3 步:计算状态 A 的策略摘要并签名

先算出“状态 A 下纯 PCR policy“的最终摘要:

sudo tpm2_createpolicy --policy-pcr -l sha256:16 -L policyA.bin

policyA.bin 里就是那个 32 字节的 policyDigest——“PCR 16 等于全零“这句断言的哈希形式。审批方拿私钥签它:

openssl dgst -sha256 -sign signer.key -out policyA.sig policyA.bin

第 4 步:TPM 验签,换票据

签名要在 TPM 这边验过、换成 TPM 自己签发的票据(ticket),PolicyAuthorize 才认:

sudo tpm2_verifysignature -c signer.ctx -g sha256 --scheme=rsassa \
    -m policyA.bin -s policyA.sig -t ticketA.bin

坑(这个坑我实打实踩过):tpm2-tools 5.8 里这条命令的参数行为变了。OpenSSL 产出的是裸签名(raw signature),必须显式加 --scheme=rsassa,工具才会按纯签名处理:

  • 很多人(包括我)凭老教程的记忆写 -f plain--format=plain——都会报错。--format 已被废弃,而且被映射到了 --scheme 上,于是你会看到莫名其妙的 Unknown signing scheme, got: plain
  • 如果什么都不加,工具默认按 TPMT_SIGNATURE 结构(TPM 原生签名格式)去解析 OpenSSL 的裸签名,解析失败,报一个反序列化错误(0x9000b),错误信息完全不提“你缺了个参数“这回事。

教训:tpm2-tools 版本之间参数差异不小,--help 比记忆可靠,比网上的老教程更可靠。

第 5 步:trial 会话预演,算出写进对象的 authPolicy

现在关键一步:我们要算的不是“状态 A 的策略摘要“,而是“走完 PolicyPCR 再走 PolicyAuthorize 之后“的最终摘要——这个值才写进对象的 authPolicy。用 trial 会话来算:trial 会话不做任何验证(不读 PCR、不验票据),只机械地算哈希,专门用来“预演“路径、得到最终摘要:

sudo tpm2_startauthsession --policy-session -S trial.ctx
sudo tpm2_policypcr -S trial.ctx -l sha256:16
sudo tpm2_policyauthorize -S trial.ctx -L policyAuthz.bin \
    -n signer.name -i policyA.bin -t ticketA.bin
sudo tpm2_flushcontext trial.ctx

-L policyAuthz.bin 把 trial 会话的最终 policyDigest 导出来。注意一个细节:trial 会话里的 PolicyPCR 并没有真去读 PCR(反正全零状态读不读都一样),PolicyAuthorize 也不验票——它们只是把自己的命令名和参数 extend 进链。所以这个最终值里只有审批方公钥的 name,没有任何具体 PCR 值

第 6 步:封存秘密

sudo tpm2_createprimary -C o -c prim.ctx
echo -n "机密:游戏存档密钥 42" | sudo tpm2_create -C prim.ctx \
    -L policyAuthz.bin -u sealA.pub -r sealA.priv -i-
sudo tpm2_load -C prim.ctx -u sealA.pub -r sealA.priv -c sealA.ctx

和实验 B 几乎一样,唯一区别是 -L 给的策略文件从“PCR policy“换成了“authorize policy“。这个对象现在绑的是“审批方的公钥“,而不是“PCR 16 = 全零“。

第 7 步:状态 A 下解封(成功)

这次不用快捷方式,显式开会话走完整流程,看看 -p pcr:... 背后的完整姿势:

sudo tpm2_startauthsession --policy-session -S s.ctx
sudo tpm2_policypcr -S s.ctx -l sha256:16
sudo tpm2_policyauthorize -S s.ctx -n signer.name \
    -i policyA.bin -t ticketA.bin
sudo tpm2_unseal -c sealA.ctx -p session:s.ctx
sudo tpm2_flushcontext s.ctx
机密:游戏存档密钥 42

成功了。三条会话命令各司其职:PolicyPCR 让 TPM 现场读 PCR 16 extend 进链(此刻全零,和签名时一致);PolicyAuthorize 验票 + 比对当前摘要 + 把摘要重置到公钥 name 决定的值;unseal 带着这个会话去碰对象,摘要匹配,放行。

第 8 步:模拟系统升级,用旧审批解封(失败,且失败点很有信息量)

extend 一下 PCR 16,环境进入状态 B:

echo "new kernel 6.17" | sudo tpm2_pcrextend 16:sha256=$(sha256sum | cut -d' ' -f1)

再用状态 A 的旧审批走一遍解封流程:

sudo tpm2_startauthsession --policy-session -S s.ctx
sudo tpm2_policypcr -S s.ctx -l sha256:16
sudo tpm2_policyauthorize -S s.ctx -n signer.name \
    -i policyA.bin -t ticketA.bin
ERROR: Esys_PolicyAuthorize(0x1C4) - tpm:parameter(1):value is out of range or is not correct

看清楚:报错的是 Esys_PolicyAuthorize根本轮不到 unseal。这个失败点精确验证了规格里的一个细节——PolicyAuthorize 执行时,TPM 强制比对“被签名的策略摘要(policyA.bin)“和“当前会话实际走出的摘要”。PCR 16 变了,会话在 PolicyPCR 那步 extend 进去的值就变了,两个摘要在 authorize 这步就对不上,当场拒绝。

思考:这说明审批方批准的不是一句抽象的“我信任这台机器“,而是具体到哈希的确定状态。签的是 policyA.bin,就只认 policyA.bin 对应的那条路径。授权没有半点含糊的空间。

清理会话现场:

sudo tpm2_flushcontext s.ctx

第 9 步:审批方为状态 B 重签,对象不动,解封复活

审批方算出状态 B 的策略摘要,重新签名、换票:

sudo tpm2_createpolicy --policy-pcr -l sha256:16 -L policyB.bin
openssl dgst -sha256 -sign signer.key -out policyB.sig policyB.bin
sudo tpm2_verifysignature -c signer.ctx -g sha256 --scheme=rsassa \
    -m policyB.bin -s policyB.sig -t ticketB.bin

注意:sealA.pub / sealA.priv 一个字节都没动。直接走解封流程,这次带上 B 的审批材料:

sudo tpm2_startauthsession --policy-session -S s.ctx
sudo tpm2_policypcr -S s.ctx -l sha256:16
sudo tpm2_policyauthorize -S s.ctx -n signer.name \
    -i policyB.bin -t ticketB.bin
sudo tpm2_unseal -c sealA.ctx -p session:s.ctx
sudo tpm2_flushcontext s.ctx
机密:游戏存档密钥 42

又成功了。环境已经面目全非,对象原封不动,仅凭一张新签名就恢复了授权。

这说明什么

把两种方案放在一起对比,差异一目了然:

纯 PCR policyAuthorize policy
环境变化后重新封存秘密(先解封再封)重新签名策略摘要,对象不动
谁能授权新状态必须能接触到秘密原文的人只持签名私钥的审批方
升级流程解封 → 换策略 → 重封存,秘密持有方在场离线算新 PCR 预期值 → 签名 → 下发
签名/封存时机只能在变化发生后补救可以预先签好,随升级包一起发布

最后一行是点睛之笔:审批方从头到尾接触不到秘密本身。签名是数学运算,签的是哈希不是秘密——这意味着密钥分发和版本审批可以离线做、委托给别人做。这就是它比纯 PCR policy 高出的那一层。

真实世界里的对应

这套机制不是实验室玩具,你身边大概率正在跑:

UKI 的 .pcrsig 段。 第六章说过 systemd-cryptenroll 默认绑 PCR 7,其实还可以绑 PCR 11(内核与 UKI 各段的度量)。问题来了:内核一升级 PCR 11 就变,难道每次升级都要人肉重封存?不用。UKI 镜像里有一个 .pcrsig 段,内核厂商构建镜像时就把“本 UKI 对应的 PCR 11 预期值“的策略摘要签好名打了进去。升级换内核,新镜像自带新签名,解锁零干预——这正是我们实验里“重签 ticketB“那一步的工业化版本。

systemd-cryptenroll --tpm2-public-key 给它一把公钥,它就用 PolicyAuthorize 把 LUKS 密钥绑到这把公钥上,而不是绑死当前 PCR 值。回头翻第六章 cryptsetup luksDump 的输出,TPM2 token 里那个当时没解释的 tpm2-pubkey 字段——现在你知道了,那就是审批方公钥,等价于我们实验里的 signer.pub

加餐:seal.pub / seal.priv / seal.ctx 三件套

从实验 B 到现在,目录里一直躺着这三个文件。大多数人的困惑是:“哪个是秘密?哪个能备份?ctx 到底是个啥?“用一个具体场景讲透。

假设你开发了一款单机游戏,存档用一个对称密钥 K 加密,防止玩家直接改存档文件。K 不能写死在程序二进制里——会被逆向出来。于是你把 K 封存进 TPM:只有“没被动过手脚的系统环境“才能取出 K。tpm2_create 跑完,你手上就有了三样东西:

seal.pub(TPM2B_PUBLIC)= 保险柜门上的规则铭牌。 里面没有任何秘密,全是元信息:对象属性(fixedTPM 表示永不可导出、fixedParent 表示不能换爹)、算法标识、还有 authPolicy 字段——就是“PCR 16 必须等于 X“这句断言的哈希。任何人拿到这个文件,只能知道规则长什么样,而且规则受哈希保护,改一个字节整个对象就废了。

seal.priv(TPM2B_PRIVATE)= 真正的信封。 K 被父密钥加密后的密文,外加完整性校验。父密钥从 TPM 内部种子派生,永不出芯片。所以这个文件你可以随便备份、被小偷偷走、甚至误传上云盘——离开那颗 TPM,它就是一堆乱码,暴力破解等于破解 TPM 的种子密钥体系。

seal.ctx = 取件凭条。 这是最容易误解的一个。它是运行时引用,不是数据tpm2_load 做的事是:把 pub + priv 送进 TPM,TPM 用父密钥拆开信封、校验完整性,把对象放进芯片里那块很小的易失内存,然后返回一个句柄(handle)。.ctx 文件存的就是这个句柄的序列化形式,给后续命令当“取件号“用。它本身毫无秘密;而且 TPM 易失内存很小、重启即清空,所以每次开机要用对象,都得重新 tpm2_load 拿一张新凭条。

注意:别把 .ctx 文件当回事去备份它。重启之后里面的句柄就是一张过期的取件号,窗口后面什么都没有。要备份的是 pub + priv 两件套。

整个生命周期串起来:

创建:tpm2_create ──→ seal.pub + seal.priv(落盘,可备份)
使用:tpm2_load(pub + priv) ──→ seal.ctx(内存句柄)──→ tpm2_unseal / tpm2_sign / ...

小结

  • TPM 授权有三条路:口令、PCR policy、Authorize policy,后两者共用策略会话机制;
  • 策略会话的核心是 policyDigest 哈希链,和 PCR extend 同构:“你必须走一遍我规定的路径,路径哈希对上才算数”;
  • 实验 B 的 -p pcr:sha256:16 只是隐式开会话执行 PolicyPCR 的快捷写法;
  • 纯 PCR policy 把环境值固化进对象,升级就得重新封存,被动且要求秘密持有方在场;
  • PolicyAuthorize 把对象绑到审批方公钥上,环境变化只需重签一张策略摘要,对象不动,审批可以离线、可以委托——实验中失败发生在 PolicyAuthorize 一步而非 unseal,说明审批针对的是具体哈希状态,没有含糊空间;
  • UKI 的 .pcrsigsystemd-cryptenroll --tpm2-public-key 是这套机制的工业实现;
  • seal.pub 是规则铭牌、seal.priv 是加密信封、seal.ctx 是重启即废的取件凭条,要备份的只有前两个。

授权讲透了,下一章换个视角看攻防:PCR 7 度量的到底够不够?攻击者抱着你的硬盘能玩出什么花样?

第八章 · PCR 7 的盲区:Evil Maid 攻击与 Secure Boot 的意义

第六章结尾我们留了一个悬念:把 LUKS 卷密钥绑到 PCR 7 之后,开机确实自动解锁了,但 PCR 7 到底度量了些什么?如果有人趁你不在动过 /boot 里的文件,TPM 会拒绝给密钥吗?

答案是:不会。而且不是“碰巧不会“,是“在设计上就不归它管“。

这一章把这个盲区彻底推演清楚。它是全书思辨性最强的一章,理解透了,你就明白为什么 Secure Boot(安全启动)这个常被嫌麻烦、甚至被人教你怎么关掉的东西,在可信启动体系里是必需品而不是摆设。

本章目标

  • 搞清楚 PCR 7 里到底度量了什么,为什么它管不着内核和 initramfs
  • 对着第六章的实验环境,完整推演一次 Evil Maid(恶意的女仆)攻击
  • 讲透“度量只是记录,不是拒绝“这个最容易被误解的认知
  • 比较两种防御哲学:预防(Secure Boot)与检测(systemd-pcrlock)
  • 可选:在实验虚拟机上亲手复现一次攻击,看着防御失效

背景知识:PCR 分工速查表

先回顾 PCR 的分工。不同固件的度量细节有差异,下面这张表以我们的实验环境(OVMF 固件 + Limine 引导 + UKI + systemd)为准,对应第五章里你重放过的那份度量日志:

PCR度量内容谁度量
0OVMF 固件代码与配置固件
4Limine、UKI 镜像(引导管理器及其加载的内核映像)固件 / bootloader
5GPT 分区表、启动配置变化固件
7SecureBoot 开关、PK / KEK / db / dbx 密钥库固件
11UKI 各段(内核、initramfs、cmdline 等)+ systemd 启动阶段标记systemd

注意:这张表不是从标准文档抄来的理想分工,而是实验环境实测出来的。换一台机器、换一个固件,同一编号的 PCR 里装的东西可能不同。所以第五章练的重放功夫不是白练的——想知道某台机器的 PCR 里有什么,去读它自己的 event log。

盯着 PCR 7 那一行多看几秒:它度量的是 Secure Boot 的验证规则和密钥库,而不是被验证的代码本身。这个区别是整章的钥匙。

PCR 7 里到底有什么

翻开第五章你重放过的日志,PCR 7 对应的事件类型是 EV_EFI_VARIABLE_DRIVER_CONFIG(EFI 驱动配置变量)。用 tpm2_eventlog 过滤出来看,大致是这样的四笔:

- EventNum: ...
  PCRIndex: 7
  EventType: EV_EFI_VARIABLE_DRIVER_CONFIG
  ...
  UnicodeName: SecureBoot
- EventNum: ...
  PCRIndex: 7
  EventType: EV_EFI_VARIABLE_DRIVER_CONFIG
  ...
  UnicodeName: PK
- EventNum: ...
  PCRIndex: 7
  EventType: EV_EFI_VARIABLE_DRIVER_CONFIG
  ...
  UnicodeName: KEK
- EventNum: ...
  PCRIndex: 7
  EventType: EV_EFI_VARIABLE_DRIVER_CONFIG
  ...
  UnicodeName: db

一笔一个变量:

  1. SecureBoot 变量:Secure Boot 开关的值(0 或 1)
  2. PK(Platform Key,平台密钥):平台的最高信任根
  3. KEK(Key Exchange Key,密钥交换密钥):用于更新 PK 和签名数据库
  4. db / dbx(签名数据库与吊销列表):哪些签名被信任、哪些被拉黑

注意看 EventType——它是“变量配置“,度量的动作发生在固件读取这些变量的时刻,而不是发生在“用这些变量验签某个文件“的时刻。固件度量的是这几个变量的内容哈希,和“我用这些规则验证过的那些文件“没有任何关系。所以 PCR 7 能回答的问题只有一个:

“Secure Boot 的配置和密钥库,和绑定那一刻相比,变了吗?”

它完全不知道、也不关心 Limine 是什么版本、内核是哪个构建、initramfs 里打了什么包——这些东西的哈希一个都不在 PCR 7 里。

而我们的实验环境 Secure Boot 是关着的(第二章为了折腾方便就没开)。也就是说 PCR 7 里躺着的是“SecureBoot=0 加一套默认密钥库“的哈希,一个基本静止的值。

Evil Maid 攻击:完整推演

现在把攻击走一遍。Evil Maid 得名于一个经典场景:你把笔记本留在酒店房间,清洁人员(或者任何能物理接触设备的人)趁你出门动了它。前提假设:攻击者有物理接触,但没有你的任何口令。

攻击面:明文的 /boot

回顾第六章实验环境的分区布局:

/dev/vda
├── vda1  /boot(vfat,明文,不在 LUKS 里)
└── vda2  /(LUKS 全盘加密,TPM 自动解锁)

/boot 为什么必须明文?因为固件要读它。OVMF 从 vda1 加载 Limine 和 UKI(Unified Kernel Image,统一内核映像)的时候,LUKS 还没解锁、TPM 也还没给密钥——这个阶段的磁盘内容对固件必须是可读的明文。

推论是:vda1 上的 UKI,任何能把磁盘挂到自己机器上的人都能读、能改。 LUKS 保护了根分区,但没有保护启动文件。

攻击动作

酒店场景,攻击者拿到 10 分钟物理接触:

  1. 引导一个自己带的 U 盘系统(或直接拆盘镜像)
  2. 把 vda1 上的 UKI 换成动了手脚的版本——比如在 initramfs 的启动脚本里加一行:拿到卷密钥解锁根分区之后,把密钥 curl 到攻击者的服务器;或者更经典地,植入一个 rootkit 再正常挂载根分区,什么都不耽误
  3. 原样放回,走人

整个过程不需要任何口令,因为 vda1 是明文。

期望 TPM 救命,但落空了

你回到酒店,开机。此时我们“期望“的安全链条是:

  • OVMF 加载被篡改的 UKI,会度量它 → PCR 4 和 PCR 11 的值变了 ✓(这一步确实发生,度量系统是忠实的)
  • TPM 发现 PCR 值和绑定时不一致 → 拒绝释放 LUKS 密钥 ✗

第二步永远不会发生。回忆第六章绑定时的那条命令:

sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/vda2

--tpm2-pcrs=7,白纸黑字:我们告诉 TPM,解锁的条件有且仅有 PCR 7。于是:

  • Secure Boot 还是关着的,没人去动它
  • PK / KEK / db / dbx 一个字节都没变
  • 所以 PCR 7 的值和绑定那一刻一模一样
  • TPM 核对策略:PCR 7 匹配,密钥照给

思考:这个场景里 TPM 出错了吗?没有,它忠实执行了你给的策略:“PCR 7 是这个值就放密钥”。度量系统出错了吗?也没有,PCR 4/11 忠实地记下了篡改。问题出在策略声明的关心范围里根本不包括 UKI。

攻击成功

恶意 initramfs 从 TPM 拿到卷密钥,解开根分区,攻击者的代码在一个完全解密的系统上运行。密钥外泄、rootkit 落地,都发生在系统正常启动的掩护下。

更隐蔽的版本:恶意 initramfs 拿到密钥、外泄之后,链式加载(kexec 或直接转交)回正版 initramfs,系统照常进桌面。你什么异常都看不到,而密钥已经在攻击者手里。

复盘整条链

整个链条里每个组件都“尽职尽责“:TPM 忠实执行了解封策略,度量系统忠实记录了篡改(PCR 4/11 确实变了),LUKS 忠实地用拿到的密钥解了密。

锁的钥匙,挂在了一扇没装锁的门上。

关键认知:度量只是记录,不是拒绝

这是本章最重要的一句话,值得单独讲透。

PCR 值的变化本身不阻止任何事。 PCR 是个只增不改的日志本(第三章说过,PCR 不能随意重置、只能 extend),它的职责是留证据,不是当保安。恶意代码被度量进了 PCR 4,恶意代码照样跑——度量和执行是两条平行的线。

真正起“门禁“作用的是你绑在 PCR 上的策略。当你把密钥 Seal 到 PCR 7,你实际是在向 TPM 声明:

“我只在乎 PCR 7 这份证据。其他 PCR 记得再准,与我无关。”

PCR 4/11 把篡改记录得清清楚楚,但你的策略没有引用它们,所以这些证据在解锁决策中被完全无视。篡改发生了、证据留存了、防御失效了——三位一体,互不干扰。

:很多人(包括我一开始)默认“绑了 TPM 就等于防篡改“。这是把“度量“和“度量 + 策略覆盖“混为一谈。防不防得住,取决于你的策略引用了哪些 PCR,以及那些 PCR 里度量的是什么。引用之前,先搞清楚里面有什么。

那绑 PCR 4/11 行不行?

既然 PCR 4/11 里有 UKI 的哈希,把密钥绑到它们上面,Evil Maid 不就解不开了吗?

行,攻击者确实解不开了。但你很快会发现自己也解不开了。

PCR 4/11 度量的是内核、initramfs、cmdline 这些每次升级都会合法变化的东西。Arch 上滚一次 pacman -Syu,内核更新了、initramfs 重建了,下次开机 PCR 值就变了——TPM 拒绝给密钥,你被锁在自己的系统门外。

除非你每次升级前都能预测新值、预先重绑(这正是下面哲学二干的事),否则这种绑定在生产上不可维护。这也是第六章选 PCR 7 的原因:不是因为它最强,而是因为它最稳——稳到连篡改都“稳定“地检测不到。

两种防御哲学

盲区讲清楚了,怎么补?业界有两条思路,哲学完全不同。

哲学一:预防——Secure Boot

思路:让攻击动作本身失败,而不是事后检测到。

Secure Boot 开启后,固件只执行带有合法签名的 EFI 文件。攻击者换上的恶意 UKI 没有你的签名,OVMF 直接拒绝加载——攻击在第一步就死了,根本轮不到 TPM 出场。

此时绑 PCR 7 反而就够了。PCR 7 绑定的语义变成了“Secure Boot 必须开着,且密钥库必须是我的“。逐条推演攻击者的选项:

  • 跑未签名的恶意 UKI? 固件验签失败,拒绝执行。攻击失败。
  • 进固件设置把 Secure Boot 关掉? SecureBoot 变量的值变了 → PCR 7 变化 → TPM 拒绝给密钥。攻击失败。
  • 换掉密钥库,用自己的密钥给恶意 UKI 签名? PK / KEK / db 的内容变了 → PCR 7 变化 → TPM 拒绝。攻击失败。

三条路全部堵死。而且内核升级不影响 Secure Boot 密钥库,PCR 7 保持稳定,升级友好。这就是为什么生产中普遍选 PCR 7:它不是偷懒,而是和 Secure Boot 配套使用时刚刚好——一个管“规则没被绕过“,一个管“代码必须合法“。

注意:这里有个隐含前提——固件设置的入口要可靠(比如设了固件口令)。否则攻击者进 BIOS 关 Secure Boot、顺手清掉 TPM 重新绑定,PCR 7 这条防线也就没了。纵深防御,没有哪一层是孤立的。

哲学二:检测 + 可预测度量——systemd-pcrlock

思路:接受 PCR 11 会合法变化,但让变化变得可预测

PCR 11 度量的 UKI 各段是可以预先计算的:升级前你已经知道新内核、新 initramfs 的内容,就能算出升级后 PCR 11 会是什么值。systemd-pcrlock(systemd 253 之后引入)干的就是这件事——升级时预测新的 PCR 11 值并预先重绑,让新旧值都能解开。

这条路线比 Secure Boot 更细:initramfs 的内容、内核 cmdline 的每个字节都纳入绑定范围,连“只改 cmdline 注入 init=/bin/bash“这种不动任何文件的攻击都能防住。代价是工具链复杂,升级流程和绑定流程耦合,对滚动发行版的运维是不小的负担。

业界参照

Windows BitLocker 的经典做法是混合绑定:PCR 0 + 2 + 4 + 11,再叠加 Secure Boot。固件、引导链、内核映像全在绑定范围里,由 Secure Boot 保证这些组件不会被非法替换,由 PCR 绑定保证验证规则不会被关闭。两条哲学它都要。

把两条哲学并排看一眼:

哲学一:Secure Boot + PCR 7哲学二:systemd-pcrlock + PCR 11
防御时机事前预防,恶意代码跑不起来事后检测,篡改留下证据并拒绝解密
覆盖粒度整条启动链(凡未签名皆拒)UKI 各段逐字节,含 cmdline
内核升级无感,PCR 7 不动需要升级前预测并重绑
运维复杂度低,一次配置长期有效高,升级与绑定流程耦合

没有标准答案,只有你愿意为多大的覆盖面付多大的运维成本。

可选动手:亲手看一次防御失效

百闻不如一见。在实验虚拟机上,这次攻击五分钟就能复现:

# 先备份!玩坏了 /boot 里唯一的 UKI 就进不了系统了
sudo cp /boot/EFI/Linux/arch-linux.efi /boot/EFI/Linux/arch-linux.efi.bak

# 记下当前的 PCR 4 和 PCR 7
sudo tpm2_pcrread sha256:4,7

然后模拟 Evil Maid,把 UKI 改动一个字节(追加一段垃圾即可):

echo "evil maid was here" | sudo tee -a /boot/EFI/Linux/arch-linux.efi

重启,观察三件事:

  1. 系统照常自动解锁,正常进桌面——TPM 给了密钥,LUKS 解开了,一切“正常“
  2. 再读一次 PCR,PCR 4 的值和改之前不一样了——篡改被忠实记录
  3. PCR 7 纹丝不动——绑定策略没有感知到任何事
sudo tpm2_pcrread sha256:4,7
sha256:
  4 : 0x...(和改之前不同的值)
  7 : 0x...(和改之前完全相同的值)

篡改发生、证据留存、防御失效,三位一体。这就是你在第六章搭起来的系统的真实防御水位。玩完记得把备份的 UKI 还原回去再重启,别带着一个“被攻击过“的系统继续往下做实验。

Secure Boot 实操路线(简述)

既然 Secure Boot 是正解,为什么第六章没开?因为在虚拟机上完整配置它要另外一番功夫,本书不展开,只给路线图,做过功课的读者可以自行实践:

  1. sbctl create-keys 生成自己的 PK / KEK / db
  2. sbctl enroll-keys 把自有密钥注入 OVMF 的固件变量(OVMF 需处于 Setup Mode)
  3. sbctl sign 给 Limine 和所有 UKI 签名
  4. 在 OVMF 设置里开启 Secure Boot
  5. PCR 7 变了(SecureBoot 从 0 变 1,密钥库从默认变成你的)——必须重新 enroll LUKS 密钥,把新的 PCR 7 值绑进去

第 5 步本身就是个很好的印证:开启 Secure Boot 后 PCR 7 的变化,恰好说明它度量的是“规则“——规则换了,值就变。

小结

  • PCR 7 度量的是 Secure Boot 的配置和密钥库,即“验证规则“,不含内核 / initramfs 的任何哈希
  • Evil Maid 攻击利用明文的 /boot 替换 UKI;只绑 PCR 7 的系统会照常交出密钥——TPM 没出错,是策略的关心范围太窄
  • 度量只是记录,不是拒绝;PCR 变了不阻止任何事,只有策略引用了那份证据,它才起作用
  • 直接绑 PCR 4/11 防得住攻击但扛不住日常升级;两条现实路线:Secure Boot + PCR 7(预防,升级友好),或 systemd-pcrlock + PCR 11(检测,预测式重绑,覆盖更细)
  • Secure Boot 不是可以顺手关掉的麻烦,而是 PCR 7 绑定策略成立的前提

下一章换一个方向:启动链到内核为止已经讲完了,但系统跑起来之后,每次执行的程序、加载的库谁来度量?这就是 IMA 登场的地方。

第九章 · IMA:把度量延伸到运行时

前面几章的可信启动讲到内核为止:固件度量 bootloader,bootloader 度量内核,然后信任链交接。但系统跑起来之后呢?每次执行的程序、加载的动态库、读入的配置,谁来度量?

答案是 IMA(Integrity Measurement Architecture,完整性度量架构):让内核自己接过这个角色,把度量延伸到运行时。

先说句实话:这一章是纯概念章,没有动手实验。原因很简单——Arch 官方内核没编译 CONFIG_IMA。我实测过 linuxlinux-hardened 都没有,检查方法:

zcat /proc/config.gz | grep CONFIG_IMA
# CONFIG_IMA is not set

Fedora / RHEL / openSUSE 默认是开的,想实操的读者本章末尾有路线。但 IMA 的思想价值不依赖动手:它是“信任链最后一环“的标准答案,也是面试和架构设计里绕不开的话题,值得讲透。

本章目标

  • 理解 IMA 在整条信任链中的位置:启动度量之后,运行时谁来接着度量
  • 掌握 IMA 的三要素:钩子点、策略、模板
  • 理解 boot_aggregate 如何把启动度量和运行时度量焊进同一条证据链
  • 从“记录“讲到“强制“:Appraisal 的三种模式和部署闭环
  • 了解 EVM 如何保护 IMA 的元数据,以及 IMA 在远程证明里的真正舞台
  • 正视 IMA 的局限性:它防什么、不防什么

定位:信任链的最后一环

回忆一下我们已经走过的链:

固件(度量)→ bootloader(度量)→ 内核 → ???

内核启动之后,这个链条就断了。系统接下来要跑成千上万个程序:init、登录管理器、shell、你敲下的每一条命令——它们都不在 PCR 0–9 的视野里。启动时一切正常,不代表运行时跑的东西也可信。

IMA 就是内核给出的答案:由内核自己在文件被使用的瞬间度量它,把结果 extend 进 PCR 10,并写进一份运行时度量日志(runtime measurement list)。

PCR 10 就是 IMA 的专用寄存器。第五章你在日志里见过它,现在可以补上它背后的故事了。

补上之后,整条信任链就完整了:

固件 ──度量──→ bootloader ──度量──→ 内核 ──度量(IMA)──→ 运行时的每个文件
PCR 0-9         PCR 4/11              PCR 10

注意角色变化:前两级是“上一级度量下一级“,到了 IMA 这里变成“自己度量自己管辖的东西“。内核不再是“被度量的对象“,而是“度量的执行者“。这个交接能成立,前提是内核本身可信——又回到了启动链:IMA 的一切结论,都建立在“内核是被可信启动链带上来的“这个前提上。链是环环相扣的,任何一环断了,下游全部作废。

度量机制三要素

要素一:钩子点(hook)

IMA 不是扫描磁盘,而是挂在内核的关键路径上,在文件“被使用“的那一刻触发度量。主要钩子点:

钩子触发时机说明
BPRM_CHECKexecve 执行程序每跑一个可执行文件就度量一次
MMAP_CHECK以可执行权限 mmap 文件动态库就是这么被抓到的
MODULE_CHECK加载内核模块
FIRMWARE_CHECK加载固件文件
KEXEC_KERNEL_CHECKkexec 加载新内核
FILE_CHECK文件被读取最重,什么文件都管,慎用
POLICY_CHECKIMA 策略本身变化度量“度量规则“的变化

注意 MMAP_CHECK 的设计:动态链接库里没有 execve,程序是靠 mmap(PROT_EXEC) 把 .so 映射进来的。如果只钩 execve,换掉一个 libc 就能神不知鬼不觉地污染所有程序——这个钩子堵的就是这条路。

要素二:策略(policy)

不是每个钩子、每个文件都度量,量什么由策略决定。策略是一组规则,写在 /sys/kernel/security/ima/policy

内核内置了几套策略,用内核参数选择,比如:

ima_policy=tcb

tcb(Trusted Computing Base,可信计算基)大致相当于:所有 exec + 所有可执行 mmap + 模块 + firmware。这是个合理的默认覆盖面。

自定义策略长这样:

measure func=BPRM_CHECK
measure func=MMAP_CHECK
appraise func=BPRM_CHECK fowner=0

语法很直白:measure(度量)或 appraise(校验),加触发条件,可加过滤(比如只管 root 拥有的文件 fowner=0)。上面这三行的意思是:所有被执行的程序都度量,所有可执行 mmap 都度量,但只有 root 拥有的程序在执行时才做签名校验——普通用户跑自己的脚本不拦。

策略还支持更细的过滤,比如按文件系统类型排除(fsuuid)、按 UID 圈定范围、用 dont_measure / dont_appraise 开白名单。调试 IMA 部署时很大一块工作就是打磨这些规则:覆盖面小了漏,大了性能吃不消还容易误伤。

注意:IMA 策略每次开机只能下发一次——写入 /sys/kernel/security/ima/policy 之后即锁定,直到下次重启。这个设计的用意是防止运行中的 root 篡改度量范围(把某类文件悄悄排除出去)。但它同时暴露了一个信任假设:IMA 防的是“文件被篡改“,防不了“root 在策略下发前抢跑“。所以正经部署里,策略必须在启动早期由 initramfs 下发,让前面几章讲的启动信任链把“策略下发“这一步也罩住。

要素三:模板(template)

每次度量记成日志里的一行,行的格式由模板决定:

  • ima:老式模板,md5 哈希,已过时
  • ima-ng:现代默认,sha256 + 文件路径
  • ima-sig:在 ima-ng 基础上附带文件签名

一条真实的 ima-ng 日志行长这样:

10  d879a1f8... ima-ng  sha256:9f2c3b...  /usr/bin/cmatrix

逐字段拆解:

字段含义
10PCR 编号,这条度量 extend 进了 PCR 10
d879a1f8...模板哈希:整条记录内容的哈希,extend 进 PCR 的就是它
ima-ng模板名
sha256:9f2c3b...文件内容哈希
/usr/bin/cmatrix文件路径

:第二列不是文件哈希!它是“模板哈希“——把模板名、文件哈希、路径等整条记录打包算的哈希。PCR 10 里链的是第二列。所以重放 IMA 日志(第五章练过的手艺在这里原样适用)时,你要链的是每行的第二列,不是文件哈希。

日志本身在 securityfs 里,用 root 直接看:

sudo head -5 /sys/kernel/security/ima/ascii_runtime_measurements
10 d879... ima-ng sha256:9f2c... boot_aggregate
10 3b1a... ima-ng sha256:44e0... /usr/lib/systemd/systemd
10 7c52... ima-ng sha256:1a9d... /usr/lib/ld-linux-x86-64.so.2
10 90ef... ima-ng sha256:c331... /usr/lib/libc.so.6
...

第一行永远是 boot_aggregate(下面单独讲),往后依次是 init 程序、动态链接器、libc……系统刚刚启动时最早被执行和映射的文件。光是这前几行,你就能看见 MMAP_CHECK 钩子在起作用:ld-linux 和 libc 不是被 exec 的,是被 mmap 进来的,照样被抓。

重放的思路和第五章一模一样:从全零开始,把每行第二列依次 extend,最后得到的值应该等于 tpm2_pcrread sha256:10 的读数。对得上,说明这份日志没被增删改过——哈希链又一次充当了“日志完整性校验和“。

boot_aggregate:两条链的焊缝

IMA 日志的第一条记录很特殊,叫 boot_aggregate(启动聚合)。它把 PCR 0 / 2 / 4 / 7 等启动期 PCR 的值聚合成一个哈希,作为运行时日志的第一环。

为什么要这么做?因为没有它,IMA 日志就是一份悬空的清单:它可以来自任何一次启动、任何一台机器。有了 boot_aggregate,运行时日志就锚定在了这次具体的启动上——验证方能确认“这份运行时日志确实接在这台机器的这次启动之后“。启动度量和运行时度量,从此焊在同一条证据链里。

这也回答了一个你可能会有的疑问:为什么 IMA 日志不能只靠 PCR 10 验证?因为 PCR 10 是日志的链上校验和,而 boot_aggregate 把日志的头接进了启动链。两者合起来,证据才完整。

Appraisal:从记录到强制

到目前为止 IMA 只做了“记录“——还记得第八章那句话吗,度量只是记录,不是拒绝。IMA 的 appraise(校验评估)功能是把记录变成强制的部分。

机制:文件的“身份证明“存在扩展属性(xattr)security.ima 里,有两种形式:

  • imahash:直接存文件哈希。不需要密钥体系,但 xattr 谁都能改——防君子不防小人,只适合做基线核对
  • imasig:存私钥对文件哈希的签名。校验时需要公钥在内核的 .ima keyring(密钥环)里

appraise 有三种模式,用内核参数 ima_appraise= 选择:

模式行为用途
fix校验失败不拦截,缺签名的就地补签初次部署,全系统“上户口“
log校验照做,失败只记日志灰度观察,看会误伤谁
enforce校验失败拒绝访问(EACCES正式运行,真拦

标准部署闭环是这样的:

  1. fix 模式全系统跑一遍,给所有合法文件补签上户口
  2. log 观察一阵,确认没有误伤
  3. enforce——此后任何被篡改的、未授权的可执行文件,执行时直接被拒

这就把第八章的教训在运行时层面补上了:不只是“记下谁跑了“,而是“没户口的不许跑“。

代价也很现实:升级软件要重新签名。这就是发行版需要官方签名体系的原因——你用 fix 模式给系统上户口,本质上是在扮演一个迷你的发行版签名方。另外注意一个细节:.ima keyring 只接受被内核内置信任链签过的 CA 下发的公钥,root 不能随手往里塞公钥。为什么?否则攻击者拿到 root 后塞一把自己的公钥进去,给自己篡改的文件签名,appraise 就形同虚设了。这和第八章 Secure Boot 密钥库的思路一脉相承:验证规则的变更,必须比被验证的对象更难伪造。

签名操作本身不神秘,ima-evm-utils 包里的 evmctl 就是干这个的:

# 生成签名密钥对(私钥离线保管,公钥放进内核 .ima keyring)
evmctl ima_sign --key /path/to/privkey.pem /usr/bin/myapp

# 检查文件当前的 security.ima xattr
getfattr -m security.ima -d /usr/bin/myapp

真正难的不是签一个文件,而是围绕签名建立整套流程:私钥怎么保管、CI 里哪一步签、升级包怎么带上新签名、keyring 里的公钥怎么轮换。技术上一下午能跑通,工程上是一个体系。

EVM:保护 xattr 本身

appraise 的信任根基是 security.ima 这个 xattr。问题来了:如果攻击者连 xattr 一起改呢?离线挂载磁盘,把文件和它的签名一起换掉——签名是对的,文件是坏的。

EVM(Extended Verification Module,扩展验证模块)堵的是这一层:把 security.ima、SELinux 标签等一组安全相关 xattr 打包,算一个 HMAC 或签名,存进另一个 xattr security.evm。改任何一个字段,校验就对不上。

EVM 也分两种口味,和 security.ima 的 imahash / imasig 对应:

  • HMAC 模式:用一把对称密钥算 HMAC。密钥不落地,启动早期由加密扇区或 TPM 解封出来,直接装进内核密钥环——这就是所谓 encrypted / trusted key 的用法
  • 签名模式:用私钥签名、公钥校验,适合需要跨机器验证的场景

EVM 密钥的来源值得再强调一遍:它由 TPM 解封(或从加密扇区读出),而解封条件可以绑定 PCR。闭环就此完成:篡改 xattr 也要先过“启动环境正确“这一关——而那一关,正是第四到第八章搭起来的东西。启动可信 → EVM 密钥可得 → xattr 可信 → IMA 签名校验可信 → 文件执行可信,一环扣一环。

IMA 与远程证明:真正的舞台

说句可能泼冷水的话:单机视角下,IMA 的价值有限。本机 root 能看日志,也就能骗日志——日志在内核内存里,而内核已经是攻击者的了,你读到的是攻击者想让你读到的。

IMA 的真正舞台是远程证明(Remote Attestation)。流程大致是:

  1. 验证方向目标机器发起挑战,附带一个一次性随机数(nonce,防重放——否则攻击者可以把上次“健康“时的 quote 录下来反复用)
  2. 目标机器的 TPM 把 nonce 和 PCR 值(包括 PCR 10)一起签名,产出 quote(引用)
  3. 验证方拿到 quote + IMA 日志,先重放日志验证链对得上 quote 里的 PCR 10
  4. 然后逐条审查:“这台远程机器自可信启动以来跑过的每一个文件,是否都在我的白名单里?”

注意第 3 步为什么可信:quote 是 TPM 用芯片内私钥签的,本机 root 伪造不了——这正是第一章说的“TPM 的价值在于你无法绕过它“。日志可以编,但编出来的日志重放后对不上 TPM 签出来的 PCR 值,谎言当场穿帮。root 控制得了内核内存里的日志,控制不了 TPM 芯片里的签名私钥,这个不对称就是远程证明的根基。

开源项目 Keylime 做的核心工作就是这个。所以在很多文档里,IMA 日志也被称为 runtime measurement list(运行时度量清单),它是远程证明的标准输入。

局限性:设计和面试都要知道

IMA 不是银弹,几个局限要心里有数:

  • 只管执行,不管数据。 BPRM_CHECK 管的是可执行文件。配置文件被改了?不在视野里。程序行为很大程度由配置驱动,这是个实打实的盲区——一个干净的 nginx 配上被改过的 nginx.conf,IMA 看到的全是绿灯。
  • 解释器绕过。 python evil.py 度量的是 /usr/bin/python,不是 evil.py。解释器本身是干净的,脚本是恶意的,IMA 一无所觉。要堵这个得靠 FILE_CHECK 之类更重的钩子把脚本也纳入度量,或者干脆让解释器自己在打开脚本时走 IMA 路径(一些加固发行版确实给解释器打过这类补丁),代价都是性能和复杂度。
  • 性能开销。 文件首次使用时算哈希,冷启动阶段大量文件集中度量,感知是有的。热缓存之后影响小,但“无感“谈不上。
  • 部署复杂度。 签名体系、keyring 管理、升级重签、策略下发时序……每一项都是运维负担。这是 IMA 在桌面端普及慢的根本原因,也是为什么它主要活在服务器和嵌入式这些有专职运维的场景。

这些局限放在一起,勾勒出 IMA 的真实定位:它不是“系统完整性“的完整答案,而是“可执行内容完整性“的扎实一步。设计系统时把它当成纵深的一层,而不是最后的一层。

想实操怎么办

本书的 Arch 环境跑不了 IMA,两条路:

  1. 换一个默认开启 IMA 的发行版。 Fedora 或 RHEL 的虚拟机,开箱就有 CONFIG_IMA=y,内核参数加 ima_policy=tcb 就能看到 /sys/kernel/security/ima/ascii_runtime_measurements 里滚滚而来的度量日志。跑起来之后第一件事建议做重放:从全零开始把日志第二列依次 extend,和 tpm2_pcrread sha256:10 对一遍——把第五章的手艺在新场景里再过一遍,IMA 就内化成你自己的东西了。
  2. 自编译内核。 Arch 的 linux 包有现成 PKGBUILD,改 config 打开 CONFIG_IMA 及相关的 appraise / EVM 选项,用 make localmodconfig 只编译当前加载的模块,能把编译时间从几小时压到一顿饭的工夫。走通这条路,你对内核构建的理解也会涨一截——算是买一送一。

无论走哪条路,记得还是在虚拟机里玩。ima_appraise=enforce 一旦配错(比如 keyring 里没装公钥),系统会在启动早期拒绝执行一切未签名文件——包括 init。这种把自己锁死的体验,留在虚拟机里就好。

小结

  • IMA 让内核接过信任链的最后一环:文件被使用的瞬间度量它,extend 进 PCR 10
  • 三要素:钩子点(在哪触发)、策略(量什么,每次开机只下发一次)、模板(记成什么样)
  • 日志第二列是模板哈希不是文件哈希,重放时链它;boot_aggregate 把运行时日志锚定到具体某次启动
  • Appraisal 把记录升级为强制:fix 上户口 → log 灰度 → enforce 真拦;代价是升级要重签,.ima keyring 受内核信任链约束
  • EVM 用 HMAC 保护 xattr 元数据,密钥由 TPM / 加密扇区解封,把防篡改闭环焊死
  • 单机上 root 能骗日志,IMA 的真正舞台是远程证明(quote + 日志重放 + 白名单审查),Keylime 是代表实现
  • 局限:不管数据文件、解释器绕过、有性能开销、部署复杂——知道这些,才算真的懂了 IMA

到这里,从芯片到固件、从固件到内核、从内核到运行时,整条信任链我们走完了。下一章收个尾,给你一张进阶地图:这本书没展开的路,各自通向哪里。

第十章 · 进阶地图:接下来学什么

本章目标

到这里,你已经把 TPM 入门的主线走完了:

  • 第三章:亲手 extend PCR,看清哈希链是怎么滚动的;
  • 第四章:把秘密 Seal 到 PCR 状态上,篡改后解封失败;
  • 第五章:重放固件度量日志,和芯片里的真实 PCR 逐一比对;
  • 第六章:把 LUKS 全盘加密绑到 TPM,开机自动解锁;
  • 第七章:摸清口令、PCR Policy、PolicyAuthorize 这套授权体系。

原理和实践的骨架都有了。这一章不再是实验,而是一张地图:告诉你接下来有哪些方向值得深入,每个方向解决什么问题、用什么工具、先读什么资料。顺序按我推荐的优先级排列,越靠前越实用。

书后还有三个附录:附录 A 是全书的踩坑实录,附录 B 是命令速查表,附录 C 收录了两个重放脚本的完整代码和逐段讲解。正文里来不及展开的细节,不少都在那里。

PCR 11 + systemd-pcrlock:可预测策略的终极形态

第六章我们把 LUKS 绑到了 --tpm2-pcrs=7。第七章讲了 PolicyAuthorize 的思路:与其绑定“具体的 PCR 值“,不如绑定“一把密钥的签名“。PCR 7 的问题是第八章讲的——它度量的是 Secure Boot 状态而非系统内容,固件度量链里混着太多不可预测的变量。

systemd 给出的现代答案是 PCR 11。在 UKI 架构下,内核、initrd、命令行这些组件在被打包成 UKI 时就被度量(度量进 PCR 11 的预期值可以提前算出来),systemd 还会把 UKI 内嵌的 .pcrsig 签名段交给 systemd-pcrlock 工具:它解析这些签名,生成一个“升级后可以预测“的 pcrlock 策略文件。最终形态是:

systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7+11 /dev/vda2

PCR 7 管 Secure Boot 没被关掉,PCR 11 管启动组件没被换掉,而系统升级(换内核、换 UKI)时新 UKI 自带的新签名会被 pcrlock 吸收,不用像裸 PCR 绑定那样每次升级都重新 enroll。

注意 这套方案目前主要在 Fedora、Arch 等走 systemd 全栈的发行版上成熟,而且要求你的启动链是 UKI 形态。第六章的实验环境恰好就是 UKI + Limine,已经具备条件,可以直接动手试。思路上它和第七章的 PolicyAuthorize 一脉相承:都是“用签名授权代替对易变值的硬绑定“。

动手前先看清楚现状再改:

# 当前 enroll 状态:绑了哪些 PCR、用的什么策略
cryptsetup luksDump /dev/vda2 | grep -A5 tpm2
# pcrlock 分析出的组件树和预测策略
systemd-pcrlock

systemd-pcrlock 不带参数时会打印它识别到的 UKI 组件和预测出的策略摘要,先把输出读懂再决定切不切换。另外老规矩不变:任何改动 enroll 的操作之前,确认还留着一个传统口令 keyslot 当退路——第六章立下的这条,在这里照样救命。

TPM 当日用密钥库:我最推荐你立刻用起来的方向

如果说前面所有实验都是在学原理,那这个是学完马上能落地、每天都能受益的用法:把 SSH 私钥存进 TPM。

传统做法是私钥躺在 ~/.ssh/id_ed25519 里,靠 passphrase 和文件权限保护。机器一旦被人拿到 shell(哪怕只是一次短暂的未锁屏),私钥文件被拷走就是永久沦陷。用 tpm2-pkcs11 把密钥生成在 TPM 内部之后,私钥从物理上不可导出——任何签名操作都必须让 TPM 芯片代劳。攻击者就算偷走整个 ~/.ssh 目录,里面也只有指向 TPM 对象的句柄,没有密钥本体。

接入方式和普通 PKCS#11 模块一样:

# 初始化 token 并生成 RSA 密钥(密钥本体在 TPM 里)
pkcs11-tool --module /usr/lib/pkcs11/libtpm2_pkcs11.so --login --keypairgen \
  --key-type rsa:2048 --label myssh
# 让 ssh 走 PKCS#11
ssh -I /usr/lib/pkcs11/libtpm2_pkcs11.so user@host

代价是每次签名要过一次 TPM,比纯软件慢一点,以及换机器意味着重新生成密钥、更新各服务的 authorized_keys。对日常使用来说,这个代价完全值得。

日常用起来还有两个实用技巧:

  • ~/.ssh/config 里给特定主机写 PKCS11Provider /usr/lib/pkcs11/libtpm2_pkcs11.so,就不用每次敲 -I 参数;
  • 想验证明私钥确实不在磁盘上,可以拔掉 TPM 会话后试试签名——它会失败,而这正是你要的效果。

同样的思路还能延伸:GPG 子密钥、VPN 客户端证书、代码签名密钥,凡是以文件形式躺在磁盘上的长期私钥,都值得考虑搬进来。

思考 这个方向其实贯穿了全书的一个核心思想:密钥的安全边界从“文件系统权限“收缩到“硬件芯片内部“。理解了 seal 的原理,再看 tpm2-pkcs11 就会发现它只是把同一套原语包装成了标准接口。

远程证明:从单机到集群的收官一步

第五章你重放日志、和本机 PCR 比对——这本质上是一次“自我证明“:你自己既是度量者又是验证者。远程证明(Remote Attestation)要解决的是:怎么让一台不信任你的机器,验证你的启动状态。

机制的关键多了一层签名。流程大致是:

  1. 验证方发来一个随机数(nonce,防重放);
  2. 被验证方让 TPM 用证明密钥(Attestation Identity Key, AIK)对“当前 PCR 值 + nonce“做一次 quote(引用),产出一份签名证据;
  3. 验证方拿着 AIK 公钥验签,再像第五章那样重放度量日志,确认日志内容确实能滚出 quote 里的 PCR 值;
  4. 验证方对照自己的“已知良好值“数据库,判断这台机器的启动环境是否可信。

AIK 的存在是为了隐私:如果每台机器直接拿背书密钥(Endorsement Key, EK)签名,所有 quote 都能被关联到同一颗芯片,等于给每台机器发了个全球唯一追踪 ID。AIK 是一把可以随时重新生成的二级密钥,它的可信性由隐私 CA(Privacy CA)背书——隐私 CA 验证你的 EK 证书是真实 TPM 厂商签发的之后,才给你的 AIK 发证。这样验证方知道“这是一颗真 TPM 签的“,但不知道是哪一颗。

动手练推荐 Keylime,CNCF 旗下的开源远程证明项目。它把整套流程封装成了 verifier(验证方)+ agent(被验证方)两个组件,跑起来不需要你自己实现 nonce 交换和日志比对。你的实验环境天然适合:再克隆一台虚拟机当 verifier,原虚拟机装 agent,就能在一个 KVM 宿主机上跑通完整的双向证明流程。

如果想先用原始命令感受一下 quote 长什么样,tpm2-tools 就有:

# 生成 AIK 后对当前 PCR 做 quote,nonce 由验证方提供
tpm2_quote -c aik.ctx -l sha256:0,1,2,3,7 -q <nonce> -m quote.msg -s quote.sig

第五章的 replay_pcrs.py 在这里直接复用:验证方重放被验证方发来的日志,算出的值和 quote 里签名的 PCR 值对得上,证据链就闭合了。

思考 走到这一步,可信启动的视角就从单机扩展到了集群和云:数据中心里成千上万台机器,每一台开机后都向 verifier 报到,证明“我跑的是没被篡改的固件和系统“,不通过的拒绝接入。这就是零信任架构里“工作负载可信度“那一层的实现方式。

TPM 流量嗅探:你以为芯片内部安全,总线上呢

这是很多人忽略的攻击面。离散 TPM(discrete TPM, dTPM)是一颗独立芯片,通过 SPI 或 LPC 总线连在主板上。芯片内部的运算确实安全,但 CPU 和 TPM 之间传输的数据是明文走总线的——攻击者只要在总线上焊一个几美元的嗅探器,就能截获所有通信。

这不是理论。已有公开研究演示过用 SPI 嗅探器截获 BitLocker 的 VMK(卷主密钥):Windows 开机时 TPM 把解封后的密钥明文送回内存,嗅探器原样抄走,全盘加密形同虚设。整个过程不需要动芯片一根毫毛。

防御手段 TPM 2.0 规范里就有:加密会话。tpm2_startauthsession--enable-encrypt 之类的参数建立加密会话后,敏感参数在总线上是密文,嗅探器抄走的只是一堆乱码。tpm2-tools 里不少命令支持会话加密,只是默认不开——这也是“安全默认值“很重要的一个例子。

另外两个相关的知识点:

  • fTPM(firmware TPM)直接做在 CPU 内部(Intel 叫 PTT,AMD 叫 fTPM),CPU 和“TPM“之间根本没有暴露在外的总线,物理嗅探这条路天然断了。代价是它依赖 CPU 厂商的固件实现,历史上出过漏洞。
  • EK 证书里烧录的是芯片厂商的私钥签名,配合 TPM 内部“密钥不可导出、芯片不可克隆“的设计,让伪造一颗“身份相同的 TPM“在物理上很难做到。嗅探解决的是传输安全,EK 解决的是芯片身份,两者是不同层面的防线。

坑 虚拟机里的 swtpm 没有真实的总线攻击面——它和 QEMU 之间走的是本机 Unix socket,物理嗅探无从谈起。所以这一节的内容没法在第二章的环境里复现,属于“知道有这回事、真机上记得开加密会话“就足够的知识。真想看总线流量,可以在宿主机上抓 swtpm 的控制通道包聊作替代。

更前沿的方向(纯阅读即可)

下面这些超出本书范围,但值得知道它们存在,读文献遇到时不陌生:

  • DRTM(Dynamic Root of Trust for Measurement,动态信任根):前面所有内容都基于 SRTM——信任链从开机那一刻的固件开始滚。DRTM 的思路是系统跑着跑着,随时可以“重启信任根“,从一个硬件强制的小型度量点开始重新建链。开源实现看 TrenchBoot 项目。
  • 机密计算(Confidential Computing)里的 vTPM:Intel TDX、AMD SEV 这类技术把整台虚拟机的内存加密隔离,连 hypervisor 都看不到里面。虚拟机里的 TPM 自然也得是虚拟的——swtpm(你第二章用的那个软件 TPM)在云场景下就是这个角色,QEMU 通过它给虚拟机提供 TPM 2.0 设备。
  • Pluton:微软主推的方案,把安全处理器直接集成进 CPU 封装,目标是取代独立 TPM 芯片,同时承担 Secure Boot、密钥管理等职责。Xbox 和新款 Windows 设备上已经落地。

书单与资料

按性价比排序:

  • 《A Practical Guide to TPM 2.0》(Will Arthur, David Challener):IBM 出的,代码示例丰富,网上能找到免费版本。本书很多概念讲法参考了它,想深入对象层次、会话、授权细节就读这本。
  • TCG 官方规范(Trusted Computing Group 网站):枯燥但权威。分四部分——架构、结构体、命令、支持例程。遇到“这个命令到底支持哪些参数“的问题时直接查规范第三部分,比博客靠谱。
  • Arch Linux Wiki 的 “Trusted Platform Module” 词条:实践向资料的标杆,配置细节和发行版差异都写得很清楚。本书第六章的很多做法和它互相印证。
  • systemd 的 PCR 注册表文档(systemd.io 上的 “TPM2 PCR Measurements”):讲清楚每个 PCR 在 systemd 启动链里度量了什么,读懂 PCR 7/11 的分工绕不开它。

如果只能选一个方向

地图给全了,最后说说我自己的选择逻辑,供参考:

  • 想要立刻见效的安全收益:选 tpm2-pkcs11。半天配置完,从此 SSH 私钥不怕偷,这是性价比最高的一步。
  • 把本书的 LUKS 实验做到生产级:选 PCR 11 + pcrlock。它和第六章无缝衔接,是“实验走向实用“的最后一公里。
  • 往职业发展或研究上走:选远程证明。它把本书所有零散知识点串成一个完整系统,也是云厂商和零信任架构正在真金白银投入的方向。
  • 嗅探与参数加密属于防御意识,不用专门练,但真机上配置任何 TPM 功能时记得问自己一句:这段通信加密了吗?

结语

回头看这本书走过的路:从“TPM 是什么“开始,到你亲手 extend 出一条哈希链,到一个秘密因为一个字节的改动而拒绝解封,到逐条重放固件度量日志并全部对上,再到把整块硬盘的钥匙交给启动环境的度量值,最后理解了授权策略如何在这之上分层。

如果要用一句话收束可信计算的核心思想,我会这样写:

不信任任何软件关于自身的声明,只信任从硬件信任根出发、可以被独立验证的度量证据链。

PCR 不可重置、哈希链不可逆、密钥不可导出、日志可重放——这些看似零散的机制,拼在一起就为了服务这一句原则。你在虚拟机上验证过的每一条命令,都是这条证据链上的一环。

剩下的路,地图上已经标出来了。去走就是了。

附录 A · 踩坑实录

这本书里每个实验都是真跑过的,坑也是真踩过的。这里把它们集中收录,每条按“现象 → 原因 → 解法 → 教训“展开。有些是 TPM 特有的,有些是虚拟化和 Linux 系统管理的老坑在新场景里复发——但教训大多通用。

收录标准只有一条:这个坑真的浪费过我的时间。那些“看文档就能避免“的问题不在此列。

1. virt-install 报“打开文件失败“:deepin 缺 OVMF_VARS_4M.ms.fd

现象:在 deepin 宿主机上 virt-install --boot uefi 创建虚拟机,直接报错退出,提示无法打开固件变量文件(OVMF_VARS_4M.ms.fd)。

原因:libvirt 不是直接去找固件文件的,它先读发行版提供的固件描述符(/usr/share/qemu/firmware/*.json),按优先级选中一个变体。deepin 打包的 ovmf 提供的描述符里,带 .ms 后缀(Microsoft 签名键预置版)的变体排在前,但软件包里恰恰没装这个文件——描述符指向一个不存在的文件。

解法:别让它自动探测,显式指定 loader 和 nvram 模板:

virt-install ... \
  --boot loader=/usr/share/OVMF/OVMF_CODE_4M.fd,loader.readonly=yes,loader.type=pflash,nvram.template=/usr/share/OVMF/OVMF_VARS_4M.fd

(具体路径以 dpkg -L ovmf 的实际输出为准。)

如果换发行版后遇到类似问题,排查顺序是:先看 /usr/share/qemu/firmware/ 下有哪些描述符,再逐个检查里面引用的文件是否真的存在于磁盘上——多半是描述符和实际文件对不上。

教训:发行版打包和 libvirt 的期望对不上时,自动探测只会给你一个含糊的错误。显式指定比祈祷自动探测选对要可靠。

2. virt-viewer:只能通过 libvirt 使用 –attach 连接显示器

现象virt-viewer --connect qemu:///system <虚拟机名> 起不来,报“只能通过 libvirt 使用 –attach 连接显示器“之类的错误。

原因:virt-viewer 默认想直连 QEMU 的显示通道,但 libvirt 管理的虚拟机不走这条路。

解法:加上 --attach

virt-viewer --connect qemu:///system --attach <虚拟机名>

或者干脆装 virt-manager,图形界面里双击虚拟机名就行。

教训:报错信息里往往已经写了答案,先把错误原文读完整再动手。这条看起来是废话,但当时我就是扫了一眼“连接显示器失败“就开始乱试,漏掉了紧跟其后的“use –attach“提示——那五个字就是解法本身。

3. 无 tty 环境下 sudo 要密码

现象:在某些脚本化或受限 shell 环境里跑 sudo,它想从终端读密码但没有终端可用,直接失败。

解法:两个出路——

# 方案一:走 polkit,弹 GUI 授权框(桌面环境适用)
pkexec <命令>

# 方案二:用 SSH_ASKPASS 把密码从别处喂给 sudo
SSH_ASKPASS=/path/to/askpass.sh SSH_ASKPASS_REQUIRE=force sudo -A <命令>

教训sudo 的密码输入路径比想象中多,-A(走 askpass 程序)、-S(从 stdin 读)、-n(非交互,没缓存凭据就直接失败)的区别值得看一遍。写自动化脚本时优先 -n——它失败得干脆,不会卡住等一个永远等不到的终端输入。

4. heredoc 抢 stdin,sudo -S 拿不到密码

现象:改造 initramfs 的脚本里用 heredoc 写配置文件,脚本里还有 sudo -S 要从 stdin 读密码。结果 heredoc 把 stdin 占走了,sudo -S 拿到的密码不对,后续步骤静默失败——配置文件根本没写进去。差一点就以“旧钩子 + 新 cmdline“的中间态重启,那大概率起不来。

原因:heredoc 会重定向整个命令块或管道的 stdin,嵌在其中的 sudo -S 读到的不是你以为的密码来源。而且这种失败不炸出明显错误,配置写入步骤“看起来执行了“。

解法:把密码读取和文件写入拆开,密码先读进变量或用 sudo -v 提前缓存凭据,heredoc 里不再碰 sudo;写完后立刻 cat 产物确认内容。

更稳的做法是给这类脚本加一个固定收尾:把所有改动过的文件列出来逐个 catdiff,确认每个都符合预期再允许重启。多十秒钟,少一次进不去系统的惊魂。

教训:两条。第一,每一步改完都验证产物,不要相信“命令没报错“;第二,改造类操作最危险的是中间态——一半生效一半没生效,比全失败更难排查。

5. UKI 的 0600 root 权限:objcopy 提取 initrd 静默失败

现象:从 UKI 里用 objcopy 提取 initrd 段,命令跑完没有任何报错,但输出文件是空的。

原因:UKI 文件(/efi/EFI/Linux/*.efi)默认权限 0600 属 root,普通用户读不了。而命令里习惯性地加了 2>/dev/null,“Permission denied“被吞得干干净净。

解法:提取前加 sudo,或者干脆先把 UKI 拷到临时目录改权限再操作。更重要的是:调试期别随手 2>/dev/null

顺带一提,UKI 设成 0600 不是打包失误,是有意的:里面可能嵌着包含敏感信息的 initrd 或命令行参数。理解这一点后,“需要 sudo“就不是麻烦而是提示——这个文件本来就不该被随便读。

教训:先确认输入有效,再分析输出。空输出文件不是“提取失败“的现象,而是“根本没读到输入“的现象——如果上来就盯着 objcopy 的参数找茬,会浪费很久。

6. tpm2_verifysignature 的版本差异

现象:按记忆写的 tpm2_verifysignature --format=plain ... 在新版本上报参数错误。

原因:tpm2-tools 5.8 起,--format=plain 被标记为 deprecated,而且对 openssl 产生的裸签名需要显式给 --scheme=rsassa。老教程里的写法在新版行为变了。

解法

tpm2_verifysignature -c key.ctx -g sha256 --scheme=rsassa -m msg.bin -s sig.bin -t ticket.bin

教训:tpm2-tools 版本之间参数变动不少,--help 比记忆可靠,也比网上抄来的老命令可靠。装完先看版本:tpm2_verifysignature --version。遇到参数报错时,第一步是 man--help 对照当前版本的写法,而不是怀疑自己记错了——很可能就是版本变了。

tpm2_verifysignature --help | grep -E 'scheme|format'
# 当前版本里 --format 还在但标了 deprecated,--scheme 才是正路

这本书所有 tpm2-tools 命令都在 5.8 上验证过;如果你用的是别的版本,附录 B 的命令照抄之前也建议先对一遍 --help

7. “LUKS 密码输错还能进系统“的真相

现象:测试 LUKS 解锁时故意输错密码,结果系统还是进了桌面。第一反应是“加密没生效?“

原因:busybox 的 encrypt 钩子允许重试 3 次,输错后并没有失败退出,而是又给了提示——后来(可能连自己都没意识到)输对了,或者后续解锁路径成功了。系统能进桌面本身就说明根分区被解开了,加密当然生效。

解法:不猜,看证据。进系统后:

lsblk
# 根分区应该挂在 /dev/mapper/luks-xxx 这类 dm-crypt 设备上
cryptsetup status luks-xxx

看到 LUKS 设备确实处于 active 状态,“加密没生效“的猜想就不攻自破。反过来想,如果根分区直接挂在 /dev/vda2 而没有 mapper 层,那才是真的出问题了。

这条坑还教会我一个排查习惯:涉及“加密生没生效““绑定起没起作用“这类安全性判断时,永远去看系统状态的实际证据,不要用“我观察到的行为像不像“来推断——人的观察在这种事上非常不可靠。

教训:对反常现象下结论之前,先找一条能直接证伪的证据。lsblk 的设备层级比感觉可靠。

8. Arch 全系内核没有 CONFIG_IMA

现象:照着第九章做 IMA 实验,/sys/kernel/security/ima/ 目录根本不存在。

原因:IMA 需要内核编译时开启 CONFIG_IMA,而 Arch 官方内核(包括 linux、linux-lts、linux-zen)都没开这个选项。不是配置错了,是内核里压根没有这个功能。

解法:动手前先确认内核支持:

zcat /proc/config.gz | grep CONFIG_IMA
# 或者
grep IMA /boot/config-$(uname -r)

想用就得自己编译内核(或换支持 IMA 的发行版内核),配合 ima_appraise 等启动参数。

教训:凡是依赖内核特性的实验,第一步永远是确认当前内核编没编进去。这一步五秒钟,省下的排查时间以小时计。同理适用于其他内核特性依赖:/proc/config.gz(或 /boot/config-*)是你判断“功能不存在“和“配置不对“的分界线。

总结

八条坑看下来,反复出现的主题就三个:

  1. 静默失败是头号敌人2>/dev/null、heredoc 抢 stdin、重试机制——坑都不是“报错看不懂“,而是“该报的错没报“。调试期让错误都暴露出来。
  2. 显式优于自动。固件文件、签名 scheme、内核特性——自动探测和老经验都会背叛你,显式指定和 --help 不会。
  3. 每步验证产物。改造类操作的危险在中间态,写完看一眼、挂完看一眼、重启前再看一眼。

这些教训没有一条是 TPM 特有的——这本身就是个好消息:你在本书实验里练出的排查习惯,放回日常 Linux 运维里一样好用。

附录 B · 命令速查表

本书用到的命令按用途分四组。每条只给最典型的写法和一句用途,完整参数以各自 --help 或 man 手册为准——tpm2-tools 版本间参数有变动,别背,查。

TPM 基础操作

命令用途
tpm2_getcap properties-fixed查看 TPM 芯片的固定属性(厂商、固件版本、支持的算法等)
tpm2_pcrread读取所有 bank 的所有 PCR 当前值
tpm2_pcrread sha256:16只读 sha256 bank 的 PCR 16
tpm2_pcrextend 16:sha256=<哈希值>把一个 32 字节哈希 extend 进 PCR 16(第三章实验 A 的核心动作)
tpm2_pcrreset 16重置 PCR 16(只有 16-23 等可本地重置的 PCR 允许此操作)
tpm2_getrandom --hex 32从 TPM 真随机数发生器取 32 字节随机数,十六进制输出
tpm2_eventlog <日志路径>解析二进制固件度量日志为可读 YAML(日志在 /sys/kernel/security/tpm0/binary_bios_measurements

实验 A 的一条完整练习链路:

tpm2_pcrreset 16                                  # 清空 PCR 16,回到全零
echo -n "hello tpm" | sha256sum                   # 先自己算出要 extend 的哈希
tpm2_pcrextend 16:sha256=<上一步的哈希>            # extend
tpm2_pcrread sha256:16                            # 读回来,和手工滚哈希的结果对比

预期观察点:extend 后 PCR 16 不再是全零,且等于 sha256(00...00 || 你的哈希)——第三章就是让读者手工验证这个等式。

对象与策略

命令用途
tpm2_createprimary -C o -c primary.ctx在 owner 层次下创建主密钥(primary key),存为上下文文件
tpm2_createpolicy --policy-pcr -l sha256:16 -L pcr.policy创建一条 PCR 策略:要求 PCR 16 等于当前值
tpm2_create -C primary.ctx -L pcr.policy -u seal.pub -r seal.priv -i-在主密钥下创建密封对象,stdin 读入秘密,绑定策略
tpm2_load -C primary.ctx -u seal.pub -r seal.priv -c seal.ctx把密封对象的公/私部分载入 TPM,得到句柄
tpm2_unseal -c seal.ctx -p session:session.ctx解封:策略会话满足条件才吐出秘密
tpm2_startauthsession --policy-session -S session.ctx开启一个策略会话(区别于口令会话,可叠加多条策略命令)
tpm2_policypcr -S session.ctx -l sha256:16在会话上附加“PCR 16 须等于指定值“条件
tpm2_policyauthorize -S session.ctx -n key.name -i policy -t ticketPolicyAuthorize:用授权密钥的签名把策略授权出去(第七章核心)
tpm2_verifysignature -c key.ctx -g sha256 --scheme=rsassa -m msg -s sig -t ticket验签并生成 ticket,供 PolicyAuthorize 使用
tpm2_loadexternal -C o -G rsa -u key.pub -c key.ctx -n key.name把外部公钥(如 openssl 生成的)载入 TPM 供验签
tpm2_flushcontext session.ctx冲刷会话/对象上下文,释放 TPM 有限的会话槽位

实验 B 的 seal/unseal 完整链路(各命令按依赖顺序):

tpm2_createprimary -C o -c primary.ctx
tpm2_pcrread sha256:16 -o pcr16.bin
tpm2_createpolicy --policy-pcr -l sha256:16 -L pcr.policy
echo -n "my secret" | tpm2_create -C primary.ctx -L pcr.policy \
  -u seal.pub -r seal.priv -i-
tpm2_load -C primary.ctx -u seal.pub -r seal.priv -c seal.ctx
tpm2_startauthsession --policy-session -S session.ctx
tpm2_policypcr -S session.ctx -l sha256:16
tpm2_unseal -c seal.ctx -p session:session.ctx     # 输出: my secret
tpm2_flushcontext session.ctx

预期观察点:PCR 16 被 extend 过任意值之后,同一条链路走到 tpm2_unseal 会报策略校验失败——那就是“篡改检测“的体感。

磁盘加密

命令用途
systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 <设备>把 LUKS 卷绑定到 TPM,PCR 7(Secure Boot 状态)作为解封条件
systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7+11 <设备>进阶形态:叠加 PCR 11,配合 systemd-pcrlock(见第十章)
cryptsetup luksDump <设备>查看 LUKS 头信息,确认 TPM2 token 和 keyslot 登记情况

enroll 后的自检链路:

cryptsetup luksDump /dev/vda2
# 预期看到:Keyslots 里新增一个类型为 tpm2 的槽位,
#          Tokens 段有 systemd-tpm2 记录,pcrs 字段写着 7
lsblk                                       # 确认根分区在 dm-crypt 设备上

重启验证时,如果 TPM 解锁成功,启动过程不会出现 LUKS 密码提示,直接进系统;用附录 A 第 7 条的方法确认解密确实发生了。

虚拟机管理

命令用途
virsh list --all列出所有虚拟机(含关机的)
virsh start <虚拟机名>开机
virsh shutdown <虚拟机名>正常关机(依赖客户机 ACPI 响应)
virsh snapshot-create-as <虚拟机名> <快照名> --disk-only --atomic对做危险实验前创建仅磁盘快照
virsh snapshot-revert <虚拟机名> <快照名>回滚到快照——改 initramfs 翻车后的救命稻草
virsh snapshot-list <虚拟机名>查看快照列表
virt-viewer --connect qemu:///system --attach <虚拟机名>打开虚拟机显示窗口(注意必须带 --attach

实验前的快照习惯(第六章改 initramfs 之前必做):

virsh snapshot-create-as tpm-lab before-initramfs --disk-only --atomic
virsh snapshot-list tpm-lab
# 翻车后:
virsh snapshot-revert tpm-lab before-initramfs

virsh shutdown 只是“请求“关机,客户机里系统卡住时它不会有任何效果,等多久都没用。确认卡死后用 virsh destroy <虚拟机名> 强制断电——前提是已有快照兜底,否则先看看还能不能从别的途径进去救数据。

注意 凡是 tpm2_ 开头的命令,输入输出对象大多是上下文文件(.ctx)而非密钥本身——密钥本体永远留在 TPM 内部,文件里只是引用。看到一堆 .ctx .pub .priv 文件别慌,那正是 TPM 的工作方式。

附录 C · 本书用到的脚本

这里收录两个完整跑过的脚本,附上逐段讲解。它们都不长,没有任何第三方依赖——这是刻意为之:重放验证这件事,理解每一步字节从哪来、到哪去,比直接调库重要得多。

replay_pcrs.py:重放固件启动度量日志

功能:读取内核暴露的二进制 BIOS 度量日志,逐条重放哈希链,算出每个 PCR 的理论值,再和 TPM 芯片里的真实值逐一比对。第五章实验 C 的核心脚本。

使用方法

python3 replay_pcrs.py
# 需要能读 /sys/kernel/security/tpm0/binary_bios_measurements(一般 root 或 securityfs 已挂载)
# 脚本内部的 tpm2_pcrread 通过 sudo 调用

完整代码

#!/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]

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 = {}
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:
        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

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” 事件(assert 处)。它的存在标志着这是 crypto-agile 格式——每条事件可以携带多个哈希算法的摘要。老格式(每条只带 SHA-1)的日志结构不同,本脚本会直接拒绝。
  • 第一条事件里还声明了日志用到的算法 ID 和对应摘要长度(nalg 循环)。ALG_NAME 把 TCG 的算法 ID(如 0x000B)映射成 hashlib 认识的名字。
  • 主循环逐条解析事件:PCR 编号、摘要列表、事件数据长度。事件数据本身(如命令行字符串)不参与哈希——参与的是固件当年算好的摘要,脚本只是把它“滚动“进去。
  • 滚动的公式就是第三章亲手验证过的那条:PCR_new = hash(PCR_old || digest)old 不存在时按全零初始化——这正是 PCR 上电初值。
  • 后半段解析 tpm2_pcrread 的文本输出做比对。这块是“够用就行“的文本解析,换个 tpm2-tools 版本输出格式变了就得跟着调,这是它的脆弱点。

已知边界

  • 只支持 crypto-agile 格式(Spec ID Event03)。老机器、老固件的 SHA-1 旧格式日志不支持。
  • 比对的是“日志能滚出的值“和“PCR 当前值“。如果开机后还有组件往这些 PCR 里 extend 过东西而没记进日志,会对不上——这不是脚本错了,是发现了日志没记录的度量,值得追下去。

replay_ima.py:重放 IMA 运行时度量日志

功能:重放 IMA(Integrity Measurement Architecture)的 ASCII 度量日志,验证重算值与 PCR 10 一致。第九章配套脚本——但注意,只有内核编了 CONFIG_IMA 的环境才能用(Arch 官方内核没有,见附录 A)。

使用方法

python3 replay_ima.py
# 需要 /sys/kernel/security/ima/ascii_runtime_measurements 存在

完整代码

#!/usr/bin/env python3
"""重放 IMA 运行时度量日志,验证与 PCR 10 一致"""
import hashlib, subprocess

pcr = bytes(32)
with open("/sys/kernel/security/ima/ascii_runtime_measurements") as f:
    n = 0
    for line in f:
        h = line.split()[1]
        if h == "0" * 64:
            continue
        pcr = hashlib.sha256(pcr + bytes.fromhex(h)).digest()
        n += 1

out = subprocess.run(["sudo", "tpm2_pcrread", "sha256:10"],
                     capture_output=True, text=True).stdout
actual = out.split("0x")[-1].strip().lower()
print(f"重放了 {n} 条度量记录")
print(f"重放计算值: {pcr.hex()}")
print(f"TPM 实际值: {actual}")
print("一致 ✓" if pcr.hex() == actual else "不一致 ✗(日志在开机后仍在增长属正常)")

关键实现点

  • IMA 日志是 ASCII 文本,每行一条度量记录,第二个字段就是 extend 进 PCR 10 的 SHA-256 摘要。所以不需要 struct 解二进制,split() 就够了。
  • 滚动公式和固件日志完全一样:PCR_new = sha256(PCR_old || digest),初值全零。学透一次,到处能用。
  • 跳过全零摘要的行:那是占位/无效的度量记录,不参与真实哈希链。
  • 实现刻意简化:只处理 sha256 bank、直接用 split("0x") 抠 PCR 值。作为验证工具够用,别当库抄。

已知边界

  • 活日志问题:IMA 日志是持续增长的——你读到一半,系统里一个新文件被访问,IMA 又记了一条、又 extend 了一次。这种情况下重放值和 PCR 值对不上,不是篡改,是日志在你读它的时候还在动。
  • 这也是为什么正规的远程证明系统(如第十章提到的 Keylime)抓日志和取 quote 必须原子进行:先让 TPM 用 quote 锁定某一时刻的 PCR 值,再验证日志能滚出这个值,中间不能允许新的度量插进来,否则永远对不齐。
  • 日常手动验证时,对不上先看末尾几行日志的时间戳,多半是新度量进来了;重跑一次往往就一致了。

思考 两个脚本加起来不到一百行,但它们做的事情——独立重放、独立验证——就是整个可信计算最核心的动作。商业远程证明系统做的事本质上一样,只是多了签名、nonce 和网络传输。