Skip to content

Commit 2c74e0a

Browse files
h4x3rotabclaude
andcommitted
dstack-util: bound LUKS2 keyslot area to the metadata region
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) <noreply@anthropic.com>
1 parent 9bf8767 commit 2c74e0a

1 file changed

Lines changed: 37 additions & 0 deletions

File tree

dstack/dstack-util/src/system_setup.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2946,6 +2946,16 @@ fn validate_single_luks2_header(mut reader: impl std::io::Read, hdr_ind: u64) ->
29462946
if area.encryption() != "aes-xts-plain64" {
29472947
bail!("Invalid LUKS keyslot encryption: {}", area.encryption());
29482948
}
2949+
// Pin where the encrypted key material is read from. The binary area
2950+
// must sit between the two header copies and the encrypted payload;
2951+
// otherwise a host with raw disk access could redirect it elsewhere.
2952+
if area.offset() < 2 * hdr_size || area.offset() + area.size() > PAYLOAD_OFFSET {
2953+
bail!(
2954+
"Invalid LUKS keyslot area: offset={} size={}",
2955+
area.offset(),
2956+
area.size()
2957+
);
2958+
}
29492959
if *key_size != 64 {
29502960
bail!("Invalid LUKS keyslot key size: {key_size}");
29512961
}
@@ -2956,6 +2966,9 @@ fn validate_single_luks2_header(mut reader: impl std::io::Read, hdr_ind: u64) ->
29562966
let LuksKdf::pbkdf2 {
29572967
hash,
29582968
iterations: _,
2969+
// Salts are left unchecked on purpose: the passphrase is
2970+
// high-entropy and KMS-derived, so an attacker-chosen salt
2971+
// buys nothing without it (see security report #552).
29592972
salt: _,
29602973
} = kdf
29612974
else {
@@ -3046,6 +3059,30 @@ fn test_validate_luks2_header() {
30463059
.contains("Invalid LUKS keyslot encryption"));
30473060
}
30483061

3062+
#[test]
3063+
fn test_validate_luks2_header_rejects_out_of_range_keyslot_area() {
3064+
// Redirect the keyslot binary area below the header region. Same length
3065+
// so the surrounding header stays intact; "00768" parses to 768, which is
3066+
// inside the header copies (< 2 * hdr_size) rather than the metadata gap.
3067+
let mut header = include_bytes!("../tests/fixtures/luks_header_good").to_vec();
3068+
let needle = br#""offset":"32768""#;
3069+
let replacement = br#""offset":"00768""#;
3070+
let mut patched = 0;
3071+
let mut i = 0;
3072+
while i + needle.len() <= header.len() {
3073+
if &header[i..i + needle.len()] == needle {
3074+
header[i..i + needle.len()].copy_from_slice(replacement);
3075+
patched += 1;
3076+
i += needle.len();
3077+
} else {
3078+
i += 1;
3079+
}
3080+
}
3081+
assert_eq!(patched, 2, "expected to patch both header copies");
3082+
let error = validate_luks2_headers(&mut &header[..]).unwrap_err();
3083+
assert!(error.to_string().contains("Invalid LUKS keyslot area"));
3084+
}
3085+
30493086
#[cfg(test)]
30503087
fn test_app_compose(
30513088
manifest_version: serde_json::Value,

0 commit comments

Comments
 (0)