From 70388fb1bb81815d703b8db1f04d25dfbd251791 Mon Sep 17 00:00:00 2001 From: Kevin Hui Date: Wed, 22 Apr 2026 12:31:15 -0700 Subject: [PATCH] vmm: Introduce kernel hashes measured boot This introduces the kernel hashes measured boot table into cloud hypervisor if a cmdline and kernel is passed into an SEV-SNP CVM, incorporating a kernel/cmdline/optional initrd into a memory page that is measured into the launch digest of a SEV-SNP CVM. If both --kernel and --cmdline are not provided, we do not insert this data page Signed-off-by: Kevin Hui --- Cargo.lock | 62 ++++++++++++ Cargo.toml | 1 + vmm/Cargo.toml | 1 + vmm/src/igvm/igvm_loader.rs | 171 ++++++++++++++++++++++++++++++++-- vmm/src/lib.rs | 7 ++ vmm/src/sev.rs | 181 ++++++++++++++++++++++++++++++++++++ vmm/src/vm.rs | 72 ++++++++++++++ 7 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 vmm/src/sev.rs diff --git a/Cargo.lock b/Cargo.lock index ef4120704..1b1d8c4bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,15 @@ dependencies = [ "zstd", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -482,6 +491,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -515,6 +530,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.23.0" @@ -599,6 +623,17 @@ dependencies = [ "thousands", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -992,6 +1027,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hypervisor" version = "0.1.0" @@ -2010,6 +2054,17 @@ dependencies = [ name = "serial_buffer" version = "0.1.0" +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2244,6 +2299,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "uds_windows" version = "1.2.1" @@ -2556,6 +2617,7 @@ dependencies = [ "serde", "serde_json", "serial_buffer", + "sha2", "signal-hook", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index aff98c0c2..4667108e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,7 @@ flume = "0.12.0" itertools = "0.14.0" libc = "0.2.186" log = "0.4.29" +sha2 = "0.11.0" signal-hook = "0.4.4" thiserror = "2.0.18" uuid = { version = "1.23.1" } diff --git a/vmm/Cargo.toml b/vmm/Cargo.toml index b3b52f494..9e37621d8 100644 --- a/vmm/Cargo.toml +++ b/vmm/Cargo.toml @@ -81,6 +81,7 @@ seccompiler = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } serial_buffer = { path = "../serial_buffer" } +sha2 = { workspace = true } signal-hook = { workspace = true } thiserror = { workspace = true } tracer = { path = "../tracer" } diff --git a/vmm/src/igvm/igvm_loader.rs b/vmm/src/igvm/igvm_loader.rs index 75b70bb7f..e60d58667 100644 --- a/vmm/src/igvm/igvm_loader.rs +++ b/vmm/src/igvm/igvm_loader.rs @@ -39,6 +39,13 @@ use crate::cpu::CpuManager; use crate::igvm::loader::Loader; use crate::igvm::{BootPageAcceptance, HV_PAGE_SIZE, IgvmLoadedInfo, StartupMemoryType}; use crate::memory_manager::{Error as MemoryManagerError, MemoryManager}; +#[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" +))] +use crate::sev::{MeasuredBootInfo, SEV_HASH_BLOCK_ADDRESS, SEV_HASH_BLOCK_SIZE}; #[cfg(feature = "sev_snp")] const ISOLATED_PAGE_SHIFT: u32 = 12; @@ -96,6 +103,29 @@ pub enum Error { MissingIgvm, #[error("Error applying VMSA to vCPU registers: {0}")] SetVmsa(#[source] crate::cpu::Error), + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + #[error("Error building SEV-SNP measured boot hash block")] + MeasuredBoot(#[source] vmm_sys_util::errno::Error), + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + #[error( + "igvmfile inserts unmeasured parameter area [0x{region_start:x}, 0x{region_end:x}) over SEV-SNP kernel hashes region [0x{hash_start:x}, 0x{hash_end:x})" + )] + MeasuredBootHashOverlap { + region_start: u64, + region_end: u64, + hash_start: u64, + hash_end: u64, + }, } // KVM SNP page types — linux/arch/x86/include/uapi/asm/sev-guest.h @@ -225,6 +255,13 @@ pub fn load_igvm( memory_manager: Arc>, cpu_manager: Arc>, cmdline: &str, + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + measured_boot: Option, #[cfg(feature = "sev_snp")] host_data: &Option, ) -> Result, Error> { let hypervisor_type = cpu_manager.lock().unwrap().hypervisor_type(); @@ -275,6 +312,45 @@ pub fn load_igvm( let mut loader = Loader::new(memory); let mut parameter_areas: HashMap = HashMap::new(); + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + let measured_boot_hash_block = if hypervisor_type == HypervisorType::Kvm { + measured_boot + .as_ref() + .map(|measured_boot| { + measured_boot + .build_hash_block() + .map_err(Error::MeasuredBoot) + }) + .transpose()? + } else { + None + }; + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + let measured_boot_hash_page_base = SEV_HASH_BLOCK_ADDRESS / HV_PAGE_SIZE; + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + let measured_boot_hash_offset = (SEV_HASH_BLOCK_ADDRESS % HV_PAGE_SIZE) as usize; + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + let mut measured_boot_hash_block_inserted = measured_boot_hash_block.is_none(); for header in igvm_file.directives() { debug_assert!(header.compatibility_mask().unwrap_or(mask) & mask == mask); @@ -418,6 +494,34 @@ pub fn load_igvm( .map_err(Error::Loader)?; imported_page = true; } + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + if let Some(hash_block) = measured_boot_hash_block.as_ref().filter(|_| { + !imported_page && gpa / HV_PAGE_SIZE == measured_boot_hash_page_base + }) { + let mut page = if data.is_empty() { + vec![0; HV_PAGE_SIZE as usize] + } else { + let mut page = data.clone(); + page.resize(HV_PAGE_SIZE as usize, 0); + page + }; + // If a data page from the bootloader contains this range, + // we need to ensure that the measured boot table is injected + // prior to importing the pages + page[measured_boot_hash_offset + ..measured_boot_hash_offset + SEV_HASH_BLOCK_SIZE] + .copy_from_slice(&hash_block[..SEV_HASH_BLOCK_SIZE]); + loader + .import_pages(gpa / HV_PAGE_SIZE, 1, acceptance, &page) + .map_err(Error::Loader)?; + measured_boot_hash_block_inserted = true; + imported_page = true; + } if !imported_page { loader .import_pages(gpa / HV_PAGE_SIZE, 1, acceptance, data) @@ -579,14 +683,37 @@ pub fn load_igvm( .get_mut(parameter_area_index) .expect("igvmfile should be valid"); match area { - ParameterAreaState::Allocated { data, max_size } => loader - .import_pages( - gpa / HV_PAGE_SIZE, - *max_size / HV_PAGE_SIZE, - BootPageAcceptance::ExclusiveUnmeasured, - data, - ) - .map_err(Error::Loader)?, + ParameterAreaState::Allocated { data, max_size } => { + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + if measured_boot_hash_block.is_some() { + let region_end = *gpa + *max_size; + let hash_end = SEV_HASH_BLOCK_ADDRESS + SEV_HASH_BLOCK_SIZE as u64; + if *gpa <= SEV_HASH_BLOCK_ADDRESS && hash_end <= region_end { + // In the case of parameter being inserted where the kernel hashes table lies, + // we should reject the igvmfile since it would interfere with the launch digest + return Err(Error::MeasuredBootHashOverlap { + region_start: *gpa, + region_end, + hash_start: SEV_HASH_BLOCK_ADDRESS, + hash_end, + }); + } + } + + loader + .import_pages( + gpa / HV_PAGE_SIZE, + *max_size / HV_PAGE_SIZE, + BootPageAcceptance::ExclusiveUnmeasured, + data, + ) + .map_err(Error::Loader)?; + } ParameterAreaState::Inserted => panic!("igvmfile is invalid, multiple insert"), } *area = ParameterAreaState::Inserted; @@ -605,6 +732,34 @@ pub fn load_igvm( } } + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + if let Some(hash_block) = measured_boot_hash_block.as_ref() + && !measured_boot_hash_block_inserted + { + // Fallback to adding kernel hashes after importing if the page data wasn't found previously + let mut page = vec![0u8; HV_PAGE_SIZE as usize]; + page[measured_boot_hash_offset..measured_boot_hash_offset + SEV_HASH_BLOCK_SIZE] + .copy_from_slice(&hash_block[..SEV_HASH_BLOCK_SIZE]); + loader + .import_pages( + measured_boot_hash_page_base, + 1, + BootPageAcceptance::Exclusive, + &page, + ) + .map_err(Error::Loader)?; + gpas.push(GpaPages { + gpa: measured_boot_hash_page_base * HV_PAGE_SIZE, + page_type: page_types.normal, + page_size: page_types.isolated_page_size_4kb, + }); + } + #[cfg(feature = "sev_snp")] if sev_snp_enabled { memory_manager diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index b0117d729..4003a241a 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -91,6 +91,13 @@ pub mod migration_transport; mod pci_segment; pub mod seccomp_filters; mod serial_manager; +#[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" +))] +pub(crate) mod sev; mod sigwinch_listener; mod sync_utils; mod uffd; diff --git a/vmm/src/sev.rs b/vmm/src/sev.rs new file mode 100644 index 000000000..b3832dece --- /dev/null +++ b/vmm/src/sev.rs @@ -0,0 +1,181 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use core::mem::size_of; +use std::ffi::CString; +use std::fs::File; +use std::io::{self, Cursor, Read, Seek, SeekFrom}; +use std::os::unix::fs::FileExt; + +use linux_loader::bootparam::boot_params; +use sha2::{Digest, Sha256}; +use uuid::{Uuid, uuid}; +use vm_memory::ByteValued; +use vmm_sys_util::errno; +use zerocopy::{Immutable, IntoBytes}; + +pub(crate) type Result = std::result::Result; + +// https://github.com/tianocore/edk2/blob/f98662c5e35b6ab60f46ee4350fa0e6eab0497cf/OvmfPkg/Include/Fdf/MemFd.fdf.inc#L89-L93 +pub const SEV_HASH_BLOCK_ADDRESS: u64 = 0x10c00; +pub const SEV_HASH_BLOCK_SIZE: usize = 0x400; +const SHA256_HASH_SIZE: usize = 32; + +// Measured hashes table definitions. These match the definitions found in +// QEMU's implementation: https://gitlab.com/qemu-project/qemu/-/blob/master/target/i386/sev.c#L68 +#[repr(C, packed)] +#[derive(IntoBytes, Immutable)] +struct SevHashTableEntry { + pub guid: [u8; 16], + pub len: u16, + pub hash: [u8; SHA256_HASH_SIZE], +} + +#[repr(C, packed)] +#[derive(IntoBytes, Immutable)] +struct SevHashTable { + pub guid: [u8; 16], + pub len: u16, + pub cmdline: SevHashTableEntry, + pub initrd: SevHashTableEntry, + pub kernel: SevHashTableEntry, +} + +const SEV_HASH_TABLE_PADDING: usize = + size_of::().next_multiple_of(16) - size_of::(); + +#[derive(IntoBytes, Immutable)] +pub struct PaddedSevHashTable { + #[allow(dead_code)] + hash_table: SevHashTable, + #[allow(dead_code)] + padding: [u8; SEV_HASH_TABLE_PADDING], +} + +// These GUIDs are defined in both EDK2 and QEMU: +// https://github.com/tianocore/edk2/blob/master/OvmfPkg/AmdSev/BlobVerifierLibSevHashes/BlobVerifierSevHashes.c#L36-L43 +// https://gitlab.com/qemu-project/qemu/-/blob/master/target/i386/sev.c#L2344-2360 +const SEV_HASH_TABLE_GUID: Uuid = uuid!("9438d606-4f22-4cc9-b479-a793d411fd21"); +const SEV_KERNEL_HASH_GUID: Uuid = uuid!("4de79437-abd2-427f-b835-d5b172d2045b"); +const SEV_INITRD_HASH_GUID: Uuid = uuid!("44baf731-3a2f-4bd7-9af1-41e29169781d"); +const SEV_CMDLINE_HASH_GUID: Uuid = uuid!("97d02dd8-bd20-4c94-aa78-e7714d36ab2a"); + +pub struct MeasuredBootInfo { + pub kernel: File, + // QEMU also makes initrd optional in the hash table + pub initramfs: Option, + pub cmdline: CString, +} + +impl MeasuredBootInfo { + fn measured_boot_io(err: &io::Error) -> errno::Error { + errno::Error::new(err.raw_os_error().unwrap_or(libc::EINVAL)) + } + + fn sha256_reader(mut reader: R) -> Result<[u8; 32]> { + let mut hasher = Sha256::new(); + let mut chunk = [0u8; 8192]; + loop { + let bytes_read = reader + .read(&mut chunk) + .map_err(|e| Self::measured_boot_io(&e))?; + if bytes_read == 0 { + break; + } + hasher.update(&chunk[..bytes_read]); + } + + Ok(hasher.finalize().into()) + } + + pub fn build_hash_block(&self) -> Result<[u8; SEV_HASH_BLOCK_SIZE]> { + // Current tooling appends a NUL byte at the end of cmdlines: + // https://github.com/virtee/sev-snp-measure/blob/main/sevsnpmeasure/sev_hashes.py#L71-L74 + let cmdline_digest: [u8; 32] = Sha256::digest(self.cmdline.as_bytes_with_nul()).into(); + + // If no initrd is provided, we will simply hash over an empty buffer, mimicking + // QEMU's behavior: https://gitlab.com/qemu-project/qemu/-/blob/master/target/i386/sev.c#L2387 + let initrd_digest: [u8; 32] = if let Some(initramfs) = &self.initramfs { + let mut initramfs = initramfs + .try_clone() + .map_err(|e| Self::measured_boot_io(&e))?; + initramfs + .seek(SeekFrom::Start(0)) + .map_err(|e| Self::measured_boot_io(&e))?; + Self::sha256_reader(initramfs)? + } else { + Self::sha256_reader(Cursor::new([]))? + }; + + // The kernel components are split up into a setup section and the actual kernel data, + // so split them up to avoid double counting, assuming this is a bzImage Linux kernel + let mut setup_header = vec![0u8; size_of::()]; + self.kernel + .read_exact_at(&mut setup_header, 0) + .map_err(|e| Self::measured_boot_io(&e))?; + + let kernel_start = { + // Matches QEMU's way of finding the kernel start/setup start + // https://gitlab.com/qemu-project/qemu/-/blob/master/hw/i386/x86-common.c#L903 + let bp = boot_params::from_mut_slice(&mut setup_header).unwrap(); + let setup_sects = if bp.hdr.setup_sects == 0 { + 4 + } else { + bp.hdr.setup_sects + }; + (u64::from(setup_sects) + 1) * 512 + }; + let setup_len = kernel_start as usize; + let mut setup_data = vec![0u8; setup_len]; + if setup_len <= setup_header.len() { + setup_data.copy_from_slice(&setup_header[..setup_len]); + } else { + setup_data[..setup_header.len()].copy_from_slice(&setup_header); + self.kernel + .read_exact_at( + &mut setup_data[setup_header.len()..], + setup_header.len() as u64, + ) + .map_err(|e| Self::measured_boot_io(&e))?; + } + + let mut kernel = self + .kernel + .try_clone() + .map_err(|e| Self::measured_boot_io(&e))?; + kernel + .seek(SeekFrom::Start(kernel_start)) + .map_err(|e| Self::measured_boot_io(&e))?; + let kernel_digest = Self::sha256_reader(Cursor::new(setup_data).chain(kernel))?; + + let table = PaddedSevHashTable { + hash_table: SevHashTable { + guid: SEV_HASH_TABLE_GUID.to_bytes_le(), + len: size_of::() as u16, + cmdline: SevHashTableEntry { + guid: SEV_CMDLINE_HASH_GUID.to_bytes_le(), + len: size_of::() as u16, + hash: cmdline_digest, + }, + initrd: SevHashTableEntry { + guid: SEV_INITRD_HASH_GUID.to_bytes_le(), + len: size_of::() as u16, + hash: initrd_digest, + }, + kernel: SevHashTableEntry { + guid: SEV_KERNEL_HASH_GUID.to_bytes_le(), + len: size_of::() as u16, + hash: kernel_digest, + }, + }, + padding: [0; SEV_HASH_TABLE_PADDING], + }; + + let mut hash_block = [0u8; SEV_HASH_BLOCK_SIZE]; + // The remainder of the page is zeroed to ensure measurements are reliably calculated + hash_block[..size_of::()].copy_from_slice(table.as_bytes()); + Ok(hash_block) + } +} diff --git a/vmm/src/vm.rs b/vmm/src/vm.rs index d479ad722..df23e37e1 100644 --- a/vmm/src/vm.rs +++ b/vmm/src/vm.rs @@ -105,6 +105,13 @@ use crate::migration::get_vm_snapshot; #[cfg(all(target_arch = "x86_64", feature = "guest_debug"))] use crate::migration::url_to_file; use crate::migration::{SNAPSHOT_CONFIG_FILE, SNAPSHOT_STATE_FILE, url_to_path}; +#[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" +))] +use crate::sev::MeasuredBootInfo; #[cfg(feature = "fw_cfg")] use crate::vm_config::FwCfgConfig; use crate::vm_config::{ @@ -1545,6 +1552,13 @@ impl Vm { igvm_file: IgvmFile, memory_manager: Arc>, cpu_manager: Arc>, + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + measured_boot: Option, #[cfg(feature = "sev_snp")] host_data: &Option, ) -> Result { // Only reserve bootloader/VMSA regions for KVM + SEV-SNP; other hypervisors @@ -1561,6 +1575,13 @@ impl Vm { memory_manager, cpu_manager.clone(), "", + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + measured_boot, #[cfg(feature = "sev_snp")] host_data, ) @@ -1655,10 +1676,61 @@ impl Vm { if payload.igvm.is_some() { let igvm_file = igvm_file.ok_or(Error::IgvmLoad(igvm_loader::Error::MissingIgvm))?; + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + let measured_boot = if let (true, Some(kernel_path), Some(_cmdline)) = ( + payload + .fw_cfg_config + .as_ref() + .is_some_and(|cfg| cfg.kernel && cfg.cmdline), + payload.kernel.as_ref(), + payload.cmdline.as_ref(), + ) { + let kernel = File::open(kernel_path).map_err(Error::KernelFile)?; + let initramfs = if payload + .fw_cfg_config + .as_ref() + .is_some_and(|cfg| cfg.initramfs) + { + payload + .initramfs + .as_ref() + .map(File::open) + .transpose() + .map_err(Error::InitramfsFile)? + } else { + None + }; + + // Must byte-match the cmdline that populate_fw_cfg sends to the guest, + // otherwise the launch measurement will diverge. + let cmdline = Self::generate_cmdline(payload)? + .as_cstring() + .map_err(Error::CmdLineCreate)?; + + Some(MeasuredBootInfo { + kernel, + initramfs, + cmdline, + }) + } else { + None + }; return Self::load_igvm( igvm_file, memory_manager, cpu_manager, + #[cfg(all( + feature = "kvm", + feature = "sev_snp", + feature = "fw_cfg", + target_arch = "x86_64" + ))] + measured_boot, #[cfg(feature = "sev_snp")] &payload.host_data, );