From 2c74e0a419ab27ee7c2a8d5f93291f09b89c4a52 Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Mon, 20 Jul 2026 06:11:47 +0000 Subject: [PATCH] dstack-util: bound LUKS2 keyslot area to the metadata region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory header validator pinned the cipher/KDF shape but never checked the keyslot area.offset/size — the byte range the encrypted key material is read from. A host with raw disk access could redirect it inside an otherwise-conforming header. cryptsetup's digest check already stops this from yielding a usable key, so it isn't exploitable on its own, but the validator shouldn't accept a header pointing its key material somewhere arbitrary. Bound the area to [2 * hdr_size, PAYLOAD_OFFSET) and reject anything outside it. Also note why salts stay unchecked (high-entropy KMS-derived passphrase, per #552) so the gap doesn't read as an oversight next time. Co-Authored-By: Claude Opus 4.8 (1M context) --- dstack/dstack-util/src/system_setup.rs | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 225ea4e38..23e8b32bf 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -2946,6 +2946,16 @@ fn validate_single_luks2_header(mut reader: impl std::io::Read, hdr_ind: u64) -> if area.encryption() != "aes-xts-plain64" { bail!("Invalid LUKS keyslot encryption: {}", area.encryption()); } + // Pin where the encrypted key material is read from. The binary area + // must sit between the two header copies and the encrypted payload; + // otherwise a host with raw disk access could redirect it elsewhere. + if area.offset() < 2 * hdr_size || area.offset() + area.size() > PAYLOAD_OFFSET { + bail!( + "Invalid LUKS keyslot area: offset={} size={}", + area.offset(), + area.size() + ); + } if *key_size != 64 { bail!("Invalid LUKS keyslot key size: {key_size}"); } @@ -2956,6 +2966,9 @@ fn validate_single_luks2_header(mut reader: impl std::io::Read, hdr_ind: u64) -> let LuksKdf::pbkdf2 { hash, iterations: _, + // Salts are left unchecked on purpose: the passphrase is + // high-entropy and KMS-derived, so an attacker-chosen salt + // buys nothing without it (see security report #552). salt: _, } = kdf else { @@ -3046,6 +3059,30 @@ fn test_validate_luks2_header() { .contains("Invalid LUKS keyslot encryption")); } +#[test] +fn test_validate_luks2_header_rejects_out_of_range_keyslot_area() { + // Redirect the keyslot binary area below the header region. Same length + // so the surrounding header stays intact; "00768" parses to 768, which is + // inside the header copies (< 2 * hdr_size) rather than the metadata gap. + let mut header = include_bytes!("../tests/fixtures/luks_header_good").to_vec(); + let needle = br#""offset":"32768""#; + let replacement = br#""offset":"00768""#; + let mut patched = 0; + let mut i = 0; + while i + needle.len() <= header.len() { + if &header[i..i + needle.len()] == needle { + header[i..i + needle.len()].copy_from_slice(replacement); + patched += 1; + i += needle.len(); + } else { + i += 1; + } + } + assert_eq!(patched, 2, "expected to patch both header copies"); + let error = validate_luks2_headers(&mut &header[..]).unwrap_err(); + assert!(error.to_string().contains("Invalid LUKS keyslot area")); +} + #[cfg(test)] fn test_app_compose( manifest_version: serde_json::Value,