Linux Lockdown 与休眠功能(又见)

Lobsters Hottest 新闻

摘要

Matthew Garrett 解释了 Linux Lockdown LSM 与休眠功能之间的冲突,以及 UEFI Secure Boot 如何催生了内核与 root 权限之间的强边界。

<p><a href="https://lobste.rs/s/rrazyb/linux_lockdown_hibernation_again">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/14 10:08

# Linux 锁定与休眠(再谈) 来源:https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/ (这是一篇旧文(https://mjg59.dreamwidth.org/55845.html)的更新版本——引言部分保持不变,但技术细节有所不同) Linux 区分运行在内核中的代码(内核空间)和运行在用户态的应用程序(用户空间)。这一区分在硬件层面强制执行——用 x86 的术语来说 [^1](https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/#fn:1),内核空间代码运行在 ring 0,用户空间代码运行在 ring 3 [^2](https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/#fn:2)。如果你运行在 ring 3 中并试图访问只能在 ring 0 中访问的内存,硬件会触发一个错误。无论你的 ring 3 代码有多高的权限,都无法触碰 ring 0。 理论上是这样的。但在传统上,这一区分并没有得到良好的强制执行。在最基本的层面上,由于 root 用户可以加载内核模块,你完全可以编译一个执行任意内核修改的内核模块,然后让 root 加载它。从技术上讲,用户空间代码并没有修改内核空间代码,但这种区别更多是语义上的,而非实质性的。但情况还更糟——root 还可以映射属于 PCI 设备的内存区域 [^3](https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/#fn:3),如果该设备能执行 DMA 操作,你就可以直接让设备覆盖内核的某些部分 [^4](https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/#fn:4)。或者 root 可以通过 /dev/msr 接口修改特殊的 CPU 寄存器("型号特定寄存器",即 MSR)来改变 CPU 行为,从而破坏内核边界。 事实证明,root 实际上等同于 ring 0 有很多种方式,而这一边界更多是出于可靠性考虑(即一个以 root 身份运行的进程如果出现异常行为,应该只能导致自身崩溃,而不是连带内核一起崩溃),而非安全性考虑。毕竟,如果你是 root,你可以直接把磁盘上的内核替换成一个植入后门的内核然后重启。再进一步,你甚至可以替换引导加载程序,让它自动将后门注入合法的内核镜像。我们没有任何办法来阻止这类事情,因此加固 root 与内核之间的边界并没有太大的意义。 2012 年,Microsoft 开始要求厂商出货的系统搭载 UEFI Secure Boot——这是一项固件功能,允许系统拒绝启动任何没有适当签名的引导内容 [^5](https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/#fn:5)。这不仅使得创建在 root 和内核之间划出严格边界成为可能,甚至可以说需要这样的边界——如果 root 之后可以随意向 ring 0 注入更多代码,那限制固件能往 ring 0 中放什么又有什么意义呢?最终成为 Linux Lockdown 安全模块的这个东西提供了实现这一目标的工具,它阻止可以被用来修改内核的用户空间接口,并强制要求所有模块都必须具有可信签名。 但这也有一定的代价。Lockdown 阻止的大多数功能都相当小众,所以启用它带来的直接影响很小。但它同时也阻止了休眠功能 [^6](https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/#fn:6),而事实证明有些人确实在使用它。显而易见的问题是"休眠和阻止 root 进入内核空间有什么关系",而答案有些复杂,并且与 Linux 实现休眠的方式有关。基本上,Linux 会将系统状态保存到交换分区中,并修改头部以表明那里存放的是一个休眠镜像而非交换空间。在下次启动时,内核看到头部表明这是一个休眠镜像,就将交换分区的内容复制回 RAM 中,然后跳转回旧的内核代码。那么是什么确保休眠镜像确实是由内核写入的呢?什么都没有。这意味着一个有 root 权限且有决心的攻击者可以关闭交换功能,自己将一个休眠镜像写入交换分区,然后重启。内核会欣然地从攻击者的镜像中恢复,从而让攻击者控制被复制回内核空间的内容。 这很烦人,因为通常当我们想到对交换空间的攻击时,我们通过要求加密的交换分区来缓解。但在这个场景中,攻击者就是 root,所以他们已经可以访问交换分区的明文版本了。磁盘加密在这里帮不了我们。我们需要某种方式来验证休眠镜像确实是由内核写入的,而不是由 root 写入的。幸运的是,我们确实有一些工具可以做到这一点。 可信平台模块(TPM)是密码学协处理器 [^7](https://codon.org.uk/~mjg59/blog/p/linux-lockdown-and-hibernation-again/#fn:7),能够执行诸如生成加密密钥然后用它们加密内容等操作。你可以要求 TPM 用一个与该特定 TPM 绑定的密钥来加密某样东西——操作系统无法访问解密密钥,其他 TPM 也无法访问。因此,我们可以让内核生成一个加密密钥,用它加密休眠镜像的一部分,然后让 TPM 对该密钥进行加密。我们将密钥的加密副本也存储在休眠镜像中。在恢复时,内核读取密钥的加密副本,将其传递给 TPM,获取解密副本,然后就能验证休眠镜像了。 这很好!但问题是 root 也能做完全相同的事情。这只能说明休眠镜像是在这台机器上生成的,但不能说明是由内核生成的。我们需要某种方式来区分在内核中生成的密钥和在用户空间中生成的密钥。TPM 有"本地性"(locality)的概念(实际上就是特权等级),这非常适合用于此目的。用户空间只能访问 locality 0,所以内核可以使用 locality 1 来加密密钥。不幸的是,locality 通常只在实现了[动态可信度量根(DRTM)](https://trustedcomputinggroup.org/resource/d-rtm-architecture-specification/)的系统上可用(例如 Intel 的 TXT),而大多数消费级系统都不支持这一功能。所以,我们需要另一种方法。 TPM 有一组平台配置寄存器(PCR),用于记录系统状态。操作系统无法直接修改 PCR。相反,操作系统向 TPM 提供某个数据的密码学哈希。TPM 获取现有的 PCR 值,将新哈希追加到其后,然后将组合后的哈希值存储在 PCR 中——这个过程称为"扩展"。这意味着 TPM 中的新值不仅取决于新数据的值,还取决于 PCR 的先前值——而那个先前值又取决于它自己的先前值,依此类推。要得到一个特定的 PCR 值,唯一的办法是(a)破解哈希算法,或(b)执行完全相同的写入序列。在系统重置时,PCR 会恢复到已知值,整个过程重新开始。 有些 PCR 是不同的。例如,PCR 23 可以在不重置系统的情况下恢复到其原始值。我们可以利用这一点。我们需要做的第一件事是阻止用户空间重置或扩展 PCR 23 本身。所有 TPM 访问都通过内核进行,所以这很简单——在写入发送给 TPM 之前解析它,如果这是一个涉及 PCR 23 的敏感命令,就返回错误。这样我们就知道 PCR 23 的任何状态变化都将仅限于内核。 当我们使用 TPM 加密数据时,可以要求它记录 PCR 状态。这会作为元数据随加密密钥一起返回给我们。除了元数据外,还有一个由 TPM 创建的附加签名,可以用来证明该元数据是合法的,并且与这个特定的加密数据相关联。在我们的情况下,这意味着我们知道加密密钥时 PCR 23 的值是多少。这意味着,如果我们在内核中加密密钥之前用一个已知值扩展 PCR 23,然后查看元数据中 PCR 23 的值——如果匹配,就说明密钥是由内核加密的。用户空间可以创建自己的密钥,但它无法先将 PCR 23 扩展到相应的值。这样我们就知道密钥是由内核生成的。 不幸的是,事实证明有些人确实在使用 PCR 23,限制只有内核才能访问它会让他们不高兴。还有其他可重置的 PCR,但只存在于非 0 的 locality 中,所以(a)我们无法在大多数系统上重置它们,(b)我们只会找到某个使用这些 PCR 的人然后遇到同样的问题。我们需要一种不同的方法。 幸运的是,TPM 有一小块非易失性 RAM 可用于存储数据,而"存储"的方式可以有很多种。其中一种实际上是作为额外的 PCR 使用。这些值可以配置成与 PCR 以相同的方式工作,唯一的修改方式是在其现有值上追加一个新哈希,然后将合并值的哈希更新为新值。我们可以分配其中一个用于内核使用,并拒绝来自用户空间的任何访问。此外,还有官方分配的范围(所以人们不应该使用随机值),而且命名空间足够大,我们不应该与任何人冲突,因此我们可以从中划出一个用于此目的。 不过遗憾的是,这些 NV 索引不包含在创建密钥时获得的创建数据中,所以我们需要另一种方法。TPM 有会话(session)的概念——一系列按顺序执行的命令。有一个标志允许这些会话被"审计",TPM 会持续追踪已执行命令和返回结果的哈希。在会话结束时,你可以请求这个哈希的签名副本,如果你保留了所有命令的日志,就可以验证它们是否与该签名哈希匹配,从而知道这个日志确实代表了一次与 TPM 的真实通信。如果我们读取 NV 索引并在同一个会话内生成密钥,就可以验证我们正在查看的密钥是在 NV 索引具有特定值时创建的——如果只有内核能设置该值,那么我们就知道密钥是由内核生成的。 或者,嗯,真的可以吗?只有新内核才会对修改该 NV 索引施加限制。攻击者可以启动一个旧内核,在其中的用户空间中生成密钥,用它签名一个休眠镜像,然后重启进入新内核。新内核会看到一个带有有效 NV 索引的有效签名,而无从得知它是由用户空间而非内核生成的。我们需要能够区分实现了此功能的内核和未实现的内核,而且在无法回到过去改变旧行为的前提下完成。我们可以通过扩展一个 PCR 来表明我们实现了新功能,但运行旧内核的攻击者同样可以这样做。 确定某个事件是否发生在引导过程特定阶段的通常方法是对 PCR 进行"封盖"(cap)——用一个已知值扩展它,表示引导过程的阶段转换。在封盖事件之前发生的任何事件必然发生在引导的前一个阶段。由于最终的 PCR 值取决于度量的顺序而不仅仅是其内容,如果在用户空间运行之前对 PCR 进行了封盖,用户空间之后就无法伪造相同的 PCR 值。如果 Linux 在用户空间开始运行之前对 PCR 进行封盖,我们就能在封盖发生之前放置一个度量,并证明该扩展发生在用户空间有机会干预之前。我们可以在那里放置一个内核支持 NV 索引限制的声明,这样就可以了。 遗憾的是,Linux 目前并不执行此操作,而添加对此的支持也不能解决问题——如果攻击者启动一个不封盖 PCR 的内核,他们可以自己从用户空间进行封盖。所以,我们面临同样的问题:启动旧内核可以使系统处于与当前内核完全相同的状态,从而写出伪造的休眠镜像。要解决这个问题,需要一个在内核代码运行之后、用户空间启动之前被修改的 PCR——即使在现有内核上也是如此。 好消息是,确实有一个!PCR 5 被[定义](https://trustedcomputinggroup.org/wp-content/uploads/TCG_PCClient_PFP_r1p05_v23_pub.pdf)为包含与引导管理配置和数据相关的度量。其中一个度量是 UEFI `ExitBootServices()` 调用的结果。`ExitBootServices()` 在从 UEFI 引导环境切换到运行中的操作系统时被调用,而内核包含在此之前的执行代码。因此,如果我们在这之前——在调用 `ExitBootServices()` 之前——将一个关于我们是否支持 NV 索引受限访问的断言度量到 PCR 5 中,就能防止用户空间伪造我们(因为用户空间只能在固件因 `ExitBootServices()` 被调用而扩展 PCR 5 之后扩展 PCR 5)。当然,这取决于固件在 `ExitBootServices()` 被调用时确实执行了 PCR 5 的扩展,但如果固件不符合规范,我想也就别指望这一切能给你带来任何安全性了。 这就是[这个补丁系列](https://lore.kernel.org/linux-pm/[email protected]/)所做的。目前的评审反馈似乎是积极的,尽管 James Bottomley [建议](https://lore.kernel.org/linux-pm/aeedde06c51910afe5231d9f359bb8767c01b5bd.camel@HansenPartnership.com/)了一个设计上的改进。我使用 NV 索引作为 PCR 并生成签名密钥的方法,是我最初涉及 PCR 23 的方案的延续——那个方案是利用密钥创建数据而完全不使用审计会话。但目标并不是使用一个密钥——而是要证明休眠发生在内核中,而签名只是实现这一点的方法之一,还有其他方法。他的建议是简单地将休眠镜像的哈希记录到 NV 索引中,然后在审计会话中将其读回。在恢复时,我们验证审计会话日志是合法的,读取休眠镜像,并将休眠镜像的哈希与日志中的哈希进行比较。如果匹配,且 PCR 5 的值匹配,我们就知道审计会话发生在内核中,且休眠镜像未被修改。 但我们似乎仍然遗漏了一些东西。如果我们不存储任何密钥,那我们如何验证审计会话呢?为此,我们需要深入了解 TPM 密钥的工作原理。TPM 可以生成一种特殊的密钥,称为证明密钥(Attestation Key,AK)。证明密钥用于签署来自 TPM 的证明声明,例如 PCR 引用或审计日志。我们需要一种单独的密钥类型来证明该签名是作为证明过程的一部分生成的——普通的 TPM 签名密钥本质上设计用于签署任意数据,因此用户空间可以用它来签署一个与证明记录看起来完全一样的东西。通过能够区分 TPM 使用的密钥类型,我们可以证明某个签名是使用 AK 生成的,从而知道这是一个真实的 TPM 证明(因为除了 TPM 之外,没有任何东西能用 AK 进行签名)。 但即使在这个区别之外,TPM 也有不同的密钥类型。普通密钥使用随机数生成器(RNG)生成,是唯一的。主密钥(Primary Key)使用一个种子生成,该种子被用作密钥派生

相似文章

当 Ldaxr 失灵时:AArch64 上的独占访问与缓存能力

Hacker News Top

作者在为 AArch64 从零编写自制操作系统 floss 的过程中,深入分析了 ldaxr/stxr 独占内存访问指令在 QEMU 上运行正常、却在真实硬件上触发异常的问题,探讨了自旋锁、异常处理、虚拟内存、内存类型与缓存属性之间的复杂交互。

Linux 与 Secure Boot 证书到期

Lobsters Hottest

本文讨论了即将到期的 Microsoft Secure Boot 证书(Linux 发行版依赖它通过 shim 进行引导),以及更新系统固件以适配替换密钥所涉及的复杂性。