From 4361c2833d513f5614dc005755ed839e85ffc90c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 22:15:02 -0700 Subject: [PATCH 1/2] fix(dstack-mr): bound the SEV-SNP metadata page budget to something a firmware needs MAX_OVMF_METADATA_PAGES admitted 16777216 pages -- 64 GiB. Every page is one SHA-384 over a PAGE_INFO, and verify_sev_launch recomputes the whole measurement *before* comparing it with the hardware MEASUREMENT, so the budget is spent entirely on input the requester authored. The document is bound only to vm_config.os_image_hash, which the requester also chooses, so no allowlist rejects it first. Measured on an EPYC-class host: 0.68 us per page, so the ceiling is **11.3 seconds of one core** for a measurement.snp.cbor of about 200 bytes. The path is reached from the verifier's POST /verify and from the KMS's key-release path, both gated only by one valid SNP quote -- which a holder can replay with an arbitrary vm_config. The shipped dstack-0.6.0 image declares 7 sections totalling **31 pages**, so the ceiling was 541000x what a real firmware asks for. Lower it to 65536 pages: over 2000x headroom, about 44 ms at the ceiling, and deliberately the same number as tdvf::MAX_MEASURED_PAGES, since the two measurement paths have the same shape and should have the same bound. --- dstack/dstack-mr/src/sev.rs | 94 ++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/dstack/dstack-mr/src/sev.rs b/dstack/dstack-mr/src/sev.rs index b1d419a49..dc7c92a4e 100644 --- a/dstack/dstack-mr/src/sev.rs +++ b/dstack/dstack-mr/src/sev.rs @@ -30,7 +30,19 @@ pub const MAX_VCPUS: u32 = 512; /// Maximum number of OVMF metadata sections accepted in a measurement input. pub const MAX_OVMF_SECTIONS: usize = 64; /// 64 GiB worth of 4 KiB pages — upper bound on measured OVMF metadata pages. -pub const MAX_OVMF_METADATA_PAGES: u64 = 16_777_216; +/// The most pages an OVMF metadata table may ask to have measured. +/// +/// Every page costs one SHA-384 over a `PAGE_INFO`, and the recomputation runs +/// to completion before the result is compared with the hardware +/// `MEASUREMENT`, so this bound is what stands between a caller-authored +/// `measurement.snp.cbor` and unbounded CPU. Measured on an EPYC-class host at +/// 0.68 us per page. +/// +/// The shipped `dstack-0.6.0` image declares 7 sections totalling **31 pages**, +/// so 65536 leaves over 2000x headroom and costs about 44 ms at the ceiling. +/// It is deliberately the same number as `tdvf::MAX_MEASURED_PAGES`: the two +/// measurement paths have the same shape and should have the same bound. +pub const MAX_OVMF_METADATA_PAGES: u64 = 0x1_0000; // VMSA page GPA: (u64)(-1) page-aligned, bits >51 cleared. const VMSA_GPA: u64 = 0x0000_FFFF_FFFF_F000; @@ -1209,6 +1221,86 @@ mod tests { ); } + /// A caller-authored measurement document must not be able to buy an + /// unbounded amount of hashing before its result is compared with + /// anything. + /// + /// `verify_sev_launch` recomputes the measurement *and then* compares it, + /// so the whole budget is spent on input the requester chose. The document + /// is bound only to `vm_config.os_image_hash`, which the requester also + /// chose, so no allowlist can reject it first. At the old ceiling of + /// 16777216 pages this call took 11.3 s of one core, measured, from a + /// ~200-byte document. + #[test] + fn a_metadata_table_cannot_buy_unbounded_hashing() { + use std::sync::mpsc; + use std::time::Duration; + + // The largest table the previous ceiling of 16777216 pages accepted: + // one section filling the budget the other sections do not claim. + const OLD_CEILING_PAGES: u64 = 16_777_216; + let mut input = valid_input(); + let claimed: u64 = input + .ovmf_sections + .iter() + .map(|section| section.size.div_ceil(4096)) + .sum(); + input.ovmf_sections.push(OvmfSectionParam { + gpa: 0x1000_0000, + size: (OLD_CEILING_PAGES - claimed) * 4096, + section_type: 1, + }); + + assert!( + validate_measurement_input(&input).is_err(), + "a table asking for {OLD_CEILING_PAGES} pages must be refused" + ); + + // And refused before any hashing happens, not after. + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let outcome = validate_measurement_input(&input) + .and_then(|()| compute_expected_measurement(&input)); + let _ = sender.send(outcome.is_err()); + }); + match receiver.recv_timeout(Duration::from_secs(5)) { + Ok(refused) => assert!(refused, "the oversized section must be refused"), + Err(_) => panic!("the measurement did not finish within 5s"), + } + } + + /// The bound must not reject a real image. The shipped dstack-0.6.0 + /// metadata is 7 sections totalling 31 pages. + #[test] + fn the_page_budget_admits_a_real_metadata_table() { + let shipped = [ + (0x800000u64, 0x9000u64, 1u32), + (0x80a000, 0x3000, 1), + (0x80d000, 0x1000, 2), + (0x80e000, 0x1000, 3), + (0x80f000, 0x1000, 4), + (0x811000, 0xf000, 1), + (0x810000, 0x1000, 0x10), + ]; + let pages: u64 = shipped.iter().map(|(_, size, _)| size.div_ceil(4096)).sum(); + assert_eq!(pages, 31, "the shipped metadata is 31 pages"); + assert!( + pages * 2000 < MAX_OVMF_METADATA_PAGES, + "the budget must leave a real image three orders of magnitude of headroom" + ); + + let mut input = valid_input(); + input.ovmf_sections = shipped + .iter() + .map(|&(gpa, size, section_type)| OvmfSectionParam { + gpa, + size, + section_type, + }) + .collect(); + validate_measurement_input(&input).expect("the shipped metadata must be accepted"); + } + fn valid_input() -> MeasurementInput { let rootfs_hash = hex_of(0x33, 32); MeasurementInput { From 241dd23b8b148f2102492ea988b887fbc8e4016b Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 24 Sep 2026 01:58:28 -0700 Subject: [PATCH 2/2] refactor(dstack-mr): trim the SEV page budget comment and tests --- dstack/dstack-mr/src/sev.rs | 101 +++++++----------------------------- 1 file changed, 20 insertions(+), 81 deletions(-) diff --git a/dstack/dstack-mr/src/sev.rs b/dstack/dstack-mr/src/sev.rs index dc7c92a4e..d877506d1 100644 --- a/dstack/dstack-mr/src/sev.rs +++ b/dstack/dstack-mr/src/sev.rs @@ -29,19 +29,8 @@ const ZEROS_LD: [u8; LD_BYTES] = [0u8; LD_BYTES]; pub const MAX_VCPUS: u32 = 512; /// Maximum number of OVMF metadata sections accepted in a measurement input. pub const MAX_OVMF_SECTIONS: usize = 64; -/// 64 GiB worth of 4 KiB pages — upper bound on measured OVMF metadata pages. -/// The most pages an OVMF metadata table may ask to have measured. -/// -/// Every page costs one SHA-384 over a `PAGE_INFO`, and the recomputation runs -/// to completion before the result is compared with the hardware -/// `MEASUREMENT`, so this bound is what stands between a caller-authored -/// `measurement.snp.cbor` and unbounded CPU. Measured on an EPYC-class host at -/// 0.68 us per page. -/// -/// The shipped `dstack-0.6.0` image declares 7 sections totalling **31 pages**, -/// so 65536 leaves over 2000x headroom and costs about 44 ms at the ceiling. -/// It is deliberately the same number as `tdvf::MAX_MEASURED_PAGES`: the two -/// measurement paths have the same shape and should have the same bound. +/// Upper bound on measured OVMF metadata pages, the same as `tdvf::MAX_MEASURED_PAGES`. +/// Each page costs a SHA-384 before the result is compared; real firmware uses 31 pages. pub const MAX_OVMF_METADATA_PAGES: u64 = 0x1_0000; // VMSA page GPA: (u64)(-1) page-aligned, bits >51 cleared. const VMSA_GPA: u64 = 0x0000_FFFF_FFFF_F000; @@ -1221,59 +1210,12 @@ mod tests { ); } - /// A caller-authored measurement document must not be able to buy an - /// unbounded amount of hashing before its result is compared with - /// anything. - /// - /// `verify_sev_launch` recomputes the measurement *and then* compares it, - /// so the whole budget is spent on input the requester chose. The document - /// is bound only to `vm_config.os_image_hash`, which the requester also - /// chose, so no allowlist can reject it first. At the old ceiling of - /// 16777216 pages this call took 11.3 s of one core, measured, from a - /// ~200-byte document. + /// The old 16M-page ceiling cost 11.3 s of CPU per request; the shipped + /// dstack-0.6.0 table (31 pages) must still be accepted. #[test] - fn a_metadata_table_cannot_buy_unbounded_hashing() { - use std::sync::mpsc; - use std::time::Duration; - - // The largest table the previous ceiling of 16777216 pages accepted: - // one section filling the budget the other sections do not claim. - const OLD_CEILING_PAGES: u64 = 16_777_216; + fn page_budget_rejects_old_ceiling_and_admits_real_table() { let mut input = valid_input(); - let claimed: u64 = input - .ovmf_sections - .iter() - .map(|section| section.size.div_ceil(4096)) - .sum(); - input.ovmf_sections.push(OvmfSectionParam { - gpa: 0x1000_0000, - size: (OLD_CEILING_PAGES - claimed) * 4096, - section_type: 1, - }); - - assert!( - validate_measurement_input(&input).is_err(), - "a table asking for {OLD_CEILING_PAGES} pages must be refused" - ); - - // And refused before any hashing happens, not after. - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let outcome = validate_measurement_input(&input) - .and_then(|()| compute_expected_measurement(&input)); - let _ = sender.send(outcome.is_err()); - }); - match receiver.recv_timeout(Duration::from_secs(5)) { - Ok(refused) => assert!(refused, "the oversized section must be refused"), - Err(_) => panic!("the measurement did not finish within 5s"), - } - } - - /// The bound must not reject a real image. The shipped dstack-0.6.0 - /// metadata is 7 sections totalling 31 pages. - #[test] - fn the_page_budget_admits_a_real_metadata_table() { - let shipped = [ + input.ovmf_sections = [ (0x800000u64, 0x9000u64, 1u32), (0x80a000, 0x3000, 1), (0x80d000, 0x1000, 2), @@ -1281,24 +1223,21 @@ mod tests { (0x80f000, 0x1000, 4), (0x811000, 0xf000, 1), (0x810000, 0x1000, 0x10), - ]; - let pages: u64 = shipped.iter().map(|(_, size, _)| size.div_ceil(4096)).sum(); - assert_eq!(pages, 31, "the shipped metadata is 31 pages"); - assert!( - pages * 2000 < MAX_OVMF_METADATA_PAGES, - "the budget must leave a real image three orders of magnitude of headroom" - ); - - let mut input = valid_input(); - input.ovmf_sections = shipped - .iter() - .map(|&(gpa, size, section_type)| OvmfSectionParam { - gpa, - size, - section_type, - }) - .collect(); + ] + .map(|(gpa, size, section_type)| OvmfSectionParam { + gpa, + size, + section_type, + }) + .to_vec(); validate_measurement_input(&input).expect("the shipped metadata must be accepted"); + + input.ovmf_sections.push(OvmfSectionParam { + gpa: 0x1000_0000, + size: 16_000_000 * 4096, + section_type: 1, + }); + assert!(validate_measurement_input(&input).is_err()); } fn valid_input() -> MeasurementInput {