From 5107a3199404f82ac5facd9f47d27c476df37f3d Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 20 Sep 2026 05:28:00 -0700 Subject: [PATCH 1/2] fix(dstack-util): reject a LUKS keyslot area whose end overflows --- dstack/dstack-util/src/system_setup.rs | 56 +++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 66839b4fa..07929eb41 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -3346,7 +3346,13 @@ fn validate_single_luks2_header(mut reader: impl std::io::Read, hdr_ind: u64) -> // 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 { + // + // `checked_add`, because both numbers come from the host-supplied + // header: `offset + size` wraps past `u64::MAX` back under + // `PAYLOAD_OFFSET` and passes this check in the release profile, which + // has overflow checks off. A wrapping sum is an out-of-range area. + let area_end = area.offset().checked_add(area.size()); + if area.offset() < 2 * hdr_size || area_end.is_none_or(|end| end > PAYLOAD_OFFSET) { bail!( "Invalid LUKS keyslot area: offset={} size={}", area.offset(), @@ -3480,6 +3486,54 @@ fn test_validate_luks2_header_rejects_out_of_range_keyslot_area() { assert!(error.to_string().contains("Invalid LUKS keyslot area")); } +/// Replace `needle` with `replacement` inside every LUKS JSON region, keeping +/// each region the same length by giving back trailing NUL padding. +#[cfg(test)] +fn patch_luks_json(header: &mut [u8], needle: &[u8], replacement: &[u8]) -> usize { + assert!(replacement.len() >= needle.len()); + let grow = replacement.len() - needle.len(); + let mut patched = 0; + let mut i = 0; + while i + needle.len() <= header.len() { + if &header[i..i + needle.len()] != needle { + i += 1; + continue; + } + let tail = &mut header[i..]; + let end = tail.iter().position(|b| *b == 0).expect("NUL padding"); + assert!(end + grow < tail.len(), "not enough NUL padding"); + tail.copy_within(needle.len()..end, replacement.len()); + tail[..replacement.len()].copy_from_slice(replacement); + patched += 1; + i += replacement.len(); + } + patched +} + +/// The keyslot-area bound exists so a host with raw disk access cannot point +/// `cryptsetup` at key material outside the metadata gap. `offset + size` is +/// u64 arithmetic on two numbers the header supplies, so a size that wraps +/// past `u64::MAX` lands back under `PAYLOAD_OFFSET` and passes the check -- +/// silently in release, where overflow checks are off and `panic = "abort"` +/// means an arithmetic panic would take the whole boot down anyway. +#[test] +fn test_validate_luks2_header_rejects_keyslot_area_that_overflows() { + let mut header = include_bytes!("../tests/fixtures/luks_header_good").to_vec(); + // 2**64 - 32768 + 1000: added to the accepted offset 32768 it wraps to + // 1000, which is below PAYLOAD_OFFSET. + let patched = patch_luks_json( + &mut header, + br#""size":"258048""#, + br#""size":"18446744073709519848""#, + ); + 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"), + "{error:#}" + ); +} + #[cfg(test)] fn test_app_compose(manifest_version: serde_json::Value, platforms: Option<&[&str]>) -> AppCompose { let mut value = serde_json::json!({ From 9b62c7c02970517bdac8223073e0ee88af3271f4 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 24 Sep 2026 01:57:39 -0700 Subject: [PATCH 2/2] refactor(dstack-util): share the LUKS header patch helper between tests --- dstack/dstack-util/src/system_setup.rs | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 07929eb41..31d42216c 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -3346,11 +3346,6 @@ fn validate_single_luks2_header(mut reader: impl std::io::Read, hdr_ind: u64) -> // 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. - // - // `checked_add`, because both numbers come from the host-supplied - // header: `offset + size` wraps past `u64::MAX` back under - // `PAYLOAD_OFFSET` and passes this check in the release profile, which - // has overflow checks off. A wrapping sum is an out-of-range area. let area_end = area.offset().checked_add(area.size()); if area.offset() < 2 * hdr_size || area_end.is_none_or(|end| end > PAYLOAD_OFFSET) { bail!( @@ -3468,19 +3463,7 @@ fn test_validate_luks2_header_rejects_out_of_range_keyslot_area() { // 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; - } - } + let patched = patch_luks_json(&mut header, br#""offset":"32768""#, br#""offset":"00768""#); 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")); @@ -3510,12 +3493,6 @@ fn patch_luks_json(header: &mut [u8], needle: &[u8], replacement: &[u8]) -> usiz patched } -/// The keyslot-area bound exists so a host with raw disk access cannot point -/// `cryptsetup` at key material outside the metadata gap. `offset + size` is -/// u64 arithmetic on two numbers the header supplies, so a size that wraps -/// past `u64::MAX` lands back under `PAYLOAD_OFFSET` and passes the check -- -/// silently in release, where overflow checks are off and `panic = "abort"` -/// means an arithmetic panic would take the whole boot down anyway. #[test] fn test_validate_luks2_header_rejects_keyslot_area_that_overflows() { let mut header = include_bytes!("../tests/fixtures/luks_header_good").to_vec();