|
| 1 | +# LUKS2 Header Malleability and Null-Cipher Abuse in Confidential VMs |
| 2 | + |
| 3 | +{{#include ../../banners/hacktricks-training.md}} |
| 4 | + |
| 5 | +## TL;DR |
| 6 | + |
| 7 | +- Many Linux-based Confidential VMs (CVMs) running on AMD SEV-SNP or Intel TDX use LUKS2 for persistent storage. The on-disk LUKS2 header is malleable and not integrity-protected against storage-adjacent attackers. |
| 8 | +- If the header’s data segment encryption is set to a null cipher (e.g., "cipher_null-ecb"), cryptsetup accepts it and the guest transparently reads/writes plaintext while believing the disk is encrypted. |
| 9 | +- Prior to and including cryptsetup 2.8.0, null ciphers could be used for keyslots; since 2.8.1 they are rejected for keyslots with non-empty passwords, but null ciphers remain allowed for volume segments. |
| 10 | +- Remote attestation usually measures VM code/config, not mutable external LUKS headers; without explicit validation/measurement, an attacker with disk write access can force plaintext I/O. |
| 11 | + |
| 12 | +## Background: LUKS2 on-disk format (what matters for attackers) |
| 13 | + |
| 14 | +- A LUKS2 device starts with a header followed by encrypted data. |
| 15 | +- The header contains two identical copies of a binary section and a JSON metadata section, plus one or more keyslots. |
| 16 | +- JSON metadata defines: |
| 17 | + - keyslots enabled and their wrapping KDF/cipher |
| 18 | + - segments that describe the data area (cipher/mode) |
| 19 | + - digests (e.g., hash of the volume key to verify passphrases) |
| 20 | +- Typical secure values: keyslot KDF argon2id; keyslot and data segment encryption aes-xts-plain64. |
| 21 | + |
| 22 | +Quickly inspect the segment cipher directly from JSON: |
| 23 | + |
| 24 | +```bash |
| 25 | +# Read JSON metadata and print the configured data segment cipher |
| 26 | +cryptsetup luksDump --type luks2 --dump-json-metadata /dev/VDISK \ |
| 27 | + | jq -r '.segments["0"].encryption' |
| 28 | +``` |
| 29 | + |
| 30 | +## Root cause |
| 31 | + |
| 32 | +- LUKS2 headers are not authenticated against storage tampering. A host/storage attacker can rewrite the JSON metadata accepted by cryptsetup. |
| 33 | +- As of cryptsetup 2.8.0, headers that set a segment’s encryption to cipher_null-ecb are accepted. The null cipher ignores keys and returns plaintext. |
| 34 | +- Up to 2.8.0, null ciphers could also be used for keyslots (keyslot opens with any passphrase). Since 2.8.1, null ciphers are rejected for keyslots with non-empty passwords, but remain allowed for segments. Switching only the segment cipher still yields plaintext I/O post-2.8.1. |
| 35 | + |
| 36 | +## Threat model: why attestation didn’t save you by default |
| 37 | + |
| 38 | +- CVMs aim to ensure confidentiality, integrity, and authenticity in an untrusted host. |
| 39 | +- Remote attestation usually measures the VM image and launch configuration, not the mutable LUKS header living on untrusted storage. |
| 40 | +- If your CVM trusts an on-disk header without robust validation/measurement, a storage attacker can alter it to a null cipher and your guest will mount a plaintext volume without error. |
| 41 | + |
| 42 | +## Exploitation (storage write access required) |
| 43 | + |
| 44 | +Preconditions: |
| 45 | +- Write access to the CVM’s LUKS2-encrypted block device. |
| 46 | +- The guest uses the on-disk LUKS2 header without robust validation/attestation. |
| 47 | + |
| 48 | +Steps (high level): |
| 49 | +1) Read the header JSON and identify the data segment definition. Example target field: segments["0"].encryption. |
| 50 | +2) Set the data segment encryption to a null cipher, e.g., cipher_null-ecb. Keep keyslot parameters and digest structure intact so the guest’s usual passphrase still “works.” |
| 51 | +3) Update both header copies and associated header digests so the header is self-consistent. |
| 52 | +4) On next boot, the guest runs cryptsetup, successfully unlocks the existing keyslot with its passphrase, and mounts the volume. Because the segment cipher is a null cipher, all reads/writes are plaintext. |
| 53 | + |
| 54 | +Variant (pre-2.8.1 keyslot abuse): if a keyslot’s area.encryption is a null cipher, it opens with any passphrase. Combine with a null segment cipher for seamless plaintext access without knowing the guest secret. |
| 55 | + |
| 56 | +## Robust mitigations (avoid TOCTOU with detached headers) |
| 57 | + |
| 58 | +Always treat on-disk LUKS headers as untrusted input. Use detached-header mode so validation and opening use the same trusted bytes from protected RAM: |
| 59 | + |
| 60 | +```bash |
| 61 | +# Copy header into protected memory (e.g., tmpfs) and open from there |
| 62 | +cryptsetup luksHeaderBackup --header-backup-file /tmp/luks_header /dev/VDISK |
| 63 | +cryptsetup open --type luks2 --header /tmp/luks_header /dev/VDISK --key-file=key.txt |
| 64 | +``` |
| 65 | + |
| 66 | +Then enforce one (or more) of: |
| 67 | + |
| 68 | +1) MAC the full header |
| 69 | + - Compute/verify a MAC over the entire header prior to use. |
| 70 | + - Only open the volume when the MAC verifies. |
| 71 | + - Examples in the wild: Flashbots tdx-init and Fortanix Salmiac adopted MAC-based verification. |
| 72 | + |
| 73 | +2) Strict JSON validation (backward compatible) |
| 74 | + - Dump JSON metadata and validate a strict allowlist of parameters (KDF, ciphers, segment count/type, flags). |
| 75 | + |
| 76 | +```bash |
| 77 | +#!/bin/bash |
| 78 | +set -e |
| 79 | +# Store header in confidential RAM fs |
| 80 | +cryptsetup luksHeaderBackup --header-backup-file /tmp/luks_header $BLOCK_DEVICE |
| 81 | +# Dump JSON metadata header to a file |
| 82 | +cryptsetup luksDump --type luks2 --dump-json-metadata /tmp/luks_header > header.json |
| 83 | +# Validate the header |
| 84 | +python validate.py header.json |
| 85 | +# Open the cryptfs using key.txt |
| 86 | +cryptsetup open --type luks2 --header /tmp/luks_header $BLOCK_DEVICE --key-file=key.txt |
| 87 | +``` |
| 88 | + |
| 89 | +<details> |
| 90 | +<summary>Example validator (enforce safe fields)</summary> |
| 91 | + |
| 92 | +```python |
| 93 | +from json import load |
| 94 | +import sys |
| 95 | +with open(sys.argv[1], "r") as f: |
| 96 | + header = load(f) |
| 97 | +if len(header["keyslots"]) != 1: |
| 98 | + raise ValueError("Expected 1 keyslot") |
| 99 | +if header["keyslots"]["0"]["type"] != "luks2": |
| 100 | + raise ValueError("Expected luks2 keyslot") |
| 101 | +if header["keyslots"]["0"]["area"]["encryption"] != "aes-xts-plain64": |
| 102 | + raise ValueError("Expected aes-xts-plain64 encryption") |
| 103 | +if header["keyslots"]["0"]["kdf"]["type"] != "argon2id": |
| 104 | + raise ValueError("Expected argon2id kdf") |
| 105 | +if len(header["tokens"]) != 0: |
| 106 | + raise ValueError("Expected 0 tokens") |
| 107 | +if len(header["segments"]) != 1: |
| 108 | + raise ValueError("Expected 1 segment") |
| 109 | +if header["segments"]["0"]["type"] != "crypt": |
| 110 | + raise ValueError("Expected crypt segment") |
| 111 | +if header["segments"]["0"]["encryption"] != "aes-xts-plain64": |
| 112 | + raise ValueError("Expected aes-xts-plain64 encryption") |
| 113 | +if "flags" in header["segments"]["0"] and header["segments"]["0"]["flags"]: |
| 114 | + raise ValueError("Segment contains unexpected flags") |
| 115 | +``` |
| 116 | + |
| 117 | +</details> |
| 118 | + |
| 119 | +3) Measure/attest the header |
| 120 | + - Remove random salts/digests and measure the sanitized header into TPM/TDX/SEV PCRs or KMS policy state. |
| 121 | + - Release decryption keys only when the measured header matches an approved, safe profile. |
| 122 | + |
| 123 | +Operational guidance: |
| 124 | +- Enforce detached header + MAC or strict validation; never trust on-disk headers directly. |
| 125 | +- Consumers of attestation should deny pre-patch framework versions in allow-lists. |
| 126 | + |
| 127 | +## Notes on versions and maintainer position |
| 128 | + |
| 129 | +- cryptsetup maintainers clarified that LUKS2 was not designed to provide integrity against storage tampering in this setting; null ciphers are retained for backward compatibility. |
| 130 | +- cryptsetup 2.8.1 (Oct 19, 2025) rejects null ciphers for keyslots with non-empty passwords but still allows null ciphers for segments. |
| 131 | + |
| 132 | +## Quick checks and triage |
| 133 | + |
| 134 | +- Inspect whether any segment encryption is set to a null cipher: |
| 135 | + |
| 136 | +```bash |
| 137 | +cryptsetup luksDump --type luks2 --dump-json-metadata /dev/VDISK \ |
| 138 | + | jq -r '.segments | to_entries[] | "segment=" + .key + ", enc=" + .value.encryption' |
| 139 | +``` |
| 140 | + |
| 141 | +- Verify keyslot and segment algorithms before opening the volume. If you cannot MAC, enforce strict JSON validation and open using the detached header from protected memory. |
| 142 | + |
| 143 | +## References |
| 144 | + |
| 145 | +- [Vulnerabilities in LUKS2 disk encryption for confidential VMs (Trail of Bits)](https://blog.trailofbits.com/2025/10/30/vulnerabilities-in-luks2-disk-encryption-for-confidential-vms/) |
| 146 | +- [cryptsetup issue #954 (null cipher acceptance and integrity considerations)](https://gitlab.com/cryptsetup/cryptsetup/-/issues/954) |
| 147 | +- [CVE-2025-59054](https://nvd.nist.gov/vuln/detail/CVE-2025-59054) |
| 148 | +- [CVE-2025-58356](https://nvd.nist.gov/vuln/detail/CVE-2025-58356) |
| 149 | +- [Related context: CVE-2021-4122 (auto-recovery path silently decrypting disks)](https://www.cve.org/CVERecord?id=CVE-2021-4122) |
| 150 | + |
| 151 | +{{#include ../../banners/hacktricks-training.md}} |
0 commit comments