理解 systemd v262 里的 NvPCR

systemd 用 TPM 的 NV 内存里的类 PCR 寄存器来缓解 PCR 稀缺,并在 v262 里重做了锚定设计。这篇动手实录在一个软件 TPM 上从零重建 NvPCR,顺路讲清涉及的 TPM 概念与安全边界。

中文
复制

systemd 用 TPM NV 内存中分配的类 PCR 寄存器来应对 PCR 数量不足的问题, 其锚定设计在 v262 中被重做。 在这篇动手实践中,我们面对软件 TPM 从零重建一个 systemd NvPCR, 顺带补齐所需的 TPM 概念,并分析这套设计为何安全。

systemd 为什么总觉得 PCR 不够用?#

systemd 的许多安全特性都依赖 TPM PCR 度量:无密码全盘加密可以在 PCR 度量符合预期时自动解锁磁盘,既防止凭据被窃取,又允许 root 盘加密的远程机器无人值守重启。服务凭据可以加密绑定到预期的 PCR 状态。还支持绑定启动阶段的凭据,让某些机密只能在 initrd 中解密。而借助远程证明,一台机器可以让 TPM 对当前 PCR 状态签名(即所谓的 quote),从而向另一方证明自己启动了什么、之后又发生了什么。这一切都建立在 PCR 度量之上。TPM PCR 是稀缺资源。在常见的符合标准的 TPM 上只有 24 个 PCR 可用。 低编号的 PCR 0-7 归固件所有,用于 UEFI 启动度量。 16 是调试用 PCR,可以被重置,因此不可用;17-22 预留给 Dynamic Root of Trust for Measurements1,23 预留给应用支持。 于是只剩 8-15 留给 systemd 做所有与操作系统相关的度量2。从远程证明的角度看,单个度量寄存器就足以验证一个系统。我们可以用度量日志重放事件,解读被度量的内容,从而得到观察到的最终值。但 PCR 不只被远程验证方读取,本地机密也锁定在它们之上,而这种用途需要可预测的值:流入某个 PCR 的每一个事件都必须事先已知,否则策略就会失效。有些度量本质上不可预测,比如取决于各平台闭源的厂商固件,或者取决于用户行为。我们可能想把登录事件纳入远程证明 quote,但 root 盘在有人登录之后仍应能自行解锁。所以那些嘈杂、不可预测的事件类型需要有自己的寄存器:避开锁所依赖的 PCR,同时仍被度量、仍可证明。这正是 systemd 很快撞上的硬性限制:操作系统只有八个 PCR 槽位,没多少空间给每种事件类型单独分配寄存器。这就是 systemd 在 v259 中引入 NvPCR 的原因:额外的类 PCR 寄存器,分配在 TPM 的非易失性内存中,名字也由此而来。它们承载那些我们不想放进真实 PCR 的事件类型,其值通过远程证明被消费。在 v262 中,NvPCR 的锚定方式被重做,以提升安全性。此前,锚定基于一个随机密钥,该密钥被封存到 PCR 11 并存储在磁盘上,攻击者只要启动另一个操作系统、重放出预期的 PCR 11 值就能恢复它,或者干脆换成一个自己知道的密钥。这篇博客介绍 v262 中发布的重做后的设计:它如何工作,以及为何安全。

跟着一起做的准备工作#

我们会在命令行上针对一个软件 TPM 构建自己的 NvPCR,借此了解基本工作原理,并对这件事有个直观感受。如果你只想读不想动手,也没问题,重要的输出我都会贴出来。前提是先把需要的工具装上,比如用 nix 或 dnf:

nix shell nixpkgs#{tpm2-tools,xxd,swtpm,openssl}
dnf install tpm2-tools vim-common swtpm openssl

创建一个工作目录,然后启动软件 TPM:

mkdir state
swtpm socket \
 --tpm2 \
 --tpmstate "dir=$PWD/state" \
 --ctrl "type=tcp,port=2322" \
 --server "type=tcp,port=2321" \
 --flags startup-clear \
 --pid "file=$PWD/swtpm.pid" \
 -d

导出连接信息,让 tpm2-tools 知道去哪里找这个 TPM:

export TPM2TOOLS_TCTI="swtpm:host=127.0.0.1,port=2321"

读取 PCR,确认 TPM 工作正常:

tpm2_pcrread sha256

这时应该看到 PCR 0-16 全为零。如果不是,你连接的可能是平台的 TPM 而不是软件 TPM,再检查一遍 TPM2TOOLS_TCTI 是否导出正确。这一点很重要,因为我们不希望接下来的实验动到你平台上密封的密钥。

用 TPM NV index 替代 PCR#

TPM NV index3 是一个非易失性存储槽,由唯一的名称标识。NV index 在重启后依然存在,可以存放用户自定义的数据:一个不透明的值、一个计数器、位域等等。NV index 的属性决定了它的行为以及用途:句柄、存储数据的大小、一组控制该 index 如何被操作或读取的属性,以及一个授权策略和授权值(后者是唯一不公开的属性),用来可选地指定在什么条件下才能操作该 index。每个 index 都有一个 nameAlg,即用于从 index 的公开属性计算唯一名称的哈希算法4,计算方式为

Name = nameAlg || H_nameAlg(marshal(TPMS_NV_PUBLIC))。

来创建一个类似 PCR 的 NV index。这里用 tpm2-tools 里的 tpm2_nvdefine。 0x01000000 是我们正在定义的 index 的句柄(随便挑的)。--hierarchy=o 标志选择定义该 index 时所用的授权:像我们这样的 NV index 位于 TPM 的 owner hierarchy 中,定义或取消定义它们需要 owner 授权值5。在典型的 Linux 系统上该值为空,所以实际上任何能访问 TPM 设备的人(通常是 root)都持有 owner 授权。hash-algorithm 标志对应前面提到的 nameAlg,这里选 sha256。接着选择 NV index 的属性:authread|authwrite 配合空的授权值,允许任何能访问该设备的人读写这个 index。而 nt=extend 表示我们希望它可以像 PCR 一样被 extend。

tpm2_nvdefine 0x01000000 \
 --hierarchy=o \
 --hash-algorithm=sha256 \
 --attributes="nt=extend|authread|authwrite"

用下面的命令查看结果:

tpm2_nvreadpublic
0x1000000:
 name: 000be9606b61ec27bc8deec096dd38a6f8961cb8b3ef2fe879b27de704ab2f3d44e3
 hash algorithm:
 friendly: sha256
 value: 0xB
 attributes:
 friendly: authwrite|nt=0x1|authread
 value: 0x40044
 size: 32

可以看到我们配置的属性6、大小,以及那个由公开属性计算出的哈希名。由于你定义的属性与我完全一致,得到的索引名哈希也会完全相同。

现在可以像使用真正的 PCR 一样使用这个 NV 索引,用一次测量去扩展它,比如记录用户 Alice 的登录事件:

printf 'user-alice-logged-in' > m1.bin
tpm2_nvextend 0x01000000 --input=m1.bin

然后读取它的值:

tpm2_nvread 0x01000000 --size=32 | xxd -p -c 64
bd9927a653c6c33297b7d884a8ad99df0f5b5b1c1e1e86f762b3aced8bc77f50

此时再查看这个 NV 索引,会发现一件有意思的事:索引多了一个新属性 written,说明它已经被写入过一次或多次。由于属性集合变了,而索引名又是包含属性在内的哈希,索引名也随之改变。后面我们会用到这一点。

tpm2_nvreadpublic
0x1000000:
 name: 000b1191942a636c11a57571f0d435c290a1955788229305ce0aa8a2f393e9ed770c
 hash algorithm:
 friendly: sha256
 value: 0xB
 attributes:
 friendly: authwrite|nt=0x1|authread|written
 value: 0x20040044
 size: 32

你也可以再做一次测量,记录 Bob 的登录事件:

printf 'user-bob-logged-in' > m2.bin
tpm2_nvextend 0x01000000 --input=m2.bin
tpm2_nvread 0x01000000 --size=32 | xxd -p -c 64
e758d6e2e54620fd45b9cd1fd57cbba62757f12751d549fd2fc957ed1908ed29

此时 NV 索引的值是 HASH(HASH(0x0 || m1) || m2)7。第二次测量后,索引名没有再变。

这样我们就有了一个可以像 PCR 一样扩展的 NV 索引。那它能直接替代 PCR 吗?还不能。我们缺少真实 PCR 的一个根本属性:要把测量链当作任何事情的证明,它在系统运行期间就不能被重置或重放。否则,攻破系统的攻击者只要清空测量历史,再重放一段未被篡改的历史,远程证明就发现不了这次攻击。

遗憾的是,TPM 并不会为我们这个类 PCR 的 NV 索引提供这一属性:索引是在系统运行时定义的,我们也能再把它取消定义:

tpm2_nvundefine 0x01000000 --hierarchy=o
tpm2_nvreadpublic

结合本节做的两次测量来看,如果 Alice 心怀恶意并拿到了 root 权限,也就拿到了 owner 授权,她只需取消定义再重新定义这个索引,然后重放一段 Alice 从未登录过的历史。重新定义后的索引名完全相同,我们根本察觉不到。

我们需要的是一个任何人都能扩展、但在系统运行期间谁都无法重启的索引。策略(policy)就是 TPM 用来表达这类条件的工具。

探索策略#

TPM 接口提供的一个极其强大的概念是策略8。策略是一个不透明的摘要,与它所保护的对象一起存储在我们之前于 NV 索引的公共属性中看到的 authPolicy 属性里。要满足一个策略,我们需要向 TPM 请求一个策略会话。每个会话都有自己的上下文,其中包含一个称为 policyDigest 的摘要,以及一组可以通过执行策略断言来修改的约束。新会话的 policyDigest 全为零。在会话中,我们可以针对 TPM 运行不同的策略命令,每个命令都断言某个条件。有些断言在命令执行时立即被检查。其他断言则被推迟:命令在会话上下文中记录一个约束,TPM 只有在会话被用于授权时才检查它。无论哪种方式,每个命令都会使用以下逻辑来扩展会话的摘要:

policyDigest := H(policyDigest_old || commandCode || command-specific args)

扩展方案的工作方式与 PCR 完全一致,即便 policyDigest 背后并没有真正的 PCR 支撑。 调用方可以提交不同类型的证据,每一种都会扩展 policyDigest。 如果这些断言累加起来,使会话的 policyDigest 与索引的 authPolicy 相匹配, 该会话即获得授权,随后就能用它调用目标命令。 策略作者会在可信环境中通过一次试验会话,或离线预先计算, 得出预期的 authPolicy 摘要。试验会话执行同样的摘要计算, 但不校验任何条件,代价是无法用它授权任何操作。正因如此, 作者才能为机器当前并不处于的状态计算策略。

PolicyPCR#

妙处在于,策略可以通过锁定 PCR 的预期值,让权限取决于机器状态。 我们来创建这样一条策略!首先启动一个试验会话,定义我们想设置在 NV 索引上的策略。 做法是不带 --policy-session 标志调用 tpm2_startauthsession。

tpm2_startauthsession --session=trial.ctx

接着调用 tpm2_policypcr,针对我们通过 --pcr-list= 选定的当前观测 PCR 值创建断言。 在本次实验中,我们使用 PCR 15,它是操作系统拥有的 PCR 之一。应用 PCR 23 看起来像是天然的试验场, 但它和调试 PCR 16 一样可以在运行时被重置,而这恰恰是我们想要摆脱的性质:

tpm2_policypcr --session=trial.ctx \
 --pcr-list=sha256:15 \
 --policy=pcr15.policy
7e247a603cd1052cabc095741b8ee2f7458aabeee960b8ec97d7f090171a039a

生成的策略摘要会被打印出来,并写入 pcr15.policy9。 然后结束会话:

tpm2_flushcontext trial.ctx

我们重新创建之前的 NV 索引,这次用刚创建的策略保护它的写访问:

tpm2_nvdefine 0x01000000 \
 --hierarchy=o \
 --hash-algorithm=sha256 \
 --attributes="nt=extend|authread|policywrite" \
 --policy=pcr15.policy

注意我们同时加上了 --policy= 标志和 policyWrite 属性。 authRead 保持不动。还有一个 policyRead,但通常 限制谁可以扩展索引要有意思得多。

tpm2_nvreadpublic
0x1000000:
 name: 000b14a6915e5830ff2b83ebc87cdf5ac481fb29637c246489bdab1bba19948bb729
 hash algorithm:
 friendly: sha256
 value: 0xB
 attributes:
 friendly: policywrite|nt=0x1|authread
 value: 0x40048
 size: 32
 authorization policy: 7E247A603CD1052CABC095741B8EE2F7458AABEEE960B8EC97D7F090171A039A

此时像之前那样直接扩展会因授权错误而失败:

tpm2_nvextend 0x01000000 --input=m1.bin
ERROR: Esys_NV_Extend(0x12F) - tpm:error(2.0): authValue or authPolicy is not
available for selected entity

要写入索引,我们需要启动一个策略会话,并提交当前的 PCR 15 状态10 来满足策略。 之后就可以执行扩展,并把该会话作为授权提交。别忘了最后刷新会话上下文。

tpm2_startauthsession --session=s.ctx --policy-session
tpm2_policypcr --session=s.ctx --pcr-list=sha256:15
tpm2_nvextend 0x01000000 \
 --hierarchy=0x01000000 \
 --auth=session:s.ctx \
 --input=m1.bin
tpm2_flushcontext s.ctx

如果 PCR 15 发生变化,这个策略就无法再满足。运行以下命令来扩展该 PCR:

echo something | tpm2_pcrevent 15

现在重试之前那三步,它们曾基于该 PCR 解锁会话。 扩展会失败并返回 tpm:session(1):a policy check failed,因为 PCR 已经变化, 它的当前值(以及它未来的任何值!)都不再与策略中包含的那个值匹配。 对绑定到该 PCR 的索引的访问已经失效,并且在机器运行期间无法重新获得。

最后,再次取消定义该索引:

tpm2_nvundefine 0x01000000 --hierarchy=o

PolicyAuthorize#

用 PolicyPCR 锁定到某个 PCR 非常酷,但也很脆弱11:在上一节末尾,一次测量发生了变化,我们的访问随之失效,且无法重新获得。这是个问题,因为 PCR 值出于正当原因随时都在变化。想想引言中那个无密码磁盘解锁:磁盘密钥被封存到启动链的 PCR 状态上,而下一次内核更新恰好会改变这个状态。磁盘将无法再解锁,尽管并没有发生任何坏事,而我们当然不希望每次更新都重新加密磁盘。

更好的做法是让策略保持稳定,同时允许已批准的状态发生变化。幸运的是,还有另一种机制可以用来创建策略:PolicyAuthorize。它引入了一层间接和委托,让我们可以基于公钥而非系统状态来创建策略。要进行授权,你需要满足另一个具体的策略(比如 PolicyPCR),然后出示对预期策略摘要的签名以及一个 policyRef。具体策略的摘要本身并不属于该策略的一部分。谁拥有这个密钥,谁就可以离线批准新的具体策略。policyRef 限定了签名的适用范围,使签名的策略无法被用于其他上下文。

我们来创建一个 RSA 密钥对,以探索 PolicyAuthorize:

openssl genrsa -out sign.key.pem 2048
openssl rsa -in sign.key.pem -pubout -out sign.pub.pem

将公钥加载到 TPM 中,以便将其用作策略的一部分:

tpm2_loadexternal \
 --hierarchy=o \
 --key-algorithm=rsa \
 --public=sign.pub.pem \
 --key-context=signkey.ctx \
 --name=signkey.name
name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661

打印出的名称会将我们的策略绑定到这个密钥上。它的计算方式与我们之前看到的 NV 索引名称完全一样:对对象的公开属性求摘要,而对密钥来说,这些属性包括公钥本身。tpm2_readpublic 展示了这些公开属性:

tpm2_readpublic --object-context=signkey.ctx
name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661
qualified name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661
name-alg:
 value: sha256
 raw: 0xb
attributes:
 value: userwithauth|decrypt|sign
 raw: 0x60040
type:
 value: rsa
 raw: 0x1
exponent: 65537
bits: 2048
...
rsa: ...

除了名称算法、对象属性和关键参数之外,公共区域还包含原始的 RSA 模数(此处已缩短)。公钥的任何变化都会改变名称,进而改变由该名称创建的任何策略。

现在使用另一个试用会话和密钥名称,创建一个可以用此密钥授权的策略:

printf 'demo' > policyref.bin
tpm2_startauthsession --session=trial.ctx
tpm2_policyauthorize --session=trial.ctx \
 --name=signkey.name \
 --qualification=policyref.bin \
 --policy=authorized.policy
tpm2_flushcontext trial.ctx
tpm2_flushcontext --transient-object
6120c875c3afb0b2811fc7c3c045c32fb53596e8009e96855ed4aa6c332d0bfb

生成的策略摘要完全不包含 PCR 值,它只取决于密钥名称(以及通过密钥名称间接依赖的公钥)和 policyRef 标签。由于你生成的是不同的密钥对,得到的策略哈希也会不同。注意第二次调用 flush 时传入了 --transient-object:它会清除 tpm2_loadexternal 遗留在 TPM 有限的瞬态内存中的密钥对象。tpm2-tools 不会清除它加载的对象,所以在执行加载密钥的命令之后,我们会重复这个清理操作。 打印出的策略哈希被写入 authorized.policy,然后我们可以像之前那样用它再次定义 NV 索引:

tpm2_nvdefine 0x01000000 \
 --hierarchy=o \
 --hash-algorithm=sha256 \
 --attributes="nt=extend|authread|policywrite" \
 --policy=authorized.policy

目前还没有人能写入这个索引,因为尚不存在满足该策略的签名。

在实际操作中,这个流程通常涉及两方:密钥持有者,比如一个发行版维护团队;另一方是单台机器,它向自己的 TPM 出示证据。密钥持有者计算出一个要批准的具体策略摘要,例如针对某个特定构建的 PolicyPCR,然后对该摘要和 policyRef 创建签名。

为 PCR 15 的更新值创建新的 PCR 策略,这就是我们现在想要认可的。和上一节一样,我们运行一个试用会话来获取所观察到状态的策略摘要:

tpm2_startauthsession --session=trial.ctx
tpm2_policypcr --session=trial.ctx \
 --pcr-list=sha256:15 \
 --policy=approved.policy
tpm2_flushcontext trial.ctx

将策略和 policyRef 拼接起来:

cat approved.policy policyref.bin > tbs.bin

然后用私钥对整个内容签名:

openssl dgst -sha256 -sign sign.key.pem -out approved.sig tbs.bin

之后就可以把策略及其签名发送到目标机器,例如作为镜像或更新的一部分。这个产物表明密钥持有者批准了该具体策略。

在机器上,我们先让 TPM 验证签名:

tpm2_verifysignature --key-context=signkey.ctx \
 --hash-algorithm=sha256 \
 --message=tbs.bin \
 --scheme=rsassa \
 --signature=approved.sig \
 --ticket=verify.tkt

验证成功后,TPM 会返回一个 ticket,即一份带有 HMAC 标记的证明12。然后我们可以启动一个新的策略会话,满足该具体策略(即我们为其创建签名的那个策略),再出示 ticket、policyRef 和策略来认证该会话。

tpm2_startauthsession --session=s.ctx --policy-session
tpm2_policypcr --session=s.ctx --pcr-list=sha256:15
tpm2_policyauthorize --session=s.ctx \
 --input=approved.policy \
 --qualification=policyref.bin \
 --name=signkey.name \
 --ticket=verify.tkt

在 tpm2_policyauthorize 调用中,TPM 会确认你的会话摘要与已签名的 approved.policy 相等,检查 ticket 对给定的 policyRef 和密钥是否有效,然后把当前会话摘要切换为 authorize 策略(authorized.policy)。

此后,会话摘要与我们在定义索引时设置的 authorize 策略一致,会话就此解锁:

tpm2_nvextend 0x01000000 \
 --hierarchy=0x01000000 \
 --auth=session:s.ctx \
 --input=m1.bin
tpm2_flushcontext s.ctx
tpm2_flushcontext --transient-object

有了这种结构,即使未来的更新改变了 PCR 15 的度量值,索引也不会变砖,甚至根本不需要改动。可信的密钥持有者只需针对新的 PCR 15 状态签一份新策略,并把它作为更新的一部分下发。在机器上,使用同一策略的 NV 索引照常工作。systemd 正是靠这一点让基于 TPM 的磁盘解锁在内核更新之间持续可用:每个 UKI 都会带上与其自身预期 PCR 11 状态相匹配的新签名。但也要记住这个机制的另一面:如果密钥持有者只签过一种状态,那么只有当机器恰好处于该状态时,策略才能被满足。稍后我们会利用这一点。

PolicyOR#

一个策略摘要只对应唯一一条断言链。所有元素都以 AND 串联,我们必须按顺序匹配每一个元素,才能得到预期的会话哈希。有时我们想构造的是一条备选分支,比如我们已知两个可信状态、希望两者都允许,或者同一个操作有两种不同的授权方式。为此就有了 PolicyOR13。TPM 会检查当前会话的摘要是否属于允许列表,然后以类似处理 PolicyAuthorize ticket 的方式替换它。只要有一条分支被满足,策略就算满足。

构建安全的、基于策略的 NvPCR#

有了前面介绍的这些原语,我们现在可以看看 systemd 是如何构建安全 NvPCR 的。NV 索引由一条写策略保护,该策略包含两条用 PolicyOR 连接的分支:一条带公钥和 policyRef initrd 的 PolicyAuthorize 分支,以及一条 PolicyNvWritten(true) 分支。

先看 PolicyNvWritten(true) 分支。这条断言允许把 written 属性作为策略的一部分来检查14。因此,只要 NvPCR 此前已经被扩展过,PolicyNvWritten(true) 无需进一步授权即可满足。初始设置完成之后,运行时用的就是这条分支。此时扩展 NvPCR 不需要额外认证,任何能访问该设备的组件都可以做。

另一条分支 PolicyAuthorize 可以用签名策略满足。NvPCR 的首次扩展必须走这条分支。systemd 实际采用的策略是针对 PCR 11 的 PCR 策略,匹配早期启动时 initrd 中该 PCR 的预期状态。systemd 用 PCR 11 跟踪启动阶段:initrd 启动时,systemd-pcrphase-initrd.service 度量事件 enter-initrd。我们针对该事件之后 PCR 11 的预期状态构造 PCR 策略。如果系统在此之前未被篡改,且签名有效,签名 PCR 策略即得到满足,NvPCR 可以完成首次扩展。进入下一个启动阶段时,同一个服务度量阶段事件 leave-initrd。这会在此后整个系统生命周期内锁死 PolicyAuthorize 分支。此后 NvPCR 只能通过 PolicyNvWritten 分支扩展。

你可能会问,写策略为什么要绕道 PolicyAuthorize,而不直接内嵌 PCR 策略。在真实系统上,PCR 11 里不只有阶段事件:UKI stub 会先把内核和 initrd 度量进去,因此每次更新都会改变 PCR 11 在 initrd 中的状态。若直接内嵌,每次更新都会改变写策略,进而改变索引名称,NvPCR 就得在每次更新时重建。用 PolicyAuthorize 则写策略和名称保持稳定,只有随 UKI 一起发布的签名会变。

下面构造最终的 NvPCR 版本,做法与 systemd 类似。先度量标记 initrd 启动阶段开始的事件,由 systemd-pcrphase-initrd.service 完成:

echo -n "enter-initrd" | tpm2_pcrevent 11
tpm2_pcrread sha256:11
 sha256:
 11: 0xD15B0E8E244E65C40F024E95773F2347CE4EF3FFE6B597C9A14B50BBAB6DF319

接着编写写策略。上一节的密钥继续复用,它扮演 UKI 的 PCR 签名密钥,其公钥部分随 UKI 的 .pcrpkey 段一起发布。先写入 policyRef,值为 initrd15。然后用 trial session 创建策略中基于密钥的分支,initrd 内的首次写入必须走这条分支:

printf 'initrd' > initrd.ref
tpm2_startauthsession --session=trial.ctx
tpm2_policyauthorize --session=trial.ctx \
 --name=signkey.name \
 --qualification=initrd.ref \
 --policy=init.branch
tpm2_flushcontext trial.ctx

接下来创建策略的第二条分支,即 PolicyNvWritten(true):

tpm2_startauthsession --session=trial.ctx
tpm2_policynvwritten --session=trial.ctx --policy=written.branch s
tpm2_flushcontext trial.ctx

最后,用 PolicyOR 将这两条策略组合起来:

tpm2_startauthsession --session=trial.ctx
tpm2_policyor --session=trial.ctx \
 --policy-list=sha256:init.branch,written.branch \
 --policy=write.policy
tpm2_flushcontext trial.ctx

用这条策略定义 NvPCR,做法和之前几乎一样:

tpm2_nvdefine 0x01D10200 \
 --hierarchy=o \
 --hash-algorithm=sha256 \
 --attributes="nt=extend|policywrite|ownerread|authread|clear_stclear" \
 --policy=write.policy
tpm2_nvreadpublic 0x01D10200
0x1d10200:
 name: 000bf2e615c91fc1738ee23d6906f3cc5da932ed3d3dd19f99de0d0e0839712d8ecf
 ...
 attributes:
 friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear
 value: 0x8060048
 size: 32
 authorization policy: A765636C5A04447BD790486F98DAF82CA694868DF19C1AF80632A52261E374EF

写入策略取决于你生成的密钥,因此你的授权策略和索引名也会与我的不同。这里唯一的新东西是 clear_stclear 属性:它告诉 TPM 在重启时清除该 NV 索引16。与 PCR 一样,NvPCR 应在重启时重置,而不是把上一次启动的度量保留到下一次。written 属性同样会在重置时被清除。

0x01D10200 是 systemd 使用的 NV 索引区间中真正的句柄。系统上存在哪些 NvPCR 由 /usr/lib/nvpcr/*.nvpcr 中的小型 JSON 文件定义,从 v262 起这些文件必须作为 UKI 的一部分随镜像发布。systemd 目前自带四个定义:hardware 对应产品 UUID,cryptsetup 对应所使用的 LUKS 解锁机制,verity 对应已激活 verity 卷的根哈希,login 对应用户登录17。这四个记录的事件要么不可预测,要么数量没有上限,正是我们不希望放进真实 PCR 的那类。

接下来,针对 PCR 11 预期的 initrd 状态构造具体的 PCR 策略,并连同 policyRef 一起签名:

tpm2_startauthsession --session=trial.ctx
tpm2_policypcr --session=trial.ctx --pcr-list=sha256:11 --policy=initrd.policy
tpm2_flushcontext trial.ctx
cat initrd.policy initrd.ref > tbs.bin
openssl dgst -sha256 -sign sign.key.pem -out initrd.sig tbs.bin

在 systemd 中,这一步在镜像构建时由 ukify 完成。该工具新增了 --sign-initrd-pcrs 标志,用于预先计算 enter-initrd 阶段的预期 PCR 11 值,并将签名后的策略以 .pcrsig 段的形式嵌入 UKI。

每次启动时,systemd 的 systemd-tpm2-setup-early.service 会用该签名和授权策略路径在 initrd 中初始化 NvPCR。首先让 TPM 验证签名:

tpm2_verifysignature --key-context=signkey.ctx \
 --hash-algorithm=sha256 --message=tbs.bin --scheme=rsassa \
 --signature=initrd.sig --ticket=initrd.tkt

然后开启一个策略会话。用 PCR 11 的当前状态满足 PCR 策略,再调用 tpm2_policyauthorize 提交针对 initrd.policy 的签名。最后调用 tpm2_policyor 提交两个分支,并匹配我们 NvPCR 的预期 auth digest:

tpm2_startauthsession --session=s.ctx --policy-session
tpm2_policypcr --session=s.ctx --pcr-list=sha256:11
tpm2_policyauthorize --session=s.ctx --input=initrd.policy \
 --qualification=initrd.ref --name=signkey.name --ticket=initrd.tkt
tpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch

有了这个会话状态,策略即被满足,我们随即扩展 NvPCR。systemd 用全零初始化 NvPCR。写入的值无关紧要,关键在于该索引获得了 written 属性,并且这第一次扩展是在已签名的策略下发生的。

head -c 32 /dev/zero > zero.bin
tpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=zero.bin
tpm2_flushcontext s.ctx
tpm2_flushcontext --transient-object
tpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64
f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b

扩展值为全零,因此每个新初始化的 SHA-256 NvPCR 在每台机器上都从这个确切值开始,SHA256(zeros32 || zeros32)。完成第一次写入后,该索引获得了 written 属性,也因此有了一个新名字:

tpm2_nvreadpublic 0x01D10200
0x1d10200:
 name: 000bf08c214c037f85d3520549dba23471a88af8aab1913bf269edfbb44b6d2de9f5
 ...
 attributes:
 friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear|written
 value: 0x28060048

记住这个名字,下一节还会用到。

在 initrd 启动阶段结束时,systemd 会测量标记该阶段结束的事件:

echo -n "leave-initrd" | tpm2_pcrevent 11

这样一来,在下一次重启之前,已签名的策略再也无法被满足!

之后在运行时,写入仅凭 NvPCR 在 initrd 中初始化时获得的 written 属性即可获得授权。systemd-pcrextend 在记录事件时做的就是这件事,它不需要密钥,也不需要任何秘密,任何能访问 TPM 的组件都可以追加。这是有意为之:对真实 PCR 的扩展同样未经认证,因为添加历史是无害的,只有重写历史才必须不可能。

tpm2_startauthsession --session=s.ctx --policy-session
tpm2_policynvwritten --session=s.ctx s
tpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch
tpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=m1.bin
tpm2_flushcontext s.ctx
tpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64
5bbe1770fd46c5edfde5ef444b2d681d87925fcd71b1fe98c4f65749a6f3afb6

注意,我们仍然需要向会话呈现两个分支,包括 init.branch 摘要。因此 systemd 在初始化后将其持久化到 /run/systemd/nvpcr/.auth。

与普通 PCR 一样,systemd 会将每次 NvPCR 测量记录到其用户空间测量日志(/run/log/systemd/tpm2-measure.log)中,这样验证者之后就能重放事件并解读 NvPCR 的值。

动手部分到此结束。实验完成后,关闭软件 TPM:

kill "$(cat swtpm.pid)"

NvPCR 设计的安全考量#

来看看这个构造为我们提供了怎样的安全性。我们考察的攻击者在系统运行时、leave-initrd 事件被测量之后获得访问权限,并希望在不被察觉的情况下重写 NvPCR 历史,就像开头那个朴素索引攻击中 Alice 隐藏自己的登录一样。他们拥有 root 权限,可以访问 TPM 来读取、写入、取消定义和重新定义 NV 索引,扩展(但不能重置)PCR。他们还可以重启或启动另一个操作系统。针对 TPM 硬件本身的攻击不在讨论范围内。

TPM 本身是受信任的,测量启动链直到并包括 initrd 也是受信任的,其中包括固件、bootloader 和 UKI。按照设计,initrd 有权初始化 NvPCR,这一点由针对 PCR 11 的 initrd 状态的签名策略来表达。但 PCR 11 处于操作系统控制之下,另一个内核也可以扩展出预期值并达到被授权的状态。只有固件控制的 PCR 才能将 PCR 11 的 initrd 状态与我们信任的 initrd 绑定起来,因为它们记录的是实际运行的 bootloader 和 UKI。启动任何其他东西都会被验证者看到。我们还必须信任密钥持有者,因为谁控制了 PCR 签名密钥,谁就能为任意状态背书;也必须信任验证者,因为它要检查的不只是 NvPCR 的值,我们下面就会看到。

写策略和公钥都不是秘密,所以攻击者可以取消定义我们的 NvPCR,再按字节完全一致地重新定义它。要重放一段历史,攻击者接下来必须对全新的索引执行第一次写入,而写策略的两个分支都会拒绝:PolicyNvWritten(true) 分支无法授权这次写入。没有什么能阻止攻击者运行策略命令,会话摘要甚至会和写策略匹配,但延迟的写入检查面对从未被写入的索引会失败,TPM 拒绝这次 extend。PolicyAuthorize 分支同样失败:现存唯一的签名批准的是 PCR 11 的 initrd 状态,也就是机器在测量 leave-initrd 时留下的那个状态。会话再也无法得到那个被签名的摘要,于是 TPM 拒绝,攻击者无法伪造出一段历史。

还有一个签名攻击者可能尝试:同一把密钥也签署了 UKI 的其他 PCR 策略,比如用于磁盘解锁的那些,其中一些在机器的运行时状态下是可以满足的。这正是 policyRef 的用武之地:我们的授权分支只接受为 ref initrd 生成的签名,而其他策略是用不同的 ref(或者根本没有 ref)签署的。签名之间不能互换。

当然,攻击者不必复用我们的写策略。他们可以用 authwrite 重新定义索引,就像我们一开始那个朴素索引一样,然后随意重放任何历史。但别忘了,NV 索引的名字是对它所有公开属性的哈希:属性、写策略,而写策略又提交了厂商的公钥。想让索引可以在 initrd 之外被写入,就不可能不得到一个不同的名字。

于是名字成了整个设计的锚点,最后缺的一块是让它可验证。初始化一个 NvPCR 之后,systemd 会把事件 nvpcr-init::0x: extend 进 PCR 9,一个真实的、不可重置的 PCR。它是最廉价的可牺牲对象:内核会把交给它的每一个 initrd 都测量进去,而在 UKI 启动时,那是 UKI 内嵌 initrd 与 systemd-stub 即时生成的 cpio 归档(比如用于 credentials)的拼接,全部揉成一个值,在某些配置下还会加上冗长的 bootloader 记录。这让 PCR 9 难以预测,所以任何解锁策略本来也无法绑定它,而真正重要的测量——UKI initrd——已经由 PCR 11 干净地覆盖了。所有 NvPCR 初始化完成后,systemd-pcrnvdone.service 会把一个分隔事件 nvpcr-separator 测量进 PCR 9,此时仍在 initrd 内。NvPCR 的值本身通过 TPM2_NV_Certify 进行证明,由 TPM 对当前索引内容连同索引名一起签名。新的 systemd-report-sign-tpm2 signer 会在常规 PCR quote 之外一并输出这些证明。做远程证明的验证方只能信任那些被证明的索引名与 PCR 9 事件日志中出现在分隔符之前的 nvpcr-init 事件相匹配的 NvPCR 值。任何重建出来的索引都过不了这一关:名字不对,或者名字对但记录在分隔符之后。

攻击者还剩两种手段:重启,以及引导另一个操作系统。重启会像真实 PCR 一样重置 NvPCR,下一次引导会在 initrd 中重新初始化它们。攻击者什么也得不到:新的引导是真实的,其历史按设计从头开始,验证方也能看出发生过一次重启。引导另一个操作系统同样没用,因为它会在 PCR 11 以及其他引导链 PCR 中产生不同的度量值。签名策略无法被满足,而由这种引导产生的任何 quote 都会暴露被篡改的引导链。

结论#

systemd v262 中的 NvPCR 是一个 NV extend 索引,它的第一次写入受签名 PCR 策略约束,而该策略只能在 initrd 中满足,之后的写入则不受限制。索引名称在早期引导期间被度量进 PCR 9,为验证方锚定了这一构造:任何用更宽松写入策略重建的索引都会带有不同的名称,从而被检测到。我们在命令行上重建了这样一个 NvPCR,并讨论了攻击者为何无法伪造度量值:一旦 initrd 窗口关闭,就没有任何途径能进入新索引的写入策略,而攻击者仍能做的一切都是破坏性的,并且会在证明中暴露出来。

最后给一条实用提示:写入策略基于 UKI 的 PCR 签名密钥。当该密钥轮换时,systemd-tpm2-setup 会检测到名称不匹配,检查现有索引是否看起来像 NvPCR,然后用新策略重建它。同一条路径会自动升级由 v262 之前的设计创建的 NvPCR,因此随着 systemd 更新,你的系统上的迁移会自动完成。

这种绑定 initrd 的签名策略的用途也不限于 NvPCR。借助 systemd-cryptenroll --tpm2-public-key-policyref=initrd,你可以注册只能在 initrd 中解锁的 LUKS keyslot。如果你想在真实系统上看到这一切,请引导一个 v262 镜像,其中的 UKI 用 --sign-initrd-pcrs 构建,然后查看 systemd-analyze nvpcrs 和 PCR 9 事件日志。


致谢#

感谢我在 Amutable 的同事 Chris Coulson,他设计了新的 NvPCR 方案,并对这篇博文提出了富有洞见的修正和补充。在我们都依赖的上游项目中从事 Linux 安全工作,正是 Amutable 使命的核心:构建新的安全基础。

参考资料#

  • systemd 上关于为 TPM2 添加基于 nvindex 的附加 PCR(即“NvPCR”)支持的 PR
  • systemd 上关于改进 NvPCR 保护机制的 PR
  • systemd 执行的 TPM2 PCR 测量文档
  • TPM 2.0 库(“最新版本”一节)
  • tpm2-tools 手册页

  1. 动态测量信任根(Dynamic Root of Trust for Measurements)是一种在运行时建立新的、可验证信任链的机制,通常由平台通过 Intel TXT 或 AMD SVM 之类的技术提供支持。 ↩︎
  2. UAPI.7 规范记录了固件和操作系统 PCR 的常见用法。 ↩︎
  3. 参见 TCG TPM 2.0 库规范,版本 185,第 1 部分:架构,第“34.2 NV Indices”节 ↩︎
  4. TCG TPM 2.0 库规范,版本 185,第 1 部分:架构,第“13 Names”节列出了所有实体类型的名称计算公式(表 9),并明确描述了当设置 written 属性时 NV 索引的名称如何变化。 ↩︎
  5. 层级及其授权在 TCG TPM 2.0 库规范,版本 185,第 1 部分:架构,第“10 TPM Control Domains”节中介绍,如果你想深入了解可以去看,但对我们这里要做的事情并不太重要。 ↩︎
  6. 注意输出中的 nt=0x1 是 tpm2-tools 的一个 bug。根据规范,TPM_NT_EXTEND 应为 0x4,原始值中确实正确包含了它。我已经向上游提交了一个 PR 来修复这个问题。 ↩︎
  7. 注意 tpm2_nvextend 使用的 TPM2_NV_Extend 调用直接将提供的任意字节传入哈希函数,没有预先哈希:NV := H_nameAlg(NV_old ‖ input)。而 TPM2_PCR_Extend 则要求传入固定大小的摘要(PCR := H(PCR ‖ digest_in)),TPM2_PCR_Event 会对传入的事件本身进行哈希(PCR := H(PCR ‖ H(event)))。 ↩︎
  8. 策略在 TCG TPM 2.0 库规范,版本 185,第 1 部分:架构,第“16.7 Enhanced Authorization”节中规定,这是一份出人意料地易读的入门材料。Trial 会话在第“16.7.10 Trial Policy”节中介绍。 ↩︎
  9. 用规范中的一些常量复现该哈希:H(zeros32 || 0000017f || 00000001000b03008000 || H(pcr15_value))在终端上:
pcrdigest=$(head -c 32 /dev/zero | sha256sum | cut -d' ' -f1)
printf '%064d0000017f00000001000b03008000%s' 0 "$pcrdigest" \
 | xxd -r -p | sha256sum

↩︎ 10. PolicyPCR 可以同时是立即和延迟的,取决于其参数:调用方可以传入他们期望的 PCR 摘要,TPM 随即对选定的 PCR 进行检查。如果不传摘要,就像这里的 tpm2_policypcr 那样,TPM 只是将当前 PCR 值折叠进 policyDigest,它们是否正确只有在最终与 authPolicy 比较时才会显现。在两种变体中,TPM 都会额外将当前 PCR 更新计数器作为延迟约束记录在会话中:如果 PCR 在断言之后被更新,该会话就无法再授权任何操作。 ↩︎ 11. 规范在第“16.7.11 Modification of Policies”节(TCG TPM 2.0 库规范,版本 185,第 1 部分:架构)中讨论了“脆弱性”问题,并引入了与我们即将构建的类似的结构。 ↩︎ 12. Ticket 是以 TPM 内部证明值为密钥的 HMAC,使 TPM 之后无需再次加载非对称密钥即可重新验证签名,参见 TCG TPM 2.0 库规范,版本 185,第 1 部分:架构,“8.4.6.3 Tickets” ↩︎ 13. 摘要替换在 TCG TPM 2.0 库规范,版本 185,第 1 部分:架构,第“16.7.4 Policy OR”节中有说明。 ↩︎ 14. 策略命令运行期间,会话尚未绑定到任何 NV 索引,因此无法立即检查所写入的属性。TPM2_PolicyNvWritten 像其他断言一样扩展 policyDigest,但额外将声称的写入状态作为延迟检查记录在会话中。只有当会话被用于授权命令时,TPM 才会将其与被访问索引的实际属性进行比较,不匹配则拒绝该命令。 ↩︎ 15. systemd 的实际实现并未使用原始字符串作为 policyRef,而是使用 SHA256("initrd"),因为 policy ref 的大小有限。语义是相同的。 ↩︎ 16. 更准确地说,clear_stclear 意味着该索引在任何 TPM2_Startup(CLEAR) 时被清除,平台会在 TPM 重置或 TPM 重启时发出该命令。除了重启之外,这还

从休眠恢复也涵盖在内(不过启用了安全启动的 Linux 系统实际上并不会休眠)。而从挂起恢复对应的则是 TPM2_Startup(STATE),即 TPM resume,它会保留索引。 ↩︎ 17. 如果你在真实系统上查看这些索引,会发现除了 hardware 之外,其余都会多出一个属性:orderly。有序 NV 索引由 TPM RAM 支撑,仅在正常关机时才刷写到 NVRAM,从而避免像 login 这类频繁写入的索引对 NVRAM 造成磨损。不过 TPM RAM 比 NVRAM 更为稀缺,因此每次启动只写一次的 hardware NvPCR 并未占用它。又因为 orderly 是一个属性,它也是索引名的一部分。 ↩︎

来源: katexochen← 返回首页