diff --git a/CHANGELOG.md b/CHANGELOG.md index 571a2ec98..09824af04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added ### Changed +* **Breaking:** Retained guest MSR state is restored on snapshot restore. KVM + denies guest MSR reads and writes by default. MSHV and WHP reset all exposed + retained MSR state. `SandboxConfiguration::allow_msrs` permits specific MSRs + by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/991 ### Removed diff --git a/Justfile b/Justfile index 5112f2f88..f69d88c41 100644 --- a/Justfile +++ b/Justfile @@ -244,6 +244,10 @@ test-isolated target=default-target features="" : {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --test integration_test -- log_message --exact --ignored @# CPU vendor check, gated to known CI runner hardware {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::snapshot::file::config::tests::cpu_vendor_current_is_recognized --exact --ignored + @# Slow host-dependent MSR audit. Run once per x86_64 CI profile. + {{ if features == "" { if hyperlight-target-arch == "x86_64" { cargo-cmd + " test --profile=" + (if target == "debug" { "dev" } else { target }) + " " + target-triple-flag + " -p hyperlight-host --lib -- sandbox::initialized_multi_use::tests::msr_tests::test_no_msr_leaks_across_restore_full_window_sweep --exact --ignored --nocapture" } else { "" } } else { "" } }} + @# LAPIC-enabled MSHV can expose additional MSRs. Audit it once in debug. + {{ if features == "mshv3,hw-interrupts" { if target == "debug" { if hyperlight-target-arch == "x86_64" { cargo-cmd + " test --no-default-features -F mshv3,hw-interrupts --profile=dev " + target-triple-flag + " -p hyperlight-host --lib -- sandbox::initialized_multi_use::tests::msr_tests::test_no_msr_leaks_across_restore_full_window_sweep --exact --ignored --nocapture" } else { "" } } else { "" } } else { "" } }} @# metrics tests {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- metrics::tests::test_metrics_are_emitted --exact diff --git a/docs/msr.md b/docs/msr.md new file mode 100644 index 000000000..8252206c6 --- /dev/null +++ b/docs/msr.md @@ -0,0 +1,240 @@ +# MSR state across restore + +## Requirement + +A snapshot represents the state of a VM at a point in time. This includes MSR +state that can affect later execution. + +After `MultiUseSandbox::restore`, the destination sandbox's MSR state must match +the supplied snapshot, regardless of prior execution in the sandbox. + +## How restore works + +Each backend provides the MSR indices it must reset. VM creation reads these +indices before guest execution and stores the values as the destination +baseline. + +A snapshot captured from a VM stores: + +* The value of every MSR in the source VM's reset set. +* The source sandbox's MSR allow list. + +Restore resolves the snapshot against the destination reset set. A value stored +in the snapshot is restored directly. An index present only in the destination +uses the destination baseline. This supports restoring into a sandbox whose +allow list is a superset of the source allow list. + +The backend writes the complete resolved set before guest execution resumes. A +read, validation, or write failure aborts restore and poisons the sandbox. + +## Why the reset set is backend-specific + +KVM can deny individual guest `RDMSR` and `WRMSR` operations. Its reset set can +therefore be limited to allowed MSRs and state changed through other CPU +instructions. + +MSHV and WHP do not provide Hyperlight with an equivalent per-MSR filter. +Their reset sets must include every retained MSR state reachable through the +partition's exposed CPU features. Hyperlight keeps a shared candidate table for +these Hyper-V backends and lets each backend select entries it can map and read. + +The backend owns discovery because register mappings and capabilities differ. +Sorting, deduplication, baseline capture, snapshot validation, and fallback to +the destination baseline are shared in `MsrResetState`. + +Every reset entry must represent guest-writable retained state that the host can +read and write. The shared candidate table is derived from the Hyper-V source +and must be audited when that source, register mappings, or feature exposure +changes. VM creation probes host reads. Table construction and round-trip +tests currently cover host write support. + +## Snapshot validation and access policy + +Snapshot state is untrusted. Every `MsrEntry` stored in a snapshot must name an +index in the destination VM's reset set. This prevents a snapshot from using +the backend register interface to write arbitrary host-visible registers. + +The allow list is separate from the captured values: + +* `msrs` contains the complete source reset state. +* `allowed_msrs` records which MSRs the source guest could access through the + configured policy. + +The source allow list must be a subset of the destination allow list. The +destination configuration remains authoritative. Snapshot data cannot expand +the destination policy or replace its KVM filter. + +`SandboxConfiguration::allow_msrs` accepts at most 16 distinct indices. KVM +also supports at most 16 contiguous filter ranges. Each allowed index must be +resettable, host-readable, and host-writable. Write-only command MSRs such as +`PRED_CMD` and `FLUSH_CMD` hold no resettable state and cannot be allowed. + +Reset and allowable are not the same set. Active SSP (`0x7A0`) is reset on the +Hyper-V backends but cannot be allowed. It has no architectural `RDMSR`/`WRMSR` +and is reachable only through the VP register API, so no guest `WRMSR` sets it +and no filter range names it. + +MSHV and WHP cannot enforce the allow list during guest execution. They retain +it in snapshots so restore compatibility has the same meaning on every +backend. + +## State restored elsewhere + +Not all architecturally MSR-backed state uses the MSR reset path. + +| State | Restore owner | +| --- | --- | +| `EFER`, `APIC_BASE`, `FS_BASE`, `GS_BASE` | Special-register snapshot state. | +| PASID (`0xD93`) on MSHV | XSAVE state. | + +Keeping one owner avoids restoring the same state through two backend APIs. + +## KVM + +KVM installs a default-deny MSR filter. The configured allow list supplies the +only permitted filter ranges. VM creation rejects an allowed index unless KVM +lists it and the host can read and write it. + +The KVM reset set contains: + +* Every allowed MSR. +* `KERNEL_GS_BASE`, because `WRGSBASE` followed by `SWAPGS` can change it + without `WRMSR`. +* `TSC`, so restore rewinds guest time on every backend. + +The default-deny filter also covers KVM's custom MSR namespace +`0x4B56_4D00..=0x4B56_4DFF`. + +Some CPU state does not pass through the filter. Hyperlight addresses those +paths separately: + +* VMX and SVM are removed from guest CPUID, so nested VMCS and VMCB state is + unreachable. Their setup MSRs remain denied. +* CET is removed from guest CPUID, so the guest cannot enable shadow stacks and + cannot move active SSP. Active SSP has no architectural MSR, so it is absent + from the KVM reset set and the backend never restores it. The CET MSRs stay + denied and unallowable. +* Hyperlight keeps the APIC in xAPIC mode. Guest writes to `APIC_BASE` and the + x2APIC range `0x800..=0x8FF` are denied. Snapshot restore also rejects an + `APIC_BASE` value with the x2APIC enable bit set. +* `FS_BASE` and `GS_BASE` are restored through special-register state. + +A denied guest access raises `#GP`. Hyperlight reports `GuestAborted` and +poisons the sandbox. + +## Hyper-V backends + +MSHV and WHP build their reset sets from: + +* Retained-state candidates the backend maps and can read, including state + reachable only through the VP register API such as active SSP. +* MTRRs required by the virtual CPU's `MTRRCAP`. +* The validated allow list. + +The shared candidate table is not an intercept list. Hyper-V also intercepts +read-only, command, and host-derived MSRs that retain no guest-controlled +value. An entry belongs in the table only when guest execution can leave state +that affects later execution. + +### MSHV + +MSHV enables the processor features supported by the host unless a partition +feature mask disables them. Its guest-visible MSR surface therefore varies by +host CPU. + +MSHV maps `IA32_XSS` through `MSR_IA32_REGISTER_U_XSS`. It maps `IA32_MPERF` +and `IA32_APERF` through the per-VP `MCount` and `ACount` registers. TSX, +WAITPKG, CET, XFD, MPX, and deadline-timer state enter the reset set when the +host exposes and maps them. + +The host's enumerated MSR index list does not identify retained state, so it +does not define the reset set. + +On capable Intel hosts MSHV can expose ENQCMD and PASID. PASID is a supervisor +XSAVE component, so XSAVE restore owns it. + +### WHP + +WHP's default feature banks disable speculation control, experimental +`DEBUGCTL` bits, and performance monitoring. Its supported feature mask omits +ENQCMD and defines no FRED feature, so WHP does not expose PASID or FRED. + +WHP maps supported MSRs to `WHV_REGISTER_NAME` values. The same mapping is used +for snapshot reads and restore writes. + +### MTRRs + +MSHV and WHP read `IA32_MTRRCAP` during VM creation. The reset set includes +`MTRR_DEF_TYPE`, every variable base and mask pair reported by `VCNT`, and all +fixed MTRRs. Hyper-V accepts fixed-MTRR writes even when `MTRRCAP.FIX` is +clear. + +VM creation fails if `VCNT` exceeds the supported maximum of 16 or required +MTRRs cannot be read. + +### TSC + +Hyper-V stores `TSC` and `TSC_ADJUST` independently. While time runs, it +preserves `TSC - TSC_ADJUST`: writing `TSC` also changes `TSC_ADJUST`, while +writing `TSC_ADJUST` changes the internal TSC offset. + +Restore writes `TSC` before `TSC_ADJUST`. The two writes remove the guest's +delta without freezing partition time. KVM also restores `TSC` so all backends +use the same guest-time semantics. + +## Retained-state inventory + +These MSRs are reset when supported by the selected backend and host. + +| MSR (index) | Retained state | +| --- | --- | +| SYSENTER CS, ESP, EIP (`0x174`-`0x176`) | System-call entry state. | +| STAR, LSTAR, CSTAR, SFMASK (`0xC000_0081`-`0xC000_0084`) | System-call target state. | +| KERNEL_GS_BASE (`0xC000_0102`) | Kernel GS base, including `SWAPGS` changes. | +| PAT (`0x277`) | Page attribute state. | +| DEBUGCTL (`0x1D9`) | Debug control state. | +| SPEC_CTRL (`0x48`), VIRT_SPEC_CTRL (`0xC001_011F`) | Speculation control state. | +| CET (`0x6A0`, `0x6A2`, `0x6A4`-`0x6A8`) | CET control and shadow-stack state. | +| Active SSP (`0x7A0`) | Shadow-stack pointer. Reset on Hyper-V backends through the VP register API. Not allowable: no architectural `RDMSR`/`WRMSR`. | +| XSS (`0xDA0`) | Extended supervisor state mask. | +| TSC, TSC_ADJUST, TSC_AUX (`0x10`, `0x3B`, `0xC000_0103`) | Guest clock state. | +| MTRRs (`0x2FF`, `0x200`-`0x21F`, `0x250`, `0x258`-`0x259`, `0x268`-`0x26F`) | Memory-type state. | +| TSX_CTRL (`0x122`) | TSX control state. | +| XFD, XFD_ERR (`0x1C4`, `0x1C5`) | Extended-feature disable state. | +| UMWAIT_CONTROL (`0xE1`) | WAITPKG control state. | +| TSC_DEADLINE (`0x6E0`) | Deadline-timer state. | +| BNDCFGS (`0xD90`) | MPX bounds configuration. | +| MPERF, APERF (`0xE7`, `0xE8`) | Per-VP performance counters. | + +These classes do not need MSR reset entries. + +| MSR (index) | Reason | +| --- | --- | +| PRED_CMD (`0x49`) | Write-only command. Issues a prediction barrier. | +| FLUSH_CMD (`0x10B`) | Write-only command. Flushes caches. | +| MISC_ENABLE (`0x1A0`) | Hyper-V discards writes. AMD faults the access. | +| FRED (`0x1CC`-`0x1D4`) | Hyperlight exposes no FRED feature. | +| PMU (`0xC1`, `0x186`, `0x38D`, `0x38F`) | Hyperlight leaves perfmon disabled. | +| LBR (`0x1C8`, `0x1C9`, `0x14CE`, `0x14CF`) | Hyperlight leaves perfmon disabled. | + +## Testing + +Focused tests cover: + +* Guest-written MSR values across snapshot, restore, and clone lifecycles. +* Source and destination allow-list compatibility. +* Backend reset-set discovery and snapshot index validation. +* `SWAPGS`, TSC, MTRR, and feature-gated Hyper-V state. +* KVM nested-virtualization, x2APIC, and custom-MSR denial. + +The ignored full-window audit probes additional Hyper-V MSR ranges on the CI +CPU. It is a regression tool, not a complete inventory of vendor MSRs. + +## Future work + +* Verify host write support for every resolved filterless reset entry during VM + creation. Allowed entries already receive a read and write check. +* Exercise MSHV and WHP on more CPU models. Their reachable MSR surfaces depend + on host features. +* Extend the inventory when Hyperlight enables new CPU features such as + perfmon, FRED, or nested virtualization. \ No newline at end of file diff --git a/docs/snapshot-versioning.md b/docs/snapshot-versioning.md index 6ce5785fc..f44f44267 100644 --- a/docs/snapshot-versioning.md +++ b/docs/snapshot-versioning.md @@ -56,6 +56,16 @@ made before it was added remain loadable. At the next hard break, make the field required, remove `serde(default)`, and reject zero as an invalid entry point rather than treating it as unknown. +### Missing MSR state + +Configs written before MSR capture may omit the `msr_state` object. The loader +represents an omitted object as empty values and an empty allow list, which +restores the destination baseline. A present object requires both `msrs` and +`allowed_msrs`. + +At the next hard break, make `msr_state` required and remove its +`serde(default)` missing-field fallback. + ## Enforcement The format is large and easy to change by accident. Two mechanisms diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs index b046f070f..d5411d80e 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs @@ -409,6 +409,9 @@ pub(crate) struct HyperlightVm { pub(super) trace_info: MemTraceInfo, #[cfg(crashdump)] pub(super) rt_cfg: SandboxRuntimeConfig, + /// MSRs restored on snapshot restore. + #[cfg(target_arch = "x86_64")] + pub(super) msr_reset: crate::hypervisor::regs::MsrResetState, } impl HyperlightVm { diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs index ae17e100b..51c6a520b 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -41,10 +41,8 @@ use crate::hypervisor::gdb::{ #[cfg(gdb)] use crate::hypervisor::gdb::{DebugError, DebugMemoryAccessError}; use crate::hypervisor::regs::{ - CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, + CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, MsrEntry, MsrResetState, }; -#[cfg(not(gdb))] -use crate::hypervisor::virtual_machine::VirtualMachine; #[cfg(kvm)] use crate::hypervisor::virtual_machine::kvm::KvmVm; #[cfg(mshv3)] @@ -69,6 +67,11 @@ use crate::sandbox::trace::MemTraceInfo; #[cfg(crashdump)] use crate::sandbox::uninitialized::SandboxRuntimeConfig; +#[cfg(gdb)] +type BoxedVm = Box; +#[cfg(not(gdb))] +type BoxedVm = Box; + impl HyperlightVm { /// Create a new HyperlightVm instance (will not run vm until calling `initialise`) #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] @@ -85,14 +88,15 @@ impl HyperlightVm { #[cfg(crashdump)] rt_cfg: SandboxRuntimeConfig, #[cfg(feature = "mem_profile")] trace_info: MemTraceInfo, ) -> std::result::Result { - #[cfg(gdb)] - type VmType = Box; - #[cfg(not(gdb))] - type VmType = Box; - - let vm: VmType = match get_available_hypervisor() { + let vm: BoxedVm = match get_available_hypervisor() { #[cfg(kvm)] - Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?), + Some(HypervisorType::Kvm) => { + let kvm_vm = KvmVm::new().map_err(VmError::CreateVm)?; + kvm_vm + .configure_msr_access(config.get_allowed_msrs()) + .map_err(VmError::CreateVm)?; + Box::new(kvm_vm) + } #[cfg(mshv3)] Some(HypervisorType::Mshv) => Box::new(MshvVm::new().map_err(VmError::CreateVm)?), #[cfg(target_os = "windows")] @@ -136,6 +140,15 @@ impl HyperlightVm { }), }); + let reset_indices = vm + .msr_reset_indices(config.get_allowed_msrs()) + .map_err(VmError::CreateVm)?; + let msr_reset = + MsrResetState::capture(reset_indices, config.get_allowed_msrs(), |indices| { + vm.msrs(indices) + }) + .map_err(VmError::Register)?; + let snapshot_slot = 0u32; let scratch_slot = 1u32; #[cfg_attr(not(gdb), allow(unused_mut))] @@ -168,6 +181,7 @@ impl HyperlightVm { trace_info, #[cfg(crashdump)] rt_cfg, + msr_reset, }; ret.update_snapshot_mapping(snapshot_mem)?; @@ -270,6 +284,39 @@ impl HyperlightVm { Ok(self.vm.sregs()?) } + /// Returns the current values of the MSR reset set. + pub(crate) fn get_msr_reset_state(&self) -> Result, AccessPageTableError> { + Ok(self.vm.msrs(&self.msr_reset.indices())?) + } + + /// Returns this VM's requested allow list, sorted and deduplicated. + pub(crate) fn get_msr_allow_list(&self) -> Vec { + self.msr_reset.allowed().to_vec() + } + + /// Restores snapshot MSRs or the initialization baseline. + pub(crate) fn restore_msrs( + &mut self, + snap_msrs: Option<&Vec>, + snap_allowed: Option<&[u32]>, + ) -> std::result::Result<(), ResetVcpuError> { + match snap_msrs { + // No captured MSRs. Use this VM's baseline. + None => self.vm.set_msrs(self.msr_reset.baseline())?, + // Reset the MSRs to the snapshot's captured values. The snapshot's + // allow list must be a subset of this VM's, and every captured + // index must be in this reset set, so validation rejects a + // mismatch first. + Some(msrs) => { + let entries = self + .msr_reset + .validate_snapshot(msrs, snap_allowed.unwrap_or(&[]))?; + self.vm.set_msrs(&entries)?; + } + } + Ok(()) + } + /// Dispatch a call from the host to the guest using the given pointer /// to the dispatch function _in the guest's address space_. /// @@ -361,6 +408,12 @@ impl HyperlightVm { cr3: u64, sregs: &CommonSpecialRegisters, ) -> std::result::Result<(), RegisterError> { + if sregs.apic_base & crate::hypervisor::regs::APIC_BASE_X2APIC_ENABLE != 0 { + return Err(RegisterError::InvalidSnapshotApicBase { + value: sregs.apic_base, + }); + } + // Restore the full special registers from snapshot, but update CR3 // to point to the new (relocated) page tables let mut sregs = *sregs; diff --git a/src/hyperlight_host/src/hypervisor/regs/x86_64/mod.rs b/src/hyperlight_host/src/hypervisor/regs/x86_64/mod.rs index 88724d95a..12d65f056 100644 --- a/src/hyperlight_host/src/hypervisor/regs/x86_64/mod.rs +++ b/src/hyperlight_host/src/hypervisor/regs/x86_64/mod.rs @@ -16,11 +16,13 @@ limitations under the License. mod debug_regs; mod fpu; +mod msrs; mod special_regs; mod standard_regs; pub(crate) use debug_regs::*; pub(crate) use fpu::*; +pub(crate) use msrs::*; pub(crate) use special_regs::*; pub(crate) use standard_regs::*; diff --git a/src/hyperlight_host/src/hypervisor/regs/x86_64/msrs.rs b/src/hyperlight_host/src/hypervisor/regs/x86_64/msrs.rs new file mode 100644 index 000000000..aabdf9fe4 --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/regs/x86_64/msrs.rs @@ -0,0 +1,499 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Model-specific register (MSR) state restored with a snapshot. +//! +//! The reset set contains every MSR whose guest-written state can persist. +//! Allowing guest access to an MSR is a separate concern. Allowed MSRs still +//! reset. + +use serde::{Deserialize, Serialize}; + +/// A single MSR captured for reset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct MsrEntry { + /// The index passed to `RDMSR` and `WRMSR`. + pub index: u32, + /// The captured value. + pub value: u64, +} + +/// The MSR reset set and initialization baseline for one VM. +#[derive(Debug, Clone)] +pub(crate) struct MsrResetState { + /// Creation-time value for every MSR this VM resets on restore and + /// captures in a snapshot: core indices, required MTRRs, and the allow + /// list. Sorted by index and deduplicated. + baseline: Vec, + /// The user-supplied allow list (`SandboxConfiguration::allow_msrs`), + /// sorted and deduplicated. A subset of the `baseline` indices, kept as a + /// separate copy so restore can compare it, as a superset, against a + /// snapshot's allow list. + allowed: Vec, +} + +impl MsrResetState { + /// Captures and normalizes the reset state for a backend-provided index set. + pub fn capture( + mut indices: Vec, + allowed: &[u32], + read: impl FnOnce( + &[u32], + ) + -> Result, crate::hypervisor::virtual_machine::RegisterError>, + ) -> Result { + indices.sort_unstable(); + indices.dedup(); + let baseline = read(&indices)?; + let mut allowed = allowed.to_vec(); + allowed.sort_unstable(); + allowed.dedup(); + Ok(Self { baseline, allowed }) + } + + /// The creation-time baseline entries. + pub fn baseline(&self) -> &[MsrEntry] { + &self.baseline + } + + /// This VM's requested allow list. + pub fn allowed(&self) -> &[u32] { + &self.allowed + } + + /// The MSR indices this VM resets, for host reads. + pub fn indices(&self) -> Vec { + self.baseline.iter().map(|entry| entry.index).collect() + } + + /// Resolves an untrusted snapshot's MSRs against this VM's reset set. + /// + /// The snapshot's allow list must be a subset of this VM's, so a + /// destination that allows at least as much accepts the snapshot on every + /// backend. Each supplied index must belong to this reset set. Returns the + /// entries to write: the snapshot's value for each reset index, or the + /// baseline where the snapshot omits it. + pub fn validate_snapshot( + &self, + snapshot: &[MsrEntry], + snap_allowed: &[u32], + ) -> Result, crate::hypervisor::virtual_machine::RegisterError> { + use crate::hypervisor::virtual_machine::RegisterError; + + let missing: Vec = snap_allowed + .iter() + .copied() + .filter(|index| !self.allowed.contains(index)) + .collect(); + if !missing.is_empty() { + return Err(RegisterError::SnapshotMsrNotAllowed { missing }); + } + + for entry in snapshot { + if !self.baseline.iter().any(|base| base.index == entry.index) { + return Err(RegisterError::InvalidSnapshotMsrIndex { index: entry.index }); + } + } + + Ok(self + .baseline + .iter() + .map(|base| MsrEntry { + index: base.index, + value: snapshot + .iter() + .find(|entry| entry.index == base.index) + .map_or(base.value, |entry| entry.value), + }) + .collect()) + } +} + +pub(crate) const MSR_TSC: u32 = 0x10; +pub(crate) const MSR_TSC_ADJUST: u32 = 0x3B; +pub(crate) const MSR_SPEC_CTRL: u32 = 0x48; +pub(crate) const MSR_UMWAIT_CONTROL: u32 = 0xE1; +pub(crate) const MSR_MPERF: u32 = 0xE7; +pub(crate) const MSR_APERF: u32 = 0xE8; +pub(crate) const MSR_MTRR_CAP: u32 = 0xFE; +pub(crate) const MSR_TSX_CTRL: u32 = 0x122; +pub(crate) const MSR_SYSENTER_CS: u32 = 0x174; +pub(crate) const MSR_SYSENTER_ESP: u32 = 0x175; +pub(crate) const MSR_SYSENTER_EIP: u32 = 0x176; +pub(crate) const MSR_XFD: u32 = 0x1C4; +pub(crate) const MSR_XFD_ERR: u32 = 0x1C5; +pub(crate) const MSR_DEBUGCTL: u32 = 0x1D9; +pub(crate) const MSR_MTRR_FIX64K_00000: u32 = 0x250; +pub(crate) const MSR_PAT: u32 = 0x277; +pub(crate) const MSR_MTRR_DEF_TYPE: u32 = 0x2FF; +pub(crate) const MSR_U_CET: u32 = 0x6A0; +pub(crate) const MSR_S_CET: u32 = 0x6A2; +pub(crate) const MSR_PL0_SSP: u32 = 0x6A4; +pub(crate) const MSR_PL1_SSP: u32 = 0x6A5; +pub(crate) const MSR_PL2_SSP: u32 = 0x6A6; +pub(crate) const MSR_PL3_SSP: u32 = 0x6A7; +pub(crate) const MSR_INTERRUPT_SSP_TABLE_ADDR: u32 = 0x6A8; +pub(crate) const MSR_IA32_SSP: u32 = 0x7A0; +pub(crate) const MSR_TSC_DEADLINE: u32 = 0x6E0; +pub(crate) const MSR_BNDCFGS: u32 = 0xD90; +pub(crate) const MSR_XSS: u32 = 0xDA0; +pub(crate) const MSR_STAR: u32 = 0xC000_0081; +pub(crate) const MSR_LSTAR: u32 = 0xC000_0082; +pub(crate) const MSR_CSTAR: u32 = 0xC000_0083; +pub(crate) const MSR_SFMASK: u32 = 0xC000_0084; +pub(crate) const MSR_KERNEL_GS_BASE: u32 = 0xC000_0102; +pub(crate) const MSR_TSC_AUX: u32 = 0xC000_0103; +pub(crate) const MSR_VIRT_SPEC_CTRL: u32 = 0xC001_011F; + +const HYPERV_VARIABLE_MTRR_COUNT: u8 = 16; + +// Every guest-writable retained value must be host-readable and host-writable. +// EFER, APIC_BASE, FS_BASE, and GS_BASE are part of the special-register state. +// PRED_CMD (0x49) and FLUSH_CMD (0x10B) are intentionally absent: they are +// write-only commands with no retained state, so they cannot be reset and +// therefore cannot be allowed. +const NON_MTRR_RESETTABLE_MSRS: &[u32] = &[ + // Guest and host access use the matching SYSENTER state. + MSR_SYSENTER_CS, + MSR_SYSENTER_ESP, + MSR_SYSENTER_EIP, + // WHP exposes no writable DEBUGCTL bits under its default feature banks. + MSR_DEBUGCTL, + // Guest and host access use the same PAT state. + MSR_PAT, + // Guest and host access use the matching syscall state. + MSR_STAR, + MSR_LSTAR, + MSR_CSTAR, + MSR_SFMASK, + // SWAPGS and host access use the same KERNEL_GS_BASE state. + MSR_KERNEL_GS_BASE, + // Guest and host access use the same SPEC_CTRL state. + MSR_SPEC_CTRL, + // AMD virtualized SSBD control. Guest-writable only where the host exposes + // the legacy VIRT_SPEC_CTRL SSBD mechanism. Host probing omits it otherwise. + MSR_VIRT_SPEC_CTRL, + // Host probing omits CET state when CET is unavailable. + MSR_U_CET, + MSR_S_CET, + MSR_PL0_SSP, + MSR_PL1_SSP, + MSR_PL2_SSP, + MSR_PL3_SSP, + MSR_INTERRUPT_SSP_TABLE_ADDR, + // MSHV maps XSS through its U_XSS alias. + MSR_XSS, + // Guest and host access use the same virtual counter. + MSR_TSC, + // Hyper-V stores TSC_ADJUST independently from TSC. + MSR_TSC_ADJUST, + // Host probing omits TSC_AUX when RDTSCP is unavailable. + MSR_TSC_AUX, + // Hyper-V exposes MPERF and APERF as per-VP counters. + MSR_MPERF, + MSR_APERF, + // Host probing omits TSX_CTRL when TSX control is unavailable. + MSR_TSX_CTRL, + // Host probing omits XFD state when XFD is unavailable. + MSR_XFD, + MSR_XFD_ERR, + // Feature MSRs enabled by default on capable hosts. Host probing omits + // each when its feature is unavailable. + MSR_UMWAIT_CONTROL, + MSR_TSC_DEADLINE, + MSR_BNDCFGS, +]; + +// State exposed through Hyper-V's VP register API, but not through an +// architectural RDMSR/WRMSR pair suitable for allow_msrs. +const HYPERV_ONLY_RESETTABLE_MSRS: &[u32] = &[MSR_IA32_SSP]; + +const MTRR_RESET_INDICES: &[u32] = &[ + // Hyper-V accepts fixed-MTRR writes even when MTRRCAP.FIX is clear. + MSR_MTRR_DEF_TYPE, + 0x200, // PHYSBASE0 + 0x201, // PHYSMASK0 + 0x202, // PHYSBASE1 + 0x203, // PHYSMASK1 + 0x204, // PHYSBASE2 + 0x205, // PHYSMASK2 + 0x206, // PHYSBASE3 + 0x207, // PHYSMASK3 + 0x208, // PHYSBASE4 + 0x209, // PHYSMASK4 + 0x20A, // PHYSBASE5 + 0x20B, // PHYSMASK5 + 0x20C, // PHYSBASE6 + 0x20D, // PHYSMASK6 + 0x20E, // PHYSBASE7 + 0x20F, // PHYSMASK7 + 0x210, // PHYSBASE8 + 0x211, // PHYSMASK8 + 0x212, // PHYSBASE9 + 0x213, // PHYSMASK9 + 0x214, // PHYSBASEA + 0x215, // PHYSMASKA + 0x216, // PHYSBASEB + 0x217, // PHYSMASKB + 0x218, // PHYSBASEC + 0x219, // PHYSMASKC + 0x21A, // PHYSBASED + 0x21B, // PHYSMASKD + 0x21C, // PHYSBASEE + 0x21D, // PHYSMASKE + 0x21E, // PHYSBASEF + 0x21F, // PHYSMASKF + MSR_MTRR_FIX64K_00000, // FIX64K_00000 + 0x258, // FIX16K_80000 + 0x259, // FIX16K_A0000 + 0x268, // FIX4K_C0000 + 0x269, // FIX4K_C8000 + 0x26A, // FIX4K_D0000 + 0x26B, // FIX4K_D8000 + 0x26C, // FIX4K_E0000 + 0x26D, // FIX4K_E8000 + 0x26E, // FIX4K_F0000 + 0x26F, // FIX4K_F8000 +]; + +/// Whether an MSR carries retained state eligible for the reset set. +pub(crate) fn is_resettable_msr(index: u32) -> bool { + NON_MTRR_RESETTABLE_MSRS.contains(&index) || is_mtrr_reset_index(index) +} + +/// Returns non-MTRR candidates probed by filterless Hyper-V backends. +pub(crate) fn filterless_core_reset_candidates() -> impl Iterator { + NON_MTRR_RESETTABLE_MSRS + .iter() + .chain(HYPERV_ONLY_RESETTABLE_MSRS) + .copied() +} + +#[cfg(test)] +pub(crate) fn resettable_msr_indices() -> impl Iterator { + NON_MTRR_RESETTABLE_MSRS + .iter() + .chain(HYPERV_ONLY_RESETTABLE_MSRS) + .chain(MTRR_RESET_INDICES) + .copied() +} + +pub(crate) fn hyperv_mtrr_reset_indices( + mtrr_cap: u64, +) -> Result, crate::hypervisor::virtual_machine::CreateVmError> { + use crate::hypervisor::virtual_machine::CreateVmError; + + let advertised = (mtrr_cap & 0xff) as u8; + if advertised > HYPERV_VARIABLE_MTRR_COUNT { + return Err(CreateVmError::UnexpectedVariableMtrrCount { + advertised, + maximum: HYPERV_VARIABLE_MTRR_COUNT, + }); + } + + let mut indices = Vec::with_capacity(1 + usize::from(advertised) * 2 + 11); + indices.push(MSR_MTRR_DEF_TYPE); + indices.extend((0..u32::from(advertised) * 2).map(|offset| 0x200 + offset)); + indices.extend([ + MSR_MTRR_FIX64K_00000, + 0x258, + 0x259, + 0x268, + 0x269, + 0x26A, + 0x26B, + 0x26C, + 0x26D, + 0x26E, + 0x26F, + ]); + Ok(indices) +} + +pub(crate) fn is_mtrr_reset_index(index: u32) -> bool { + MTRR_RESET_INDICES.contains(&index) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hypervisor::virtual_machine::{CreateVmError, RegisterError}; + + fn state() -> MsrResetState { + MsrResetState { + baseline: vec![ + MsrEntry { + index: MSR_SYSENTER_CS, + value: 0x1C, + }, + MsrEntry { + index: MSR_SYSENTER_ESP, + value: 0x2C, + }, + MsrEntry { + index: MSR_KERNEL_GS_BASE, + value: 0x3C, + }, + ], + allowed: vec![MSR_SYSENTER_CS, MSR_SYSENTER_ESP], + } + } + + #[test] + fn hyperv_mtrr_indices_follow_guest_capability() { + assert_eq!( + hyperv_mtrr_reset_indices(2).unwrap(), + [ + MSR_MTRR_DEF_TYPE, + 0x200, + 0x201, + 0x202, + 0x203, + MSR_MTRR_FIX64K_00000, + 0x258, + 0x259, + 0x268, + 0x269, + 0x26A, + 0x26B, + 0x26C, + 0x26D, + 0x26E, + 0x26F, + ] + ); + let fixed_bit_does_not_change_reset_set = hyperv_mtrr_reset_indices(2 | (1 << 8)).unwrap(); + assert_eq!( + fixed_bit_does_not_change_reset_set, + hyperv_mtrr_reset_indices(2).unwrap() + ); + } + + #[test] + fn hyperv_mtrr_count_rejects_more_than_sixteen_pairs() { + let indices = hyperv_mtrr_reset_indices(16).unwrap(); + assert!(indices.contains(&0x21F)); + assert_eq!(indices.last(), Some(&0x26F)); + assert!(indices.iter().all(|&index| is_resettable_msr(index))); + assert!(matches!( + hyperv_mtrr_reset_indices(17), + Err(CreateVmError::UnexpectedVariableMtrrCount { + advertised: 17, + maximum: 16 + }) + )); + } + + #[test] + fn snapshot_msr_validation_accepts_exact_canonical_set() { + let supplied = vec![ + MsrEntry { + index: MSR_SYSENTER_CS, + value: 1, + }, + MsrEntry { + index: MSR_SYSENTER_ESP, + value: 2, + }, + MsrEntry { + index: MSR_KERNEL_GS_BASE, + value: 3, + }, + ]; + + assert_eq!( + state() + .validate_snapshot(&supplied, &[MSR_SYSENTER_CS]) + .unwrap(), + supplied + ); + } + + #[test] + fn snapshot_msr_validation_baselines_omitted_indices() { + // A snapshot covering a subset of this VM's reset set is accepted. + // The omitted index takes the creation-time baseline value. + let supplied = vec![ + MsrEntry { + index: MSR_SYSENTER_CS, + value: 1, + }, + MsrEntry { + index: MSR_KERNEL_GS_BASE, + value: 3, + }, + ]; + + assert_eq!( + state().validate_snapshot(&supplied, &[]).unwrap(), + vec![ + MsrEntry { + index: MSR_SYSENTER_CS, + value: 1 + }, + MsrEntry { + index: MSR_SYSENTER_ESP, + value: 0x2C + }, + MsrEntry { + index: MSR_KERNEL_GS_BASE, + value: 3 + }, + ] + ); + } + + #[test] + fn snapshot_msr_validation_rejects_non_superset_allow_list() { + // The snapshot allows an MSR the destination does not. + assert!(matches!( + state().validate_snapshot(&[], &[MSR_KERNEL_GS_BASE]), + Err(RegisterError::SnapshotMsrNotAllowed { missing }) if missing == vec![MSR_KERNEL_GS_BASE] + )); + } + + #[test] + fn snapshot_msr_validation_rejects_index_outside_reset_set() { + let supplied = vec![ + MsrEntry { + index: MSR_SYSENTER_CS, + value: 1, + }, + MsrEntry { + index: MSR_SYSENTER_ESP, + value: 2, + }, + MsrEntry { + index: MSR_PAT, + value: 3, + }, + ]; + + assert!(matches!( + state().validate_snapshot(&supplied, &[]), + Err(RegisterError::InvalidSnapshotMsrIndex { index }) if index == MSR_PAT + )); + } + + #[test] + fn snapshot_msr_validation_accepts_empty_canonical_set() { + let state = MsrResetState { + baseline: Vec::new(), + allowed: Vec::new(), + }; + assert!(state.validate_snapshot(&[], &[]).unwrap().is_empty()); + } +} diff --git a/src/hyperlight_host/src/hypervisor/regs/x86_64/special_regs.rs b/src/hyperlight_host/src/hypervisor/regs/x86_64/special_regs.rs index e67ab3b49..3786b41da 100644 --- a/src/hyperlight_host/src/hypervisor/regs/x86_64/special_regs.rs +++ b/src/hyperlight_host/src/hypervisor/regs/x86_64/special_regs.rs @@ -33,6 +33,7 @@ const CR0_PE: u64 = 1; const CR0_ET: u64 = 1 << 4; const CR0_WP: u64 = 1 << 16; const CR0_PG: u64 = 1 << 31; +pub(crate) const APIC_BASE_X2APIC_ENABLE: u64 = 1 << 10; mod amd64_consts { pub(crate) const CR4_PAE: u64 = 1 << 5; @@ -677,6 +678,16 @@ mod tests { assert_eq!(original, roundtrip); } + /// The guest boots in xAPIC mode. `APIC_BASE` bit 10 (`EXTD`) enables + /// x2APIC, which would make the `0x800`-`0x8FF` MSR range legal. Keeping it + /// clear is why a guest write to an x2APIC MSR faults. + #[test] + fn standard_defaults_leave_x2apic_disabled() { + const X2APIC_ENABLE: u64 = 1 << 10; + let sregs = CommonSpecialRegisters::standard_64bit_defaults(0x1000); + assert_eq!(sregs.apic_base & X2APIC_ENABLE, 0); + } + #[cfg(mshv3)] #[test] fn round_trip_mshv_sregs() { diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs index 3dc8ec87a..dbee6e450 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs @@ -20,10 +20,13 @@ use hyperlight_common::outb::VmAction; #[cfg(gdb)] use kvm_bindings::kvm_guest_debug; use kvm_bindings::{ - kvm_debugregs, kvm_fpu, kvm_regs, kvm_sregs, kvm_userspace_memory_region, kvm_xsave, + Msrs, kvm_debugregs, kvm_fpu, kvm_msr_entry, kvm_regs, kvm_sregs, kvm_userspace_memory_region, + kvm_xsave, }; use kvm_ioctls::Cap::UserMemory; -use kvm_ioctls::{Kvm, VcpuExit, VcpuFd, VmFd}; +use kvm_ioctls::{ + Cap, Kvm, MsrFilterDefaultAction, MsrFilterRange, MsrFilterRangeFlags, VcpuExit, VcpuFd, VmFd, +}; use tracing::{Span, instrument}; #[cfg(feature = "trace_guest")] use tracing_opentelemetry::OpenTelemetrySpanExt; @@ -34,7 +37,7 @@ use vmm_sys_util::eventfd::EventFd; use crate::hypervisor::gdb::{DebugError, DebuggableVm}; use crate::hypervisor::regs::{ CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, FP_CONTROL_WORD_DEFAULT, - MXCSR_DEFAULT, + MSR_KERNEL_GS_BASE, MSR_TSC, MXCSR_DEFAULT, MsrEntry, }; #[cfg(test)] use crate::hypervisor::virtual_machine::XSAVE_BUFFER_SIZE; @@ -42,7 +45,7 @@ use crate::hypervisor::virtual_machine::XSAVE_BUFFER_SIZE; use crate::hypervisor::virtual_machine::x86_64::hw_interrupts::TimerThread; use crate::hypervisor::virtual_machine::{ CreateVmError, MapMemoryError, RegisterError, RunVcpuError, UnmapMemoryError, VirtualMachine, - VmExit, + VmExit, validate_allowed_msrs, }; use crate::mem::memory_region::MemoryRegion; #[cfg(feature = "trace_guest")] @@ -65,6 +68,15 @@ use crate::sandbox::trace::TraceContext as SandboxTraceContext; /// Extended Feature Identification, pp. 627--628 const CPUID_FUNCTION_PROCESSOR_CAPACITY_PARAMETERS_AND_EXTENDED_FEATURE_IDENTIFICATION: u32 = 0x8000_0008; +const CPUID_FUNCTION_FEATURE_INFORMATION: u32 = 0x1; +const CPUID_FUNCTION_STRUCTURED_EXTENDED_FEATURES: u32 = 0x7; +const CPUID_FUNCTION_EXTENDED_FEATURE_INFORMATION: u32 = 0x8000_0001; +const CPUID_FEATURE_VMX: u32 = 1 << 5; +const CPUID_FEATURE_SVM: u32 = 1 << 2; +/// CPUID.(EAX=7,ECX=0):ECX[7], shadow-stack support. +const CPUID_FEATURE_SHSTK: u32 = 1 << 7; +/// CPUID.(EAX=7,ECX=0):EDX[20], indirect-branch-tracking support. +const CPUID_FEATURE_IBT: u32 = 1 << 20; /// Return `true` if the KVM API is available, version 12, and has UserMemory capability, or `false` otherwise #[instrument(skip_all, parent = Span::current(), level = "Trace")] @@ -110,6 +122,24 @@ pub(crate) struct KvmVm { static KVM: LazyLock> = LazyLock::new(|| Kvm::new().map_err(|e| CreateVmError::HypervisorNotAvailable(e.into()))); +/// KVM allows at most this many MSR filter ranges. +const KVM_MSR_FILTER_MAX_RANGES: usize = 16; + +/// Returns the smallest contiguous ranges covering the supplied indices. +fn coalesce_msr_ranges(indices: &[u32]) -> Vec<(u32, usize)> { + let mut sorted: Vec = indices.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + let mut groups: Vec<(u32, usize)> = Vec::new(); + for idx in sorted { + match groups.last_mut() { + Some((base, count)) if *base + *count as u32 == idx => *count += 1, + _ => groups.push((idx, 1)), + } + } + groups +} + #[cfg(feature = "hw-interrupts")] impl KvmVm { /// Create the in-kernel IRQ chip and register an irqfd for GSI 0. @@ -163,19 +193,34 @@ impl KvmVm { .create_vcpu(0) .map_err(|e| CreateVmError::CreateVcpuFd(e.into()))?; - // Set the CPUID leaf for MaxPhysAddr. KVM allows this to - // easily be overridden by the hypervisor and defaults it very - // low, while mshv passes it through from hardware unless an - // intercept is installed. + // Configure the guest CPUID for Hyperlight's supported CPU model. let mut kvm_cpuid = hv .get_supported_cpuid(kvm_bindings::KVM_MAX_CPUID_ENTRIES) .map_err(|e| CreateVmError::InitializeVm(e.into()))?; for entry in kvm_cpuid.as_mut_slice().iter_mut() { - if entry.function - == CPUID_FUNCTION_PROCESSOR_CAPACITY_PARAMETERS_AND_EXTENDED_FEATURE_IDENTIFICATION - { - entry.eax &= !0xff; - entry.eax |= hyperlight_common::layout::SCRATCH_TOP_GPA.ilog2() + 1; + match entry.function { + // Hyperlight does not support nested Intel virtualization. + CPUID_FUNCTION_FEATURE_INFORMATION => entry.ecx &= !CPUID_FEATURE_VMX, + // Hyperlight does not support nested AMD virtualization. + CPUID_FUNCTION_EXTENDED_FEATURE_INFORMATION => { + entry.ecx &= !CPUID_FEATURE_SVM; + } + // Hyperlight does not expose CET. Shadow stacks let the guest + // move active SSP. It has no architectural MSR, so it is not in + // the KVM reset set, and the backend does not make the separate + // KVM register access needed to restore it. + CPUID_FUNCTION_STRUCTURED_EXTENDED_FEATURES if entry.index == 0 => { + entry.ecx &= !CPUID_FEATURE_SHSTK; + entry.edx &= !CPUID_FEATURE_IBT; + } + // KVM allows MaxPhysAddr to be overridden and defaults it too low for + // Hyperlight's memory layout. MSHV passes it through from hardware + // unless an intercept is installed. + CPUID_FUNCTION_PROCESSOR_CAPACITY_PARAMETERS_AND_EXTENDED_FEATURE_IDENTIFICATION => { + entry.eax &= !0xff; + entry.eax |= hyperlight_common::layout::SCRATCH_TOP_GPA.ilog2() + 1; + } + _ => {} } } vcpu_fd @@ -292,6 +337,66 @@ impl KvmVm { ))), } } + + /// Installs a deny filter containing the validated allow list. + /// Requires `KVM_CAP_X86_MSR_FILTER`. + pub(crate) fn configure_msr_access( + &self, + allowed: &[u32], + ) -> std::result::Result<(), CreateVmError> { + let hv = KVM.as_ref().map_err(|e| e.clone())?; + if !hv.check_extension(Cap::X86MsrFilter) { + tracing::error!("KVM does not support KVM_CAP_X86_MSR_FILTER."); + return Err(CreateVmError::MsrFilterNotSupported); + } + + validate_allowed_msrs(self, allowed)?; + + // Each contiguous group consumes one KVM filter range. + let groups = coalesce_msr_ranges(allowed); + if groups.len() > KVM_MSR_FILTER_MAX_RANGES { + return Err(CreateVmError::TooManyMsrRanges(groups.len())); + } + + // The bitmaps must live through set_msr_filter. + let bitmaps: Vec> = groups + .iter() + .map(|(_, count)| { + let mut bytes = vec![0u8; count.div_ceil(8)]; + for bit in 0..*count { + bytes[bit / 8] |= 1 << (bit % 8); + } + bytes + }) + .collect(); + + // Default deny requires at least one range. + static DENY_BITMAP: [u8; 1] = [0u8]; + let ranges: Vec = if groups.is_empty() { + vec![MsrFilterRange { + flags: MsrFilterRangeFlags::READ | MsrFilterRangeFlags::WRITE, + base: 0, + msr_count: 1, + bitmap: &DENY_BITMAP, + }] + } else { + groups + .iter() + .zip(bitmaps.iter()) + .map(|((base, count), bitmap)| MsrFilterRange { + flags: MsrFilterRangeFlags::READ | MsrFilterRangeFlags::WRITE, + base: *base, + msr_count: *count as u32, + bitmap: bitmap.as_slice(), + }) + .collect() + }; + + self.vm_fd + .set_msr_filter(MsrFilterDefaultAction::DENY, &ranges) + .map_err(|e| CreateVmError::InitializeVm(e.into()))?; + Ok(()) + } } impl VirtualMachine for KvmVm { @@ -403,6 +508,73 @@ impl VirtualMachine for KvmVm { Ok(()) } + fn msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError> { + if indices.is_empty() { + return Ok(Vec::new()); + } + let entries: Vec = indices + .iter() + .map(|&index| kvm_msr_entry { + index, + ..Default::default() + }) + .collect(); + let mut msrs = + Msrs::from_entries(&entries).map_err(|e| RegisterError::MsrBuild(format!("{e:?}")))?; + let n = self + .vcpu_fd + .get_msrs(&mut msrs) + .map_err(|e| RegisterError::GetMsrs(e.into()))?; + if n != indices.len() { + return Err(RegisterError::MsrShortCount { + expected: indices.len(), + actual: n, + }); + } + Ok(indices + .iter() + .zip(msrs.as_slice()) + .map(|(&index, entry)| MsrEntry { + index, + value: entry.data, + }) + .collect()) + } + + fn set_msrs(&self, msrs: &[MsrEntry]) -> std::result::Result<(), RegisterError> { + let entries: Vec = msrs + .iter() + .map(|e| kvm_msr_entry { + index: e.index, + data: e.value, + ..Default::default() + }) + .collect(); + if entries.is_empty() { + return Ok(()); + } + let kvm_msrs = + Msrs::from_entries(&entries).map_err(|e| RegisterError::MsrBuild(format!("{e:?}")))?; + let n = self + .vcpu_fd + .set_msrs(&kvm_msrs) + .map_err(|e| RegisterError::SetMsrs(e.into()))?; + if n != entries.len() { + return Err(RegisterError::MsrShortCount { + expected: entries.len(), + actual: n, + }); + } + Ok(()) + } + + fn msr_reset_indices(&self, allowed: &[u32]) -> std::result::Result, CreateVmError> { + Ok([MSR_KERNEL_GS_BASE, MSR_TSC] + .into_iter() + .chain(allowed.iter().copied()) + .collect()) + } + #[allow(dead_code)] fn xsave(&self) -> std::result::Result, RegisterError> { let xsave = self @@ -570,10 +742,45 @@ impl DebuggableVm for KvmVm { } #[cfg(test)] -#[cfg(feature = "hw-interrupts")] -mod hw_interrupt_tests { +mod tests { use super::*; + #[test] + fn coalesces_unsorted_contiguous_indices() { + assert_eq!( + coalesce_msr_ranges(&[0x176, 0x174, 0x175]), + vec![(0x174, 3)] + ); + } + + #[test] + fn deduplicates_indices() { + assert_eq!( + coalesce_msr_ranges(&[0x174, 0x174, 0x176]), + vec![(0x174, 1), (0x176, 1)] + ); + } + + #[test] + fn preserves_sixteen_range_boundary() { + let indices: Vec = (0..KVM_MSR_FILTER_MAX_RANGES as u32) + .map(|index| index * 2) + .collect(); + let ranges = coalesce_msr_ranges(&indices); + assert_eq!(ranges.len(), KVM_MSR_FILTER_MAX_RANGES); + assert!(ranges.iter().all(|(_, count)| *count == 1)); + } + + #[test] + fn scattered_indices_exceed_sixteen_range_limit() { + let indices: Vec = (0..=KVM_MSR_FILTER_MAX_RANGES as u32) + .map(|index| index * 2) + .collect(); + let ranges = coalesce_msr_ranges(&indices); + assert!(ranges.len() > KVM_MSR_FILTER_MAX_RANGES); + } + + #[cfg(feature = "hw-interrupts")] #[test] fn halt_port_is_not_standard_device() { // VmAction::Halt port must not overlap in-kernel PIC/PIT/speaker ports diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs index dac344711..dc820d699 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs @@ -24,6 +24,12 @@ use crate::hypervisor::gdb::DebugError; use crate::hypervisor::regs::{ CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, }; +#[cfg(all(target_arch = "x86_64", any(mshv3, target_os = "windows")))] +use crate::hypervisor::regs::{ + MSR_MTRR_CAP, filterless_core_reset_candidates, hyperv_mtrr_reset_indices, +}; +#[cfg(target_arch = "x86_64")] +use crate::hypervisor::regs::{MsrEntry, is_resettable_msr}; use crate::mem::memory_region::MemoryRegion; #[cfg(feature = "trace_guest")] use crate::sandbox::trace::TraceContext as SandboxTraceContext; @@ -183,6 +189,30 @@ pub enum CreateVmError { HypervisorNotAvailable(HypervisorError), #[error("Initialize VM failed: {0}")] InitializeVm(HypervisorError), + #[cfg(all(kvm, target_arch = "x86_64"))] + #[error("KVM MSR filtering requires KVM_CAP_X86_MSR_FILTER")] + MsrFilterNotSupported, + #[cfg(target_arch = "x86_64")] + #[error("MSR {msr:#x} cannot be allowed: {reason}")] + MsrNotAllowable { msr: u32, reason: String }, + #[cfg(target_arch = "x86_64")] + #[error("Failed to read IA32_MTRRCAP: {0}")] + GetMtrrCap(RegisterError), + #[cfg(target_arch = "x86_64")] + #[error("Guest-visible MTRRs cannot be reset: {0}")] + RequiredMtrrsNotResettable(RegisterError), + #[cfg(target_arch = "x86_64")] + #[error("Guest exposes {advertised} variable MTRR pairs, expected at most {maximum}")] + UnexpectedVariableMtrrCount { advertised: u8, maximum: u8 }, + #[cfg(all(kvm, target_arch = "x86_64"))] + #[error("Too many allowed MSR filter ranges: {0}. Maximum is 16")] + TooManyMsrRanges(usize), + #[cfg(target_os = "windows")] + #[error("Get Partition Property failed: {0}")] + GetPartitionProperty(HypervisorError), + #[cfg(target_os = "windows")] + #[error("WHP exposes {advertised} processor feature banks, expected {expected}")] + UnexpectedProcessorFeatureBankCount { advertised: u32, expected: u32 }, #[error("Set Partition Property failed: {0}")] SetPartitionProperty(HypervisorError), #[cfg(target_os = "windows")] @@ -224,6 +254,12 @@ pub enum RegisterError { GetSregs(HypervisorError), #[error("Failed to set special registers: {0}")] SetSregs(HypervisorError), + #[cfg(target_arch = "x86_64")] + #[error("Snapshot APIC_BASE {value:#x} enables unsupported x2APIC mode")] + InvalidSnapshotApicBase { + /// APIC_BASE value supplied by the snapshot. + value: u64, + }, #[error("Failed to get debug registers: {0}")] GetDebugRegs(HypervisorError), #[error("Failed to set debug registers: {0}")] @@ -241,6 +277,38 @@ pub enum RegisterError { }, #[error("Invalid xsave alignment")] InvalidXsaveAlignment, + #[cfg(target_arch = "x86_64")] + #[error("MSR operation not supported on this hypervisor")] + MsrsUnsupported, + #[cfg(target_arch = "x86_64")] + #[error("Failed to build MSR list: {0}")] + MsrBuild(String), + #[cfg(target_arch = "x86_64")] + #[error("Failed to get MSRs: {0}")] + GetMsrs(HypervisorError), + #[cfg(target_arch = "x86_64")] + #[error("Failed to set MSRs: {0}")] + SetMsrs(HypervisorError), + #[cfg(target_arch = "x86_64")] + #[error("Snapshot allows MSRs the destination does not: {missing:x?}")] + SnapshotMsrNotAllowed { + /// Allowed MSR indices present in the snapshot but not the destination. + missing: Vec, + }, + #[cfg(target_arch = "x86_64")] + #[error("Snapshot MSR index {index:#x} is not in this VM's reset set")] + InvalidSnapshotMsrIndex { + /// Architectural MSR index supplied by the snapshot. + index: u32, + }, + #[cfg(all(kvm, target_arch = "x86_64"))] + #[error("MSR batch short count: expected {expected}, applied {actual}")] + MsrShortCount { + /// Number of MSRs requested + expected: usize, + /// Number of MSRs actually applied before KVM stopped + actual: usize, + }, #[cfg(target_os = "windows")] #[error("Failed to get xsave size: {0}")] GetXsaveSize(#[from] HypervisorError), @@ -359,6 +427,16 @@ pub(crate) trait VirtualMachine: Debug + Send { #[allow(dead_code)] fn set_debug_regs(&self, drs: &CommonDebugRegs) -> std::result::Result<(), RegisterError>; + /// Reads the requested MSRs. + #[cfg(target_arch = "x86_64")] + fn msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError>; + /// Writes the supplied MSRs. + #[cfg(target_arch = "x86_64")] + fn set_msrs(&self, msrs: &[MsrEntry]) -> std::result::Result<(), RegisterError>; + /// Returns the MSRs whose state this backend must reset. + #[cfg(target_arch = "x86_64")] + fn msr_reset_indices(&self, allowed: &[u32]) -> std::result::Result, CreateVmError>; + /// Get xsave #[allow(dead_code)] #[cfg(not(target_arch = "aarch64"))] @@ -385,6 +463,68 @@ pub(crate) trait VirtualMachine: Debug + Send { fn partition_handle(&self) -> windows::Win32::System::Hypervisor::WHV_PARTITION_HANDLE; } +/// Validates that each allowed MSR is restorable: reset replays a captured +/// value, so the host must read and write it. +/// Rejects e.g. a variable MTRR above the host VCNT. +#[cfg(target_arch = "x86_64")] +pub(crate) fn validate_allowed_msrs( + vm: &dyn VirtualMachine, + allowed: &[u32], +) -> std::result::Result<(), CreateVmError> { + for &msr in allowed { + if !is_resettable_msr(msr) { + return Err(CreateVmError::MsrNotAllowable { + msr, + reason: "MSR is not a resettable MSR".to_string(), + }); + } + let captured = vm + .msrs(&[msr]) + .map_err(|_| CreateVmError::MsrNotAllowable { + msr, + reason: "MSR cannot be read on this host".to_string(), + })?; + vm.set_msrs(&captured) + .map_err(|_| CreateVmError::MsrNotAllowable { + msr, + reason: "MSR cannot be written on this host".to_string(), + })?; + } + Ok(()) +} + +/// Returns every guest-visible MTRR a filterless (MSHV/WHP) host must reset. +#[cfg(all(target_arch = "x86_64", any(mshv3, target_os = "windows")))] +pub(crate) fn mtrr_reset_indices( + vm: &dyn VirtualMachine, +) -> std::result::Result, CreateVmError> { + let mtrr_cap = vm + .msrs(&[MSR_MTRR_CAP]) + .map_err(CreateVmError::GetMtrrCap)?[0] + .value; + let indices = hyperv_mtrr_reset_indices(mtrr_cap)?; + vm.msrs(&indices) + .map_err(CreateVmError::RequiredMtrrsNotResettable)?; + Ok(indices) +} + +/// Builds the reset index set required by a filterless Hyper-V backend. +#[cfg(all(target_arch = "x86_64", any(mshv3, target_os = "windows")))] +pub(crate) fn hyperv_msr_reset_indices( + vm: &dyn VirtualMachine, + allowed: &[u32], +) -> std::result::Result, CreateVmError> { + validate_allowed_msrs(vm, allowed)?; + let mut indices: Vec = filterless_core_reset_candidates() + .filter(|index| vm.msrs(&[*index]).is_ok()) + .chain(mtrr_reset_indices(vm)?) + .chain(allowed.iter().copied()) + .collect(); + indices.sort_unstable(); + indices.dedup(); + Ok(indices) +} + #[cfg(test)] mod tests { diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs index 1fd50d29a..469d9ad5c 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs @@ -31,8 +31,10 @@ use mshv_bindings::{ hv_message_type_HVMSG_X64_HALT, hv_message_type_HVMSG_X64_IO_PORT_INTERCEPT, hv_partition_property_code_HV_PARTITION_PROPERTY_SYNTHETIC_PROC_FEATURES, hv_partition_synthetic_processor_features, hv_register_assoc, - hv_register_name_HV_X64_REGISTER_RIP, hv_register_value, mshv_create_partition_v2, - mshv_user_mem_region, + hv_register_name_HV_X64_REGISTER_MSR_MTRR_PHYS_BASE0, + hv_register_name_HV_X64_REGISTER_MSR_MTRR_PHYS_MASK0, hv_register_name_HV_X64_REGISTER_RIP, + hv_register_name_HV_X64_REGISTER_U_XSS, hv_register_value, mshv_create_partition_v2, + mshv_user_mem_region, msr_to_hv_reg_name as mshv_msr_to_hv_reg_name, }; #[cfg(feature = "hw-interrupts")] use mshv_bindings::{ @@ -50,7 +52,8 @@ use tracing_opentelemetry::OpenTelemetrySpanExt; use crate::hypervisor::gdb::{DebugError, DebuggableVm}; use crate::hypervisor::regs::{ CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, FP_CONTROL_WORD_DEFAULT, - MXCSR_DEFAULT, + MSR_APERF, MSR_BNDCFGS, MSR_MPERF, MSR_TSC_DEADLINE, MSR_TSX_CTRL, MSR_UMWAIT_CONTROL, + MSR_VIRT_SPEC_CTRL, MSR_XFD, MSR_XFD_ERR, MSR_XSS, MXCSR_DEFAULT, MsrEntry, }; #[cfg(test)] use crate::hypervisor::virtual_machine::XSAVE_BUFFER_SIZE; @@ -77,6 +80,84 @@ pub(crate) fn is_hypervisor_present() -> bool { } } +/// Maps an MSR index to the Hyper-V register used for host reset. +fn msr_to_hv_register_name(index: u32) -> Result { + const HV_X64_REGISTER_BNDCFGS: u32 = 0x0008_007C; + const HV_X64_REGISTER_MCOUNT: u32 = 0x0008_007E; + const HV_X64_REGISTER_A_COUNT: u32 = 0x0008_007F; + const HV_X64_REGISTER_TSX_CTRL: u32 = 0x0008_0088; + const HV_X64_REGISTER_TSC_DEADLINE: u32 = 0x0008_0095; + const HV_X64_REGISTER_UMWAIT_CONTROL: u32 = 0x0008_0098; + const HV_X64_REGISTER_XFD: u32 = 0x0008_0099; + const HV_X64_REGISTER_XFD_ERR: u32 = 0x0008_009A; + const HV_X64_REGISTER_VIRT_SPEC_CTRL: u32 = 0x0008_0086; + + if (0x200..=0x21F).contains(&index) { + let pair = (index - 0x200) / 2; + return Ok(if index & 1 == 0 { + hv_register_name_HV_X64_REGISTER_MSR_MTRR_PHYS_BASE0 + pair + } else { + hv_register_name_HV_X64_REGISTER_MSR_MTRR_PHYS_MASK0 + pair + }); + } + if index == MSR_XSS { + return Ok(hv_register_name_HV_X64_REGISTER_U_XSS); + } + if index == MSR_MPERF { + return Ok(HV_X64_REGISTER_MCOUNT); + } + if index == MSR_APERF { + return Ok(HV_X64_REGISTER_A_COUNT); + } + if index == MSR_TSX_CTRL { + return Ok(HV_X64_REGISTER_TSX_CTRL); + } + if index == MSR_TSC_DEADLINE { + return Ok(HV_X64_REGISTER_TSC_DEADLINE); + } + if index == MSR_UMWAIT_CONTROL { + return Ok(HV_X64_REGISTER_UMWAIT_CONTROL); + } + if index == MSR_BNDCFGS { + return Ok(HV_X64_REGISTER_BNDCFGS); + } + if index == MSR_XFD { + return Ok(HV_X64_REGISTER_XFD); + } + if index == MSR_XFD_ERR { + return Ok(HV_X64_REGISTER_XFD_ERR); + } + if index == MSR_VIRT_SPEC_CTRL { + return Ok(HV_X64_REGISTER_VIRT_SPEC_CTRL); + } + mshv_msr_to_hv_reg_name(index) +} + +#[cfg(test)] +mod msr_mapping_tests { + use super::*; + use crate::hypervisor::regs::resettable_msr_indices; + + #[test] + fn maps_all_stateful_msrs() { + for index in resettable_msr_indices() { + assert!( + msr_to_hv_register_name(index).is_ok(), + "missing MSR mapping for {index:#x}" + ); + } + assert_eq!(msr_to_hv_register_name(MSR_MPERF), Ok(0x0008_007E)); + assert_eq!(msr_to_hv_register_name(MSR_APERF), Ok(0x0008_007F)); + assert_eq!(msr_to_hv_register_name(MSR_TSX_CTRL), Ok(0x0008_0088)); + assert_eq!(msr_to_hv_register_name(MSR_TSC_DEADLINE), Ok(0x0008_0095)); + assert_eq!(msr_to_hv_register_name(MSR_UMWAIT_CONTROL), Ok(0x0008_0098)); + assert_eq!(msr_to_hv_register_name(MSR_BNDCFGS), Ok(0x0008_007C)); + assert_eq!(msr_to_hv_register_name(MSR_XFD), Ok(0x0008_0099)); + assert_eq!(msr_to_hv_register_name(MSR_XFD_ERR), Ok(0x0008_009A)); + assert_eq!(msr_to_hv_register_name(MSR_VIRT_SPEC_CTRL), Ok(0x0008_0086)); + } +} + /// A MSHV implementation of a single-vcpu VM #[derive(Debug)] pub(crate) struct MshvVm { @@ -103,8 +184,8 @@ impl MshvVm { #[allow(unused_mut)] let mut pr: mshv_create_partition_v2 = Default::default(); - // Enable LAPIC for hw-interrupts — required for interrupt delivery - // via request_virtual_interrupt. + // The default has no partition flags. Hardware interrupts add only the + // LAPIC required by request_virtual_interrupt. #[cfg(feature = "hw-interrupts")] { use mshv_bindings::MSHV_PT_BIT_LAPIC; @@ -432,6 +513,59 @@ impl VirtualMachine for MshvVm { Ok(()) } + fn msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError> { + if indices.is_empty() { + return Ok(Vec::new()); + } + let mut registers: Vec = indices + .iter() + .map(|&index| { + Ok(hv_register_assoc { + name: msr_to_hv_register_name(index) + .map_err(|_| RegisterError::MsrsUnsupported)?, + ..Default::default() + }) + }) + .collect::>()?; + self.vcpu_fd + .get_reg(&mut registers) + .map_err(|e| RegisterError::GetMsrs(e.into()))?; + Ok(registers + .iter() + .zip(indices) + .map(|(register, &index)| MsrEntry { + index, + // SAFETY: get_reg initialized each association as a 64-bit register. + value: unsafe { register.value.reg64 }, + }) + .collect()) + } + + fn set_msrs(&self, msrs: &[MsrEntry]) -> std::result::Result<(), RegisterError> { + let registers: Vec = msrs + .iter() + .map(|entry| { + Ok(hv_register_assoc { + name: msr_to_hv_register_name(entry.index) + .map_err(|_| RegisterError::MsrsUnsupported)?, + value: hv_register_value { reg64: entry.value }, + ..Default::default() + }) + }) + .collect::>()?; + if registers.is_empty() { + return Ok(()); + } + self.vcpu_fd + .set_reg(®isters) + .map_err(|e| RegisterError::SetMsrs(e.into()))?; + Ok(()) + } + + fn msr_reset_indices(&self, allowed: &[u32]) -> std::result::Result, CreateVmError> { + crate::hypervisor::virtual_machine::hyperv_msr_reset_indices(self, allowed) + } + #[allow(dead_code)] fn xsave(&self) -> std::result::Result, RegisterError> { let xsave = self @@ -613,7 +747,7 @@ impl DebuggableVm for MshvVm { /// for use with the shared LAPIC helpers. #[cfg(feature = "hw-interrupts")] fn lapic_regs_as_u8(regs: &[::std::os::raw::c_char; 1024]) -> &[u8] { - // Safety: c_char (i8) and u8 have the same size and alignment; + // SAFETY: c_char (i8) and u8 have the same size and alignment; // LAPIC register values are treated as raw bytes. unsafe { &*(regs as *const [::std::os::raw::c_char; 1024] as *const [u8; 1024]) } } @@ -622,7 +756,7 @@ fn lapic_regs_as_u8(regs: &[::std::os::raw::c_char; 1024]) -> &[u8] { /// for use with the shared LAPIC helpers. #[cfg(feature = "hw-interrupts")] fn lapic_regs_as_u8_mut(regs: &mut [::std::os::raw::c_char; 1024]) -> &mut [u8] { - // Safety: same as above. + // SAFETY: same as above. unsafe { &mut *(regs as *mut [::std::os::raw::c_char; 1024] as *mut [u8; 1024]) } } diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs index 6de2b29f1..6595f14d2 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs @@ -33,9 +33,13 @@ use windows_result::HRESULT; use crate::hypervisor::gdb::{DebugError, DebuggableVm}; use crate::hypervisor::regs::{ Align16, CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, - FP_CONTROL_WORD_DEFAULT, MXCSR_DEFAULT, WHP_DEBUG_REGS_NAMES, WHP_DEBUG_REGS_NAMES_LEN, - WHP_FPU_NAMES, WHP_FPU_NAMES_LEN, WHP_REGS_NAMES, WHP_REGS_NAMES_LEN, WHP_SREGS_NAMES, - WHP_SREGS_NAMES_LEN, + FP_CONTROL_WORD_DEFAULT, MSR_APERF, MSR_BNDCFGS, MSR_CSTAR, MSR_IA32_SSP, + MSR_INTERRUPT_SSP_TABLE_ADDR, MSR_KERNEL_GS_BASE, MSR_LSTAR, MSR_MPERF, MSR_MTRR_CAP, MSR_PAT, + MSR_PL0_SSP, MSR_PL1_SSP, MSR_PL2_SSP, MSR_PL3_SSP, MSR_S_CET, MSR_SFMASK, MSR_SPEC_CTRL, + MSR_STAR, MSR_SYSENTER_CS, MSR_SYSENTER_EIP, MSR_SYSENTER_ESP, MSR_TSC_DEADLINE, MSR_TSX_CTRL, + MSR_U_CET, MSR_UMWAIT_CONTROL, MSR_XFD, MSR_XFD_ERR, MXCSR_DEFAULT, MsrEntry, + WHP_DEBUG_REGS_NAMES, WHP_DEBUG_REGS_NAMES_LEN, WHP_FPU_NAMES, WHP_FPU_NAMES_LEN, + WHP_REGS_NAMES, WHP_REGS_NAMES_LEN, WHP_SREGS_NAMES, WHP_SREGS_NAMES_LEN, }; use crate::hypervisor::surrogate_process::SurrogateProcess; use crate::hypervisor::surrogate_process_manager::{ @@ -73,6 +77,144 @@ pub(crate) fn is_hypervisor_present() -> bool { } } +/// Maps an MSR index to the WHP register used for host reset. +fn msr_to_whv_register_name(index: u32) -> Option { + Some(match index { + MSR_MTRR_CAP => WHvX64RegisterMsrMtrrCap, + MSR_SYSENTER_CS => WHvX64RegisterSysenterCs, + MSR_SYSENTER_ESP => WHvX64RegisterSysenterEsp, + MSR_SYSENTER_EIP => WHvX64RegisterSysenterEip, + MSR_PAT => WHvX64RegisterPat, + MSR_STAR => WHvX64RegisterStar, + MSR_LSTAR => WHvX64RegisterLstar, + MSR_CSTAR => WHvX64RegisterCstar, + MSR_SFMASK => WHvX64RegisterSfmask, + MSR_KERNEL_GS_BASE => WHvX64RegisterKernelGsBase, + MSR_SPEC_CTRL => WHvX64RegisterSpecCtrl, + MSR_U_CET => WHvX64RegisterUCet, + MSR_S_CET => WHvX64RegisterSCet, + MSR_PL0_SSP => WHvX64RegisterPl0Ssp, + MSR_PL1_SSP => WHvX64RegisterPl1Ssp, + MSR_PL2_SSP => WHvX64RegisterPl2Ssp, + MSR_PL3_SSP => WHvX64RegisterPl3Ssp, + MSR_INTERRUPT_SSP_TABLE_ADDR => WHvX64RegisterInterruptSspTableAddr, + MSR_IA32_SSP => WHvX64RegisterSsp, + // TSC and TSC offset/aux. + 0x10 => WHvX64RegisterTsc, + 0x3B => WHvX64RegisterTscAdjust, + 0xC000_0103 => WHvX64RegisterTscAux, + MSR_MPERF => WHvX64RegisterMCount, + MSR_APERF => WHvX64RegisterACount, + MSR_TSX_CTRL => WHvX64RegisterTsxCtrl, + MSR_XFD => WHvX64RegisterXfd, + MSR_XFD_ERR => WHvX64RegisterXfdErr, + MSR_UMWAIT_CONTROL => WHvX64RegisterUmwaitControl, + MSR_TSC_DEADLINE => WHvX64RegisterTscDeadline, + MSR_BNDCFGS => WHvX64RegisterBndcfgs, + // XSAVE supervisor state mask (IA32_XSS). + 0xDA0 => WHvX64RegisterXss, + // MTRRs: def type, variable base/mask pairs 0..=9, fixed ranges. + 0x2FF => WHvX64RegisterMsrMtrrDefType, + 0x200 => WHvX64RegisterMsrMtrrPhysBase0, + 0x201 => WHvX64RegisterMsrMtrrPhysMask0, + 0x202 => WHvX64RegisterMsrMtrrPhysBase1, + 0x203 => WHvX64RegisterMsrMtrrPhysMask1, + 0x204 => WHvX64RegisterMsrMtrrPhysBase2, + 0x205 => WHvX64RegisterMsrMtrrPhysMask2, + 0x206 => WHvX64RegisterMsrMtrrPhysBase3, + 0x207 => WHvX64RegisterMsrMtrrPhysMask3, + 0x208 => WHvX64RegisterMsrMtrrPhysBase4, + 0x209 => WHvX64RegisterMsrMtrrPhysMask4, + 0x20A => WHvX64RegisterMsrMtrrPhysBase5, + 0x20B => WHvX64RegisterMsrMtrrPhysMask5, + 0x20C => WHvX64RegisterMsrMtrrPhysBase6, + 0x20D => WHvX64RegisterMsrMtrrPhysMask6, + 0x20E => WHvX64RegisterMsrMtrrPhysBase7, + 0x20F => WHvX64RegisterMsrMtrrPhysMask7, + 0x210 => WHvX64RegisterMsrMtrrPhysBase8, + 0x211 => WHvX64RegisterMsrMtrrPhysMask8, + 0x212 => WHvX64RegisterMsrMtrrPhysBase9, + 0x213 => WHvX64RegisterMsrMtrrPhysMask9, + 0x214 => WHvX64RegisterMsrMtrrPhysBaseA, + 0x215 => WHvX64RegisterMsrMtrrPhysMaskA, + 0x216 => WHvX64RegisterMsrMtrrPhysBaseB, + 0x217 => WHvX64RegisterMsrMtrrPhysMaskB, + 0x218 => WHvX64RegisterMsrMtrrPhysBaseC, + 0x219 => WHvX64RegisterMsrMtrrPhysMaskC, + 0x21A => WHvX64RegisterMsrMtrrPhysBaseD, + 0x21B => WHvX64RegisterMsrMtrrPhysMaskD, + 0x21C => WHvX64RegisterMsrMtrrPhysBaseE, + 0x21D => WHvX64RegisterMsrMtrrPhysMaskE, + 0x21E => WHvX64RegisterMsrMtrrPhysBaseF, + 0x21F => WHvX64RegisterMsrMtrrPhysMaskF, + 0x250 => WHvX64RegisterMsrMtrrFix64k00000, + 0x258 => WHvX64RegisterMsrMtrrFix16k80000, + 0x259 => WHvX64RegisterMsrMtrrFix16kA0000, + 0x268 => WHvX64RegisterMsrMtrrFix4kC0000, + 0x269 => WHvX64RegisterMsrMtrrFix4kC8000, + 0x26A => WHvX64RegisterMsrMtrrFix4kD0000, + 0x26B => WHvX64RegisterMsrMtrrFix4kD8000, + 0x26C => WHvX64RegisterMsrMtrrFix4kE0000, + 0x26D => WHvX64RegisterMsrMtrrFix4kE8000, + 0x26E => WHvX64RegisterMsrMtrrFix4kF0000, + 0x26F => WHvX64RegisterMsrMtrrFix4kF8000, + _ => return None, + }) +} + +#[cfg(test)] +mod msr_mapping_tests { + use super::*; + use crate::hypervisor::regs::{MSR_DEBUGCTL, MSR_VIRT_SPEC_CTRL, resettable_msr_indices}; + + #[test] + fn maps_all_stateful_msrs_except_debugctl_and_virt_spec_ctrl() { + for index in resettable_msr_indices() { + // WHP models neither DEBUGCTL nor VIRT_SPEC_CTRL as a named register. + // Both are safe: WHP does not expose their guest-writable features, so + // a guest cannot leave retained state in them. + if index != MSR_DEBUGCTL && index != MSR_VIRT_SPEC_CTRL { + assert!( + msr_to_whv_register_name(index).is_some(), + "missing MSR mapping for {index:#x}" + ); + } + } + + assert!(msr_to_whv_register_name(MSR_DEBUGCTL).is_none()); + assert!(msr_to_whv_register_name(MSR_VIRT_SPEC_CTRL).is_none()); + assert_eq!( + msr_to_whv_register_name(MSR_MPERF), + Some(WHvX64RegisterMCount) + ); + assert_eq!( + msr_to_whv_register_name(MSR_APERF), + Some(WHvX64RegisterACount) + ); + assert_eq!( + msr_to_whv_register_name(MSR_TSX_CTRL), + Some(WHvX64RegisterTsxCtrl) + ); + assert_eq!(msr_to_whv_register_name(MSR_XFD), Some(WHvX64RegisterXfd)); + assert_eq!( + msr_to_whv_register_name(MSR_XFD_ERR), + Some(WHvX64RegisterXfdErr) + ); + assert_eq!( + msr_to_whv_register_name(MSR_UMWAIT_CONTROL), + Some(WHvX64RegisterUmwaitControl) + ); + assert_eq!( + msr_to_whv_register_name(MSR_TSC_DEADLINE), + Some(WHvX64RegisterTscDeadline) + ); + assert_eq!( + msr_to_whv_register_name(MSR_BNDCFGS), + Some(WHvX64RegisterBndcfgs) + ); + } +} + /// Helper: release a host-side file mapping view and its handle. /// Called from both `unmap_memory` and `WhpVm::drop`. fn release_file_mapping(view_base: *mut c_void, mapping_handle: HandleWrapper) { @@ -177,6 +319,9 @@ impl WhpVm { #[cfg(feature = "hw-interrupts")] Self::enable_lapic_emulation(p)?; + // Hyper-V permits MSR intercepts only for unimplemented indices. + // Implemented MSR isolation therefore depends on reset. + WHvSetupPartition(p).map_err(|e| CreateVmError::InitializeVm(e.into()))?; WHvCreateVirtualProcessor(p, 0, 0) .map_err(|e| CreateVmError::CreateVcpuFd(e.into()))?; @@ -208,6 +353,25 @@ impl WhpVm { }) } + fn get_registers( + &self, + names: &[WHV_REGISTER_NAME], + values: &mut [Align16], + ) -> windows_result::Result<()> { + assert_eq!(names.len(), values.len()); + // SAFETY: The slices have equal lengths, and `values` is aligned output + // storage for each requested register on virtual processor 0. + unsafe { + WHvGetVirtualProcessorRegisters( + self.partition, + 0, + names.as_ptr(), + names.len() as u32, + values.as_mut_ptr() as *mut WHV_REGISTER_VALUE, + ) + } + } + /// Helper for setting arbitrary registers. Makes sure the same number /// of names and values are passed (at the expense of some performance). fn set_registers( @@ -484,16 +648,8 @@ impl VirtualMachine for WhpVm { let names = [WHvX64RegisterDr6]; let mut out: [Align16; 1] = unsafe { std::mem::zeroed() }; - unsafe { - WHvGetVirtualProcessorRegisters( - self.partition, - 0, - names.as_ptr(), - 1, - out.as_mut_ptr() as *mut WHV_REGISTER_VALUE, - ) + self.get_registers(&names, &mut out) .map_err(|e| RunVcpuError::GetDr6(e.into()))?; - } unsafe { out[0].0.Reg64 } }; @@ -539,16 +695,8 @@ impl VirtualMachine for WhpVm { let mut whv_regs_values: [Align16; WHP_REGS_NAMES_LEN] = unsafe { std::mem::zeroed() }; - unsafe { - WHvGetVirtualProcessorRegisters( - self.partition, - 0, - WHP_REGS_NAMES.as_ptr(), - whv_regs_values.len() as u32, - whv_regs_values.as_mut_ptr() as *mut WHV_REGISTER_VALUE, - ) + self.get_registers(&WHP_REGS_NAMES, &mut whv_regs_values) .map_err(|e| RegisterError::GetRegs(e.into()))?; - } WHP_REGS_NAMES .into_iter() @@ -576,16 +724,8 @@ impl VirtualMachine for WhpVm { let mut whp_fpu_values: [Align16; WHP_FPU_NAMES_LEN] = unsafe { std::mem::zeroed() }; - unsafe { - WHvGetVirtualProcessorRegisters( - self.partition, - 0, - WHP_FPU_NAMES.as_ptr(), - whp_fpu_values.len() as u32, - whp_fpu_values.as_mut_ptr() as *mut WHV_REGISTER_VALUE, - ) + self.get_registers(&WHP_FPU_NAMES, &mut whp_fpu_values) .map_err(|e| RegisterError::GetFpu(e.into()))?; - } WHP_FPU_NAMES .into_iter() @@ -613,16 +753,8 @@ impl VirtualMachine for WhpVm { let mut whp_sregs_values: [Align16; WHP_SREGS_NAMES_LEN] = unsafe { std::mem::zeroed() }; - unsafe { - WHvGetVirtualProcessorRegisters( - self.partition, - 0, - WHP_SREGS_NAMES.as_ptr(), - whp_sregs_values.len() as u32, - whp_sregs_values.as_mut_ptr() as *mut WHV_REGISTER_VALUE, - ) + self.get_registers(&WHP_SREGS_NAMES, &mut whp_sregs_values) .map_err(|e| RegisterError::GetSregs(e.into()))?; - } WHP_SREGS_NAMES .into_iter() @@ -667,20 +799,59 @@ impl VirtualMachine for WhpVm { } } + fn msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError> { + if indices.is_empty() { + return Ok(Vec::new()); + } + // Callers validate every index before reaching this mapping. + let names: Vec = indices + .iter() + .map(|&i| msr_to_whv_register_name(i).ok_or(RegisterError::MsrsUnsupported)) + .collect::>()?; + // SAFETY: WHV_REGISTER_VALUE is a union of plain-old-data fields, so an + // all-zero value is a valid initial state. + let mut values: Vec> = + vec![unsafe { std::mem::zeroed() }; names.len()]; + self.get_registers(&names, &mut values) + .map_err(|e| RegisterError::GetMsrs(e.into()))?; + Ok(indices + .iter() + .zip(values) + .map(|(&index, v)| MsrEntry { + index, + // SAFETY: each register was read as a 64-bit MSR value. + value: unsafe { v.0.Reg64 }, + }) + .collect()) + } + + fn set_msrs(&self, msrs: &[MsrEntry]) -> std::result::Result<(), RegisterError> { + let regs: Vec<(WHV_REGISTER_NAME, Align16)> = msrs + .iter() + .map(|e| { + msr_to_whv_register_name(e.index) + .map(|name| (name, Align16(WHV_REGISTER_VALUE { Reg64: e.value }))) + .ok_or(RegisterError::MsrsUnsupported) + }) + .collect::>()?; + if regs.is_empty() { + return Ok(()); + } + self.set_registers(®s) + .map_err(|e| RegisterError::SetMsrs(e.into()))?; + Ok(()) + } + + fn msr_reset_indices(&self, allowed: &[u32]) -> std::result::Result, CreateVmError> { + crate::hypervisor::virtual_machine::hyperv_msr_reset_indices(self, allowed) + } + fn debug_regs(&self) -> std::result::Result { let mut whp_debug_regs_values: [Align16; WHP_DEBUG_REGS_NAMES_LEN] = Default::default(); - unsafe { - WHvGetVirtualProcessorRegisters( - self.partition, - 0, - WHP_DEBUG_REGS_NAMES.as_ptr(), - whp_debug_regs_values.len() as u32, - whp_debug_regs_values.as_mut_ptr() as *mut WHV_REGISTER_VALUE, - ) + self.get_registers(&WHP_DEBUG_REGS_NAMES, &mut whp_debug_regs_values) .map_err(|e| RegisterError::GetDebugRegs(e.into()))?; - } let whp_debug_regs: [(WHV_REGISTER_NAME, Align16); WHP_DEBUG_REGS_NAMES_LEN] = diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index 7c74fea91..c29a8f436 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -306,6 +306,7 @@ where root_pt_gpas: &[u64], rsp_gva: u64, sregs: CommonSpecialRegisters, + #[cfg(target_arch = "x86_64")] msr_state: crate::sandbox::snapshot::SnapshotMsrState, next_action: NextAction, host_functions: HostFunctionDetails, ) -> Result { @@ -319,6 +320,8 @@ where root_pt_gpas, rsp_gva, sregs, + #[cfg(target_arch = "x86_64")] + msr_state, next_action, self.original_entrypoint, self.snapshot_count, diff --git a/src/hyperlight_host/src/sandbox/config.rs b/src/hyperlight_host/src/sandbox/config.rs index f12387a0b..875ae0185 100644 --- a/src/hyperlight_host/src/sandbox/config.rs +++ b/src/hyperlight_host/src/sandbox/config.rs @@ -29,6 +29,18 @@ pub struct DebugInfo { pub port: u16, } +/// Errors returned when configuring guest MSR access. +#[cfg(target_arch = "x86_64")] +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum AllowMsrError { + /// The requested allow list exceeds its fixed capacity. + #[error("MSR allow list exceeds its maximum of {maximum} distinct entries")] + CapacityExceeded { + /// Maximum number of distinct allowed MSRs. + maximum: usize, + }, +} + /// The complete set of configuration needed to create a Sandbox #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(C)] @@ -74,6 +86,12 @@ pub struct SandboxConfiguration { interrupt_vcpu_sigrtmin_offset: u8, /// How much writable memory to offer the guest scratch_size: usize, + /// Requested allow list stored inline to keep this type `Copy`. + #[cfg(target_arch = "x86_64")] + allowed_msrs: [u32; Self::MAX_ALLOWED_MSRS], + /// Number of valid entries in `allowed_msrs`. + #[cfg(target_arch = "x86_64")] + allowed_msrs_count: usize, } impl SandboxConfiguration { @@ -93,6 +111,11 @@ impl SandboxConfiguration { pub const DEFAULT_HEAP_SIZE: u64 = 131072; /// The default size of the scratch region pub const DEFAULT_SCRATCH_SIZE: usize = 0x48000; + /// Maximum number of distinct requested MSRs. + /// KVM supports at most 16 MSR filter ranges. Each index may require its + /// own range, so 16 is the portable limit across backends. + #[cfg(target_arch = "x86_64")] + pub const MAX_ALLOWED_MSRS: usize = 16; #[allow(clippy::too_many_arguments)] /// Create a new configuration for a sandbox with the given sizes. @@ -118,6 +141,10 @@ impl SandboxConfiguration { guest_debug_info, #[cfg(crashdump)] guest_core_dump, + #[cfg(target_arch = "x86_64")] + allowed_msrs: [0; Self::MAX_ALLOWED_MSRS], + #[cfg(target_arch = "x86_64")] + allowed_msrs_count: 0, } } @@ -159,6 +186,62 @@ impl SandboxConfiguration { self.interrupt_vcpu_sigrtmin_offset } + /// Adds MSRs to the sandbox allow list. + /// + /// Adds MSRs to the state captured by + /// [`MultiUseSandbox::snapshot`](crate::MultiUseSandbox::snapshot) and restored + /// by [`MultiUseSandbox::restore`](crate::MultiUseSandbox::restore). + /// + /// If this method is not called, only the platform's standard MSR set is + /// captured and restored. + /// + /// # Platform-specific behavior + /// + /// * KVM also uses this list to control guest MSR access. Access to unlisted + /// MSRs is denied. + /// * MSHV and WHP cannot restrict guest MSR access. An unlisted MSR is not + /// restored unless it belongs to the platform's standard MSR set. + /// + /// Duplicate indices, within the slice or against the existing list, are + /// ignored and do not count toward capacity. + /// + /// # Errors + /// + /// Returns [`AllowMsrError::CapacityExceeded`] if the distinct entries + /// would exceed [`Self::MAX_ALLOWED_MSRS`]. The allow list is unchanged on + /// error. + #[cfg(target_arch = "x86_64")] + #[instrument(skip_all, parent = Span::current(), level= "Trace")] + pub fn allow_msrs(&mut self, indices: &[u32]) -> Result<&mut Self, AllowMsrError> { + let additional = indices + .iter() + .enumerate() + .filter(|(position, index)| { + !self.allowed_msrs[..self.allowed_msrs_count].contains(index) + && !indices[..*position].contains(index) + }) + .count(); + if additional > Self::MAX_ALLOWED_MSRS - self.allowed_msrs_count { + return Err(AllowMsrError::CapacityExceeded { + maximum: Self::MAX_ALLOWED_MSRS, + }); + } + for &index in indices { + if !self.allowed_msrs[..self.allowed_msrs_count].contains(&index) { + self.allowed_msrs[self.allowed_msrs_count] = index; + self.allowed_msrs_count += 1; + } + } + Ok(self) + } + + /// Returns the requested allow list. + #[cfg(target_arch = "x86_64")] + #[instrument(skip_all, parent = Span::current(), level= "Trace")] + pub(crate) fn get_allowed_msrs(&self) -> &[u32] { + &self.allowed_msrs[..self.allowed_msrs_count] + } + /// Sets the offset from `SIGRTMIN` to determine the real-time signal used for /// interrupting the VCPU thread. /// @@ -261,7 +344,63 @@ impl Default for SandboxConfiguration { #[cfg(test)] mod tests { - use super::SandboxConfiguration; + use super::{AllowMsrError, SandboxConfiguration}; + + #[test] + #[cfg(target_arch = "x86_64")] + fn msr_allow_list_reports_overflow() { + let mut cfg = SandboxConfiguration::default(); + for index in 0..SandboxConfiguration::MAX_ALLOWED_MSRS as u32 { + cfg.allow_msrs(&[index]).unwrap(); + } + + cfg.allow_msrs(&[0]).unwrap(); + assert_eq!( + cfg.allow_msrs(&[SandboxConfiguration::MAX_ALLOWED_MSRS as u32]), + Err(AllowMsrError::CapacityExceeded { + maximum: SandboxConfiguration::MAX_ALLOWED_MSRS, + }) + ); + } + + #[test] + #[cfg(target_arch = "x86_64")] + fn bulk_msr_allow_list_overflow_is_atomic() { + let mut cfg = SandboxConfiguration::default(); + cfg.allow_msrs(&[1, 2]).unwrap(); + let oversized: Vec = (3..=SandboxConfiguration::MAX_ALLOWED_MSRS as u32 + 1).collect(); + + assert!(matches!( + cfg.allow_msrs(&oversized), + Err(AllowMsrError::CapacityExceeded { .. }) + )); + assert_eq!(cfg.get_allowed_msrs(), &[1, 2]); + } + + #[test] + #[cfg(target_arch = "x86_64")] + fn allow_msrs_dedups_and_preserves_order() { + let mut cfg = SandboxConfiguration::default(); + cfg.allow_msrs(&[0x10]).unwrap(); + cfg.allow_msrs(&[0x20, 0x20, 0x10, 0x30, 0x20]).unwrap(); + // 0x10 already present, 0x20 and 0x30 added once each in first-seen order. + assert_eq!(cfg.get_allowed_msrs(), &[0x10, 0x20, 0x30]); + } + + #[test] + #[cfg(target_arch = "x86_64")] + fn allow_msrs_duplicates_do_not_count_toward_capacity() { + let mut cfg = SandboxConfiguration::default(); + let fill: Vec = (0..SandboxConfiguration::MAX_ALLOWED_MSRS as u32 - 1).collect(); + cfg.allow_msrs(&fill).unwrap(); + // One slot remains. Three copies of one new index count as a single + // distinct entry and fit. + cfg.allow_msrs(&[u32::MAX, u32::MAX, u32::MAX]).unwrap(); + assert_eq!( + cfg.get_allowed_msrs().len(), + SandboxConfiguration::MAX_ALLOWED_MSRS + ); + } #[test] fn overrides() { diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 8e60c68e7..8ee924d32 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -159,7 +159,9 @@ impl MultiUseSandbox { /// interrupt behavior. Memory layout fields /// (`input_data_size`, `output_data_size`, `heap_size`, `scratch_size`) /// are always taken from the snapshot. Any values supplied in - /// `config` for those fields are ignored. + /// `config` for those fields are ignored. On x86_64 the `config` MSR + /// allow list must be a superset of the one the snapshot was taken with, + /// or the load fails with an MSR mismatch. /// /// # Examples /// @@ -304,6 +306,15 @@ impl MultiUseSandbox { crate::hypervisor::hyperlight_vm::HyperlightVmError::Restore(e.into()), ) })?; + + // Restore captured MSR state. + #[cfg(target_arch = "x86_64")] + vm.restore_msrs(snapshot.msrs(), snapshot.allowed_msrs()) + .map_err(|e| { + crate::HyperlightError::HyperlightVmError( + crate::hypervisor::hyperlight_vm::HyperlightVmError::Restore(e), + ) + })?; } #[cfg(gdb)] @@ -329,6 +340,10 @@ impl MultiUseSandbox { /// [`MultiUseSandbox::from_snapshot`] for the exact compatibility /// rules and the error variants returned on mismatch. /// + /// On x86_64, the snapshot includes the platform's standard MSR set and + /// each MSR selected with + /// [`SandboxConfiguration::allow_msrs`](crate::sandbox::SandboxConfiguration::allow_msrs). + /// /// ## Poisoned Sandbox /// /// This method will return [`crate::HyperlightError::PoisonedSandbox`] if the sandbox @@ -385,6 +400,15 @@ impl MultiUseSandbox { .vm .get_snapshot_sregs() .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; + #[cfg(target_arch = "x86_64")] + let msrs = self + .vm + .get_msr_reset_state() + .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; + #[cfg(target_arch = "x86_64")] + let allowed_msrs = self.vm.get_msr_allow_list(); + #[cfg(target_arch = "x86_64")] + let msr_state = crate::sandbox::snapshot::SnapshotMsrState::new(msrs, allowed_msrs); let next_action = self.vm.get_next_action(); let host_functions = (&*self.host_funcs.try_lock().map_err(|e| { crate::new_error!("Error locking host_funcs at {}:{}: {}", file!(), line!(), e) @@ -396,6 +420,8 @@ impl MultiUseSandbox { &root_pt_gpas, stack_top_gpa, sregs, + #[cfg(target_arch = "x86_64")] + msr_state, next_action, host_functions, )?; @@ -417,6 +443,15 @@ impl MultiUseSandbox { /// [`SnapshotHostFunctionMismatch`](crate::HyperlightError::SnapshotHostFunctionMismatch) /// carrying the missing names and signature differences. /// + /// On x86_64, this restores the MSR state captured by + /// [`MultiUseSandbox::snapshot`]. + /// [`SandboxConfiguration::allow_msrs`](crate::sandbox::SandboxConfiguration::allow_msrs) + /// selects additional MSRs to capture and restore. + /// + /// On x86_64 this sandbox's MSR allow list must be a superset of the one + /// the snapshot was taken with, or the restore poisons with an MSR + /// mismatch. + /// /// ## Poison State Recovery /// /// This method automatically clears any poison state when successful. This is safe because: @@ -543,6 +578,15 @@ impl MultiUseSandbox { HyperlightVmError::Restore(e) })?; + // Restore captured MSR state. + #[cfg(target_arch = "x86_64")] + self.vm + .restore_msrs(snapshot.msrs(), snapshot.allowed_msrs()) + .map_err(|e| { + self.poisoned = true; + HyperlightVmError::Restore(e) + })?; + self.vm.set_stack_top(snapshot.stack_top_gva()); self.vm.set_next_action(snapshot.next_action()); // Carry the guest ELF entry point across restore so a later @@ -2725,6 +2769,1204 @@ mod tests { ); } + #[cfg(target_arch = "x86_64")] + mod msr_tests { + use super::*; + use crate::HostFunctions; + use crate::hypervisor::hyperlight_vm::{CreateHyperlightVmError, HyperlightVmError}; + use crate::hypervisor::regs::{ + MSR_APERF, MSR_BNDCFGS, MSR_CSTAR, MSR_DEBUGCTL, MSR_IA32_SSP, + MSR_INTERRUPT_SSP_TABLE_ADDR, MSR_KERNEL_GS_BASE as KERNEL_GS_BASE, MSR_LSTAR, + MSR_MPERF, MSR_MTRR_DEF_TYPE, MSR_MTRR_FIX64K_00000, MSR_PAT, MSR_PL0_SSP, MSR_PL1_SSP, + MSR_PL2_SSP, MSR_PL3_SSP, MSR_S_CET, MSR_SFMASK, MSR_SPEC_CTRL, MSR_STAR, + MSR_SYSENTER_CS as SYSENTER_CS, MSR_SYSENTER_EIP, MSR_SYSENTER_ESP, MSR_TSC, + MSR_TSC_ADJUST, MSR_TSC_AUX, MSR_TSC_DEADLINE, MSR_TSX_CTRL, MSR_U_CET, + MSR_UMWAIT_CONTROL, MSR_VIRT_SPEC_CTRL, MSR_XFD, MSR_XFD_ERR, MSR_XSS, + }; + use crate::hypervisor::virtual_machine::{ + CreateVmError, RegisterError, ResetVcpuError, VmError, + }; + use crate::sandbox::snapshot::Snapshot; + + fn assert_msr_not_allowable(error: &HyperlightError, expected: u32) { + assert!( + matches!( + error, + HyperlightError::HyperlightVmError(HyperlightVmError::Create( + CreateHyperlightVmError::Vm(VmError::CreateVm( + CreateVmError::MsrNotAllowable { msr, .. } + )) + )) if *msr == expected + ), + "expected MsrNotAllowable for {expected:#x}, got: {error:?}" + ); + } + + fn assert_msr_not_allowed(error: &HyperlightError) { + assert!( + matches!( + error, + HyperlightError::HyperlightVmError(HyperlightVmError::Restore( + ResetVcpuError::Register(RegisterError::SnapshotMsrNotAllowed { .. }) + )) + ), + "expected SnapshotMsrNotAllowed, got: {error:?}" + ); + } + + #[test] + fn kernel_gs_base_does_not_leak_through_swapgs() { + let mut sandbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + let original: u64 = sandbox.call("ReadKernelGsBaseViaSwapgs", ()).unwrap(); + let sentinel = if original == 0x0000_7AAA_5555_AAAA { + 0x0000_6BBB_4444_BBBB + } else { + 0x0000_7AAA_5555_AAAA + }; + let snapshot = sandbox.snapshot().unwrap(); + + sandbox + .call::<()>("WriteKernelGsBaseViaSwapgs", sentinel) + .unwrap(); + assert_eq!( + sandbox + .call::("ReadKernelGsBaseViaSwapgs", ()) + .unwrap(), + sentinel + ); + + sandbox.restore(snapshot).unwrap(); + assert_eq!( + sandbox + .call::("ReadKernelGsBaseViaSwapgs", ()) + .unwrap(), + original, + "KERNEL_GS_BASE leaked across restore" + ); + } + + #[test] + fn snapshot_msr_values_survive_full_in_memory_lifecycle() { + let mut config = SandboxConfiguration::default(); + config.allow_msrs(&[KERNEL_GS_BASE]).unwrap(); + let mut source = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(config), + ) + .unwrap() + .evolve() + .unwrap(); + let first = 0x1111; + let second = 0x2222; + let third = 0x3333; + + source + .call::<()>("WriteMSR", (KERNEL_GS_BASE, first)) + .unwrap(); + assert_eq!( + source.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + first + ); + let first_snapshot = source.snapshot().unwrap(); + + source + .call::<()>("WriteMSR", (KERNEL_GS_BASE, second)) + .unwrap(); + assert_eq!( + source.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + second + ); + source.restore(first_snapshot.clone()).unwrap(); + assert_eq!( + source.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + first + ); + + let mut clone = MultiUseSandbox::from_snapshot( + first_snapshot.clone(), + HostFunctions::default(), + Some(config), + ) + .unwrap(); + assert_eq!(clone.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), first); + + clone + .call::<()>("WriteMSR", (KERNEL_GS_BASE, third)) + .unwrap(); + assert_eq!(clone.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), third); + let third_snapshot = clone.snapshot().unwrap(); + source.restore(third_snapshot.clone()).unwrap(); + assert_eq!( + source.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + third + ); + + let mut second_clone = MultiUseSandbox::from_snapshot( + third_snapshot, + HostFunctions::default(), + Some(config), + ) + .unwrap(); + assert_eq!( + second_clone.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + third + ); + second_clone.restore(first_snapshot).unwrap(); + assert_eq!( + second_clone.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + first + ); + } + + #[test] + fn equivalent_msr_configs_are_order_independent_across_sandboxes() { + let source_order = [KERNEL_GS_BASE, SYSENTER_CS]; + let target_order = [SYSENTER_CS, KERNEL_GS_BASE]; + let mut source_config = SandboxConfiguration::default(); + source_config.allow_msrs(&source_order).unwrap(); + let mut source = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(source_config), + ) + .unwrap() + .evolve() + .unwrap(); + source + .call::<()>("WriteMSR", (KERNEL_GS_BASE, 0x4444u64)) + .unwrap(); + assert_eq!( + source.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + 0x4444 + ); + source + .call::<()>("WriteMSR", (SYSENTER_CS, 0x5555u64)) + .unwrap(); + assert_eq!(source.call::("ReadMSR", SYSENTER_CS).unwrap(), 0x5555); + let snapshot = source.snapshot().unwrap(); + + let mut target_config = SandboxConfiguration::default(); + target_config.allow_msrs(&target_order).unwrap(); + let mut target = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(target_config), + ) + .unwrap() + .evolve() + .unwrap(); + target + .call::<()>("WriteMSR", (KERNEL_GS_BASE, 0xAAAAu64)) + .unwrap(); + assert_eq!( + target.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + 0xAAAA + ); + target + .call::<()>("WriteMSR", (SYSENTER_CS, 0xBBBBu64)) + .unwrap(); + assert_eq!(target.call::("ReadMSR", SYSENTER_CS).unwrap(), 0xBBBB); + target.restore(snapshot.clone()).unwrap(); + assert_eq!( + target.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + 0x4444 + ); + assert_eq!(target.call::("ReadMSR", SYSENTER_CS).unwrap(), 0x5555); + + let mut clone = MultiUseSandbox::from_snapshot( + snapshot, + HostFunctions::default(), + Some(target_config), + ) + .unwrap(); + assert_eq!( + clone.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + 0x4444 + ); + assert_eq!(clone.call::("ReadMSR", SYSENTER_CS).unwrap(), 0x5555); + } + + /// A restore succeeds when the destination allow list is a superset + /// of the snapshot's. The snapshot's allowed MSR keeps its captured + /// value. An MSR the destination adds resets to the baseline. + #[test] + fn snapshot_restores_into_superset_allow_list() { + const SYSENTER_ESP: u32 = 0x175; + let sentinel: u64 = 0x1234; + let mut source_config = SandboxConfiguration::default(); + source_config.allow_msrs(&[SYSENTER_CS]).unwrap(); + let mut source = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(source_config), + ) + .unwrap() + .evolve() + .unwrap(); + source + .call::<()>("WriteMSR", (SYSENTER_CS, sentinel)) + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + let mut dest_config = SandboxConfiguration::default(); + dest_config + .allow_msrs(&[SYSENTER_CS, SYSENTER_ESP]) + .unwrap(); + + let mut clone = MultiUseSandbox::from_snapshot( + snapshot.clone(), + HostFunctions::default(), + Some(dest_config), + ) + .unwrap(); + assert_eq!(clone.call::("ReadMSR", SYSENTER_CS).unwrap(), sentinel); + let baseline: u64 = clone.call("ReadMSR", SYSENTER_ESP).unwrap(); + + let mut target = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(dest_config), + ) + .unwrap() + .evolve() + .unwrap(); + target + .call::<()>("WriteMSR", (SYSENTER_ESP, baseline ^ 0x55)) + .unwrap(); + target.restore(snapshot).unwrap(); + assert_eq!( + target.call::("ReadMSR", SYSENTER_CS).unwrap(), + sentinel + ); + // An MSR the destination adds resets to its baseline. + assert_eq!( + target.call::("ReadMSR", SYSENTER_ESP).unwrap(), + baseline + ); + } + + /// A restore is rejected when the snapshot allows an MSR the + /// destination does not. Both restore paths fail and poison the + /// sandbox. + #[test] + fn snapshot_rejects_non_superset_allow_list() { + const SYSENTER_ESP: u32 = 0x175; + let mut source_config = SandboxConfiguration::default(); + source_config.allow_msrs(&[SYSENTER_CS]).unwrap(); + let mut source = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(source_config), + ) + .unwrap() + .evolve() + .unwrap(); + source + .call::<()>("WriteMSR", (SYSENTER_CS, 0x1234u64)) + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + // A destination that allows nothing, and one that allows a + // disjoint MSR, both fail the superset check. + for dest in [&[][..], &[SYSENTER_ESP][..]] { + let mut config = SandboxConfiguration::default(); + config.allow_msrs(dest).unwrap(); + + let err = MultiUseSandbox::from_snapshot( + snapshot.clone(), + HostFunctions::default(), + Some(config), + ) + .expect_err("from_snapshot must reject a non-superset allow list"); + assert_msr_not_allowed(&err); + + let mut target = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(config), + ) + .unwrap() + .evolve() + .unwrap(); + let err = target + .restore(snapshot.clone()) + .expect_err("restore must reject a non-superset allow list"); + assert_msr_not_allowed(&err); + assert!(target.poisoned()); + assert!(matches!( + target.call::("Echo", "hi".to_string()), + Err(HyperlightError::PoisonedSandbox) + )); + } + } + + #[test] + fn from_pre_init_snapshot_uses_local_msr_reset_set() { + let mut config = SandboxConfiguration::default(); + config.allow_msrs(&[KERNEL_GS_BASE]).unwrap(); + let snapshot = Arc::new( + Snapshot::from_env( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + config, + ) + .unwrap(), + ); + assert!(snapshot.msrs().is_none()); + + let mut sandbox = MultiUseSandbox::from_snapshot( + snapshot.clone(), + HostFunctions::default(), + Some(config), + ) + .unwrap(); + let baseline: u64 = sandbox.call("ReadMSR", KERNEL_GS_BASE).unwrap(); + sandbox + .call::<()>("WriteMSR", (KERNEL_GS_BASE, baseline ^ 0x55)) + .unwrap(); + assert_eq!( + sandbox.call::("ReadMSR", KERNEL_GS_BASE).unwrap(), + baseline ^ 0x55 + ); + } + + #[test] + #[cfg(kvm)] + fn guest_cannot_enable_x2apic_through_apic_base() { + use crate::hypervisor::regs::APIC_BASE_X2APIC_ENABLE; + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + if !matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return; + } + + const MSR_IA32_APIC_BASE: u32 = 0x1B; + const MSR_X2APIC_BASE: u32 = 0x800; + const APIC_BASE_DEFAULT: u64 = 0xFEE0_0900; + + let mut sandbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + let x2apic_base = APIC_BASE_DEFAULT | APIC_BASE_X2APIC_ENABLE; + let result = sandbox.call::<()>("WriteMSR", (MSR_IA32_APIC_BASE, x2apic_base)); + assert!( + matches!(result, Err(HyperlightError::GuestAborted(_, _))), + "guest enabled x2APIC through APIC_BASE: {result:?}" + ); + assert!(sandbox.poisoned()); + sandbox.restore(snapshot).unwrap(); + assert!(!sandbox.poisoned()); + + let result = sandbox.call::<()>("WriteMSR", (MSR_X2APIC_BASE, 1u64)); + assert!( + matches!(result, Err(HyperlightError::GuestAborted(_, _))), + "x2APIC MSR access succeeded after restore: {result:?}" + ); + assert!(sandbox.poisoned()); + } + + #[test] + #[cfg(kvm)] + fn denied_msr_access_poisons_sandbox() { + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + match get_available_hypervisor() { + Some(HypervisorType::Kvm) => {} + _ => { + return; + } + } + + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + let snapshot = sbox.snapshot().unwrap(); + let msr_index: u32 = 0xC000_0102; // IA32_KERNEL_GS_BASE + + let result = sbox.call::("ReadMSR", msr_index); + assert!( + matches!(&result, Err(HyperlightError::GuestAborted(_, _))), + "RDMSR 0x{:X}: expected direct #GP, got: {:?}", + msr_index, + result + ); + assert!(sbox.poisoned()); + + sbox.restore(snapshot.clone()).unwrap(); + + let result = sbox.call::<()>("WriteMSR", (msr_index, 0x5u64)); + assert!( + matches!(&result, Err(HyperlightError::GuestAborted(_, _))), + "WRMSR 0x{:X}: expected direct #GP, got: {:?}", + msr_index, + result + ); + assert!(sbox.poisoned()); + } + + #[test] + #[cfg(kvm)] + fn nested_virtualization_is_hidden_from_guest() { + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + if !matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return; + } + + let mut sandbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + let features: u32 = sandbox.call("NestedVirtualizationCpuid", ()).unwrap(); + assert_eq!(features & 0b11, 0, "guest CPUID exposes VMX or SVM"); + } + + #[test] + #[cfg(kvm)] + fn nested_vmx_setup_msrs_are_denied() { + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + if !matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return; + } + + let mut sandbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + let snapshot = sandbox.snapshot().unwrap(); + let vmx_basic: u32 = 0x480; + let result = sandbox.call::("ReadMSR", vmx_basic); + assert!( + matches!(result, Err(HyperlightError::GuestAborted(_, _))), + "RDMSR 0x{vmx_basic:X}: expected direct #GP, got: {result:?}" + ); + + sandbox.restore(snapshot).unwrap(); + let feature_control: u32 = 0x3A; + let result = sandbox.call::<()>("WriteMSR", (feature_control, 0x5u64)); + assert!( + matches!(result, Err(HyperlightError::GuestAborted(_, _))), + "WRMSR 0x{feature_control:X}: expected direct #GP, got: {result:?}" + ); + } + + /// A write-only command cannot enter the reset set. + #[test] + #[cfg(target_arch = "x86_64")] + fn test_allow_non_resettable_msr_fails_creation() { + let mut cfg = SandboxConfiguration::default(); + cfg.allow_msrs(&[0x49]).unwrap(); // IA32_PRED_CMD, a write-only command MSR + + let err = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(cfg), + ) + .unwrap() + .evolve() + .unwrap_err(); + + assert_msr_not_allowable(&err, 0x49); + } + + /// Host support cannot authorize an unclassified MSR. + #[test] + #[cfg(kvm)] + fn unclassified_allowed_msr_rejected_at_creation() { + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + if !matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return; + } + + let mut cfg = SandboxConfiguration::default(); + cfg.allow_msrs(&[0x1A0]).unwrap(); // IA32_MISC_ENABLE: host-probeable, not in MSR_TABLE + + let err = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(cfg), + ) + .unwrap() + .evolve() + .expect_err("an unclassified allowed MSR must be rejected at creation"); + + assert_msr_not_allowable(&err, 0x1A0); + } + + #[test] + #[cfg(target_arch = "x86_64")] + fn test_multiple_allowed_msrs_reset_across_restore() { + // Resettable MSRs the guest may write once allowed. + let msrs: [u32; 4] = [0x174, 0x175, 0x176, 0xC000_0102]; + let mut cfg = SandboxConfiguration::default(); + cfg.allow_msrs(&msrs).unwrap(); + + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(cfg), + ) + .unwrap() + .evolve() + .unwrap(); + + let baseline_snapshot = sbox.snapshot().unwrap(); + + let value: u64 = 0x1000; + for &msr in &msrs { + sbox.call::<()>("WriteMSR", (msr, value)).unwrap(); + let read_value: u64 = sbox.call("ReadMSR", msr).unwrap(); + assert_eq!(read_value, value, "MSR 0x{msr:X} should be writable"); + } + + sbox.restore(baseline_snapshot).unwrap(); + for &msr in &msrs { + let read_value: u64 = sbox.call("ReadMSR", msr).unwrap(); + assert_ne!( + read_value, value, + "MSR 0x{msr:X} should be reset to baseline across restore" + ); + } + } + + /// An allowed guest write must not survive restore. + #[test] + #[cfg(target_arch = "x86_64")] + fn test_allowed_msr_does_not_leak_across_restore() { + let msr_index: u32 = 0xC000_0102; // IA32_KERNEL_GS_BASE + let sentinel: u64 = 0xCAFE_F00D; + + let mut cfg = SandboxConfiguration::default(); + cfg.allow_msrs(&[msr_index]).unwrap(); + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(cfg), + ) + .unwrap() + .evolve() + .unwrap(); + + let baseline = sbox.snapshot().unwrap(); + let original: u64 = sbox.call("ReadMSR", msr_index).unwrap(); + assert_ne!( + original, sentinel, + "test sentinel must differ from the baseline value" + ); + + sbox.call::<()>("WriteMSR", (msr_index, sentinel)).unwrap(); + assert_eq!( + sbox.call::("ReadMSR", msr_index).unwrap(), + sentinel, + "sentinel should be observable before restore" + ); + sbox.restore(baseline).unwrap(); + + let after: u64 = sbox.call("ReadMSR", msr_index).unwrap(); + assert_ne!(after, sentinel, "sentinel leaked across restore"); + assert_eq!(after, original, "MSR not reset to its baseline value"); + } + + /// KVM denies DEBUGCTL through its filter and x2APIC through xAPIC mode. + #[test] + #[cfg(all(kvm, target_arch = "x86_64"))] + fn test_debugctl_and_x2apic_msr_denied_by_default() { + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + if !matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return; + } + + for msr_index in [0x1D9_u32, 0x800] { + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + let result = sbox.call::<()>("WriteMSR", (msr_index, 0x1u64)); + assert!( + matches!(&result, Err(HyperlightError::GuestAborted(_, _))), + "WRMSR 0x{msr_index:X}: expected direct #GP, got: {result:?}" + ); + assert!( + sbox.poisoned(), + "sandbox should be poisoned after a denied WRMSR to 0x{msr_index:X}" + ); + } + } + + #[test] + #[cfg(all(kvm, target_arch = "x86_64"))] + fn all_kvm_custom_msrs_are_denied() { + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + if !matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return; + } + + const KVM_CUSTOM_MSR_START: u32 = 0x4B56_4D00; + const KVM_CUSTOM_MSR_END: u32 = 0x4B56_4DFF; + + let mut sandbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + for index in KVM_CUSTOM_MSR_START..=KVM_CUSTOM_MSR_END { + let result = sandbox.call::("ReadMSR", index); + assert!( + matches!(result, Err(HyperlightError::GuestAborted(_, _))), + "RDMSR {index:#x} was not denied: {result:?}" + ); + sandbox.restore(snapshot.clone()).unwrap(); + + let result = sandbox.call::<()>("WriteMSR", (index, 1u64)); + assert!( + matches!(result, Err(HyperlightError::GuestAborted(_, _))), + "WRMSR {index:#x} was not denied: {result:?}" + ); + sandbox.restore(snapshot.clone()).unwrap(); + } + } + + /// Unresettable feature-class MSRs must not retain guest writes. PMU, + /// LBR, and FRED are perfmon or feature gated. The AMD virtualization + /// MSRs are gated on nested-virt capability the sandbox never requests. + #[test] + #[cfg(target_arch = "x86_64")] + fn unresettable_msr_classes_do_not_leak() { + let cases: &[(u32, &str)] = &[ + (0xC1, "PMU IA32_PMC0"), + (0x186, "PMU IA32_PERFEVTSEL0"), + (0x38F, "PMU IA32_PERF_GLOBAL_CTRL"), + (0x1C8, "LBR_SELECT"), + (0x14CE, "arch-LBR IA32_LBR_CTL"), + (0x1D4, "FRED IA32_FRED_CONFIG"), + (0xC001_0114, "AMD VM_CR"), + (0xC001_0117, "AMD VM_HSAVE_PA"), + ]; + + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + for &(msr, _name) in cases { + assert_msr_write_does_not_survive_restore(&mut sbox, msr, 0x1); + } + } + + /// A guest write to IA32_MISC_ENABLE leaves no retained state. Hyper-V + /// drops the write on Intel and faults it on AMD. + #[test] + #[cfg(target_arch = "x86_64")] + fn misc_enable_guest_write_does_not_survive_restore() { + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + assert_msr_write_does_not_survive_restore(&mut sbox, 0x1A0, 1u64 << 40); + } + + /// Every stateful table entry needs runtime reset coverage. + #[test] + #[cfg(target_arch = "x86_64")] + fn runtime_msr_table_entries_are_justified() { + use crate::hypervisor::regs::resettable_msr_indices; + + #[cfg(kvm)] + let is_kvm = matches!( + crate::hypervisor::virtual_machine::get_available_hypervisor(), + Some(crate::hypervisor::virtual_machine::HypervisorType::Kvm) + ); + #[cfg(not(kvm))] + let is_kvm = false; + + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + let reset_indices: Vec = sbox + .snapshot() + .unwrap() + .msrs() + .expect("filterless backend should have an MSR reset set") + .iter() + .map(|entry| entry.index) + .collect(); + + for index in resettable_msr_indices() { + if !reset_indices.contains(&index) { + assert_omitted_msr_does_not_retain(&mut sbox, index); + } else if is_kvm && index == KERNEL_GS_BASE { + // Direct WRMSR is denied. The dedicated SWAPGS test proves + // the instruction-side mutation is restored. + } else if (index == MSR_TSC && !is_kvm) || matches!(index, MSR_MPERF | MSR_APERF) { + assert_guest_counter_is_writable_and_restored(&mut sbox, index); + } else if let Some(test_value) = guest_write_test_value(index) { + assert_guest_msr_is_writable_and_restored(&mut sbox, index, test_value); + } else { + assert!( + reset_exception_reason(index).is_some(), + "MSR 0x{index:X} is in the reset set without positive guest-write coverage or an explicit reason" + ); + } + } + } + + fn assert_omitted_msr_does_not_retain(sbox: &mut MultiUseSandbox, index: u32) { + let baseline = sbox.snapshot().unwrap(); + let original: u64 = match sbox.call("ReadMSR", index) { + Ok(value) => value, + Err(_) => { + assert!(sbox.poisoned(), "0x{index:X}: fault did not poison sandbox"); + sbox.restore(baseline).unwrap(); + return; + } + }; + let preferred = guest_write_test_value(index).unwrap_or(original ^ 1); + let candidates = [preferred, original ^ 1, original ^ 2, 0, 1, 0x1000]; + + for candidate in candidates { + if candidate == original { + continue; + } + if sbox.call::<()>("WriteMSR", (index, candidate)).is_err() { + assert!(sbox.poisoned(), "0x{index:X}: fault did not poison sandbox"); + sbox.restore(baseline.clone()).unwrap(); + continue; + } + let written: u64 = sbox.call("ReadMSR", index).unwrap_or_else(|error| { + panic!("0x{index:X}: read after successful write failed: {error:?}") + }); + if written != original { + sbox.restore(baseline).unwrap(); + let after: u64 = sbox.call("ReadMSR", index).unwrap(); + assert_eq!( + after, original, + "0x{index:X}: guest retained a write but the MSR is absent from the reset set" + ); + return; + } + sbox.restore(baseline.clone()).unwrap(); + } + } + + fn guest_write_test_value(index: u32) -> Option { + match index { + SYSENTER_CS => Some(0x10), + MSR_SYSENTER_ESP | MSR_SYSENTER_EIP => Some(0x1000), + MSR_PAT => Some(0x0007_0406_0007_0406), + MSR_STAR => Some(0x001B_0008_0000_0000), + MSR_LSTAR | MSR_CSTAR => Some(0x1000), + MSR_SFMASK => Some(0x200), + KERNEL_GS_BASE => Some(0x1000), + MSR_TSC_ADJUST => Some(0x1000), + MSR_TSC_AUX => Some(0x5), + MSR_MTRR_DEF_TYPE => Some(0xC00), + 0x200..=0x21F if index & 1 == 0 => Some(0x6), // MTRR_PHYSBASEn + 0x200..=0x21F => Some(0x800), // MTRR_PHYSMASKn + MSR_MTRR_FIX64K_00000 | 0x258 | 0x259 | 0x268..=0x26F => { + Some(0x0606_0606_0606_0606) + } + _ => None, + } + } + + fn reset_exception_reason(index: u32) -> Option<&'static str> { + match index { + MSR_TSC => Some("KVM denies direct guest TSC MSR access"), + MSR_IA32_SSP => Some( + "active SSP has no architectural RDMSR/WRMSR; covered by active_ssp_does_not_leak_across_restore", + ), + MSR_DEBUGCTL => Some("DEBUGCTL support depends on exposed debug features"), + MSR_SPEC_CTRL => Some("SPEC_CTRL writable bits depend on mitigation features"), + MSR_U_CET + | MSR_S_CET + | MSR_PL0_SSP + | MSR_PL1_SSP + | MSR_PL2_SSP + | MSR_PL3_SSP + | MSR_INTERRUPT_SSP_TABLE_ADDR => { + Some("CET writable state depends on exposed CET features") + } + MSR_TSX_CTRL => Some("TSX_CTRL writable bits depend on exposed TSX features"), + MSR_XFD | MSR_XFD_ERR => Some("XFD writable bits depend on exposed XSAVE features"), + MSR_UMWAIT_CONTROL => { + Some("UMWAIT_CONTROL writable bits depend on exposed WAITPKG features") + } + MSR_TSC_DEADLINE => { + Some("TSC_DEADLINE writable bits depend on exposed APIC-timer features") + } + MSR_BNDCFGS => Some("BNDCFGS writable bits depend on exposed MPX features"), + MSR_XSS => Some("XSS writable bits depend on exposed XSAVE features"), + MSR_VIRT_SPEC_CTRL => { + Some("VIRT_SPEC_CTRL writable bits depend on exposed AMD SSBD virtualization") + } + _ => None, + } + } + + fn assert_guest_msr_is_writable_and_restored( + sbox: &mut MultiUseSandbox, + index: u32, + sentinel: u64, + ) { + let baseline = sbox.snapshot().unwrap(); + let original: u64 = sbox + .call("ReadMSR", index) + .unwrap_or_else(|error| panic!("0x{index:X}: guest RDMSR failed: {error:?}")); + let value = if original == sentinel { 0 } else { sentinel }; + + sbox.call::<()>("WriteMSR", (index, value)) + .unwrap_or_else(|error| panic!("0x{index:X}: guest WRMSR failed: {error:?}")); + let written: u64 = sbox + .call("ReadMSR", index) + .unwrap_or_else(|error| panic!("0x{index:X}: guest read-back failed: {error:?}")); + assert_eq!(written, value, "0x{index:X}: guest write did not stick"); + + sbox.restore(baseline).unwrap(); + let restored: u64 = sbox.call("ReadMSR", index).unwrap(); + assert_eq!( + restored, original, + "0x{index:X}: restore did not recover the baseline" + ); + } + + fn assert_guest_counter_is_writable_and_restored(sbox: &mut MultiUseSandbox, index: u32) { + let baseline = sbox.snapshot().unwrap(); + let original: u64 = sbox.call("ReadMSR", index).unwrap(); + let jump = original.wrapping_add(1 << 60); + + sbox.call::<()>("WriteMSR", (index, jump)).unwrap(); + let written: u64 = sbox.call("ReadMSR", index).unwrap(); + assert!( + written >= jump / 2, + "0x{index:X}: guest write did not stick" + ); + + sbox.restore(baseline).unwrap(); + let restored: u64 = sbox.call("ReadMSR", index).unwrap(); + assert!( + restored < jump / 2, + "0x{index:X}: restore did not pull the counter below the guest-written jump" + ); + } + + /// Verifies that a guest MSR write faults or resets to its baseline. + #[cfg(target_arch = "x86_64")] + fn assert_msr_write_does_not_survive_restore( + sbox: &mut MultiUseSandbox, + msr: u32, + sentinel: u64, + ) { + let baseline = sbox.snapshot().unwrap(); + let original: u64 = match sbox.call("ReadMSR", msr) { + Ok(v) => v, + Err(_) => { + assert!( + sbox.poisoned(), + "0x{msr:X}: a faulting RDMSR should poison the sandbox" + ); + sbox.restore(baseline).unwrap(); + return; + } + }; + assert_ne!( + original, sentinel, + "0x{msr:X}: sentinel must differ from baseline" + ); + + if sbox.call::<()>("WriteMSR", (msr, sentinel)).is_err() { + assert!( + sbox.poisoned(), + "0x{msr:X}: a faulting WRMSR should poison the sandbox" + ); + sbox.restore(baseline).unwrap(); + return; + } + + sbox.restore(baseline).unwrap(); + let after: u64 = sbox.call("ReadMSR", msr).unwrap(); + assert_eq!( + after, original, + "0x{msr:X}: MSR leaked across restore (expected 0x{original:X}, got 0x{after:X})" + ); + } + + /// Audits Hyper-V MSR bitmap ranges for guest state retained by restore. + #[test] + #[ignore = "slow host-dependent hardware MSR audit"] + #[cfg(target_arch = "x86_64")] + fn test_no_msr_leaks_across_restore_full_window_sweep() { + // Free-running counters use a magnitude check after restore. + const FREE_RUNNING: &[u32] = &[ + 0x10, // IA32_TIME_STAMP_COUNTER + 0xE7, // IA32_MPERF + 0xE8, // IA32_APERF + ]; + + #[cfg(kvm)] + if matches!( + crate::hypervisor::virtual_machine::get_available_hypervisor(), + Some(crate::hypervisor::virtual_machine::HypervisorType::Kvm) + ) { + return; + } + + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + let baseline = sbox.snapshot().unwrap(); + + // At least one retained write must exercise restore. + let mut readable = 0usize; + let mut exercised: Vec = Vec::new(); + let mut read_only: Vec = Vec::new(); + let mut masked_only: Vec = Vec::new(); + // Collect all free-running leaks for one diagnostic. + let mut free_running_leaked: Vec = Vec::new(); + + // Architectural and low model-specific indices. + let low = 0x0000_0000u32..=0x0000_1FFF; + // Hyper-V synthetic indices. + let hyperv_synthetic = 0x4000_0000u32..=0x4000_1FFF; + // Extended and AMD model-specific indices. + let extended = 0xC000_0000u32..=0xC001_FFFF; + let windows = low.chain(hyperv_synthetic).chain(extended); + for msr in windows { + let original: u64 = match sbox.call("ReadMSR", msr) { + Ok(v) => v, + Err(_) => { + sbox.restore(baseline.clone()).unwrap(); + continue; + } + }; + readable += 1; + + // A large jump distinguishes reset from normal counter progress. + if FREE_RUNNING.contains(&msr) { + let jump = original.wrapping_add(1 << 60); + if sbox.call::<()>("WriteMSR", (msr, jump)).is_err() { + sbox.restore(baseline.clone()).unwrap(); + read_only.push(msr); + continue; + } + let planted = match sbox.call::("ReadMSR", msr) { + Ok(v) => v, + Err(_) => { + sbox.restore(baseline.clone()).unwrap(); + masked_only.push(msr); + continue; + } + }; + if planted < jump / 2 { + sbox.restore(baseline.clone()).unwrap(); + masked_only.push(msr); + continue; + } + sbox.restore(baseline.clone()).unwrap(); + let after: u64 = sbox.call("ReadMSR", msr).unwrap(); + if after < jump / 2 { + exercised.push(msr); + } else { + free_running_leaked.push(msr); + } + continue; + } + + // Multiple candidates cover MSRs with restricted writable bits. + let candidates = [ + original ^ 0x55, + original ^ 0x1, + original ^ (1 << 12), + original ^ (1 << 20), + original ^ (1 << 32), + original.wrapping_add(1), + 0, + ]; + let mut planted = false; + let mut saw_write = false; + for cand in candidates { + if cand == original { + continue; + } + if sbox.call::<()>("WriteMSR", (msr, cand)).is_err() { + sbox.restore(baseline.clone()).unwrap(); + continue; + } + saw_write = true; + match sbox.call::("ReadMSR", msr) { + Ok(v) if v != original => { + planted = true; + break; + } + _ => { + sbox.restore(baseline.clone()).unwrap(); + } + } + } + + if planted { + sbox.restore(baseline.clone()).unwrap(); + match sbox.call::("ReadMSR", msr) { + Ok(after) => assert_eq!( + after, original, + "0x{msr:X}: a guest MSR write leaked across restore \ + (expected 0x{original:X}, got 0x{after:X})" + ), + Err(e) => panic!("0x{msr:X}: read-back after restore failed: {e:?}"), + } + exercised.push(msr); + } else if saw_write { + masked_only.push(msr); + } else { + read_only.push(msr); + } + } + + let fmt = |v: &[u32]| { + v.iter() + .map(|m| format!("0x{m:X}")) + .collect::>() + .join(", ") + }; + eprintln!( + "full-window MSR sweep: readable={readable} exercised={} masked_only={} read_only={}", + exercised.len(), + masked_only.len(), + read_only.len() + ); + eprintln!(" exercised: [{}]", fmt(&exercised)); + eprintln!(" masked_only: [{}]", fmt(&masked_only)); + eprintln!(" read_only: [{}]", fmt(&read_only)); + eprintln!(" free_running_leaked: [{}]", fmt(&free_running_leaked)); + assert!( + free_running_leaked.is_empty(), + "free-running MSRs not reset across restore on this backend: [{}]", + fmt(&free_running_leaked) + ); + assert!( + !exercised.is_empty(), + "sweep was vacuous: no guest MSR write ever retained a value that restore \ + then rolled back, so the rollback path was never exercised" + ); + } + + /// Active SSP is guest-writable state the Hyper-V backends reset + /// across restore. Skips where the guest cannot use CET shadow + /// stacks, which includes every KVM host. + #[test] + #[cfg(all(any(mshv3, target_os = "windows"), target_arch = "x86_64"))] + fn active_ssp_does_not_leak_across_restore() { + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + + if !sbox.call::("CetShadowStackSupported", ()).unwrap() { + return; + } + + let baseline = sbox.snapshot().unwrap(); + let original: u64 = sbox.call("ReadActiveSsp", ()).unwrap(); + + let mutated: u64 = sbox.call("IncrementActiveSsp", ()).unwrap(); + assert_ne!(mutated, original, "guest did not change active SSP"); + let seen: u64 = sbox.call("ReadActiveSsp", ()).unwrap(); + assert_eq!(seen, mutated, "guest did not observe its own SSP mutation"); + + sbox.restore(baseline).unwrap(); + + let after: u64 = sbox.call("ReadActiveSsp", ()).unwrap(); + assert_eq!( + after, original, + "active SSP leaked across restore (original=0x{original:X}, mutated=0x{mutated:X}, after=0x{after:X})" + ); + } + + /// Hyperlight hides CET from KVM guests, so shadow stacks cannot be + /// enabled and active SSP cannot be moved. Active SSP has no + /// architectural MSR, so it is absent from the KVM reset set and the + /// backend never restores it. Hiding CET keeps that gap unreachable. + #[test] + #[cfg(all(kvm, target_arch = "x86_64"))] + fn kvm_does_not_expose_cet_to_guest() { + use crate::hypervisor::virtual_machine::{HypervisorType, get_available_hypervisor}; + + if !matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return; + } + + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + None, + ) + .unwrap() + .evolve() + .unwrap(); + assert!( + !sbox.call::("CetShadowStackSupported", ()).unwrap(), + "KVM guest CPUID exposes CET shadow stacks" + ); + + // With CET hidden the host cannot read or write IA32_S_CET, so + // allowing it is rejected at VM creation. + let mut cfg = SandboxConfiguration::default(); + cfg.allow_msrs(&[MSR_S_CET]).unwrap(); + let err = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().expect("Guest Binary Missing")), + Some(cfg), + ) + .unwrap() + .evolve() + .expect_err("allowing IA32_S_CET must be rejected when CET is hidden"); + assert_msr_not_allowable(&err, MSR_S_CET); + } + } + /// Tests for [`MultiUseSandbox::from_snapshot`] in-memory. mod from_snapshot { use std::sync::Arc; diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs index fef71263a..4d918302d 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -21,6 +21,8 @@ use serde::{Deserialize, Serialize}; use super::media_types::SNAPSHOT_ABI_VERSION; use crate::hypervisor::regs::CommonSpecialRegisters; +#[cfg(target_arch = "x86_64")] +use crate::hypervisor::regs::MsrEntry; use crate::mem::layout::SandboxMemoryLayout; // --- Arch and hypervisor identifiers -------------------------------- @@ -188,6 +190,12 @@ pub(super) struct OciSnapshotConfig { /// Special registers captured from the paused vCPU, restored /// verbatim when resuming the call. pub(super) sregs: CommonSpecialRegisters, + /// Complete MSR reset state and capture-time guest access policy. + /// `msrs` contains every reset value. `allowed_msrs` is the policy subset. + #[cfg(target_arch = "x86_64")] + // Legacy configs omit this field. Empty state restores the destination baseline. + #[serde(default)] + pub(super) msr_state: OciSnapshotMsrState, pub(super) layout: MemoryLayout, /// Total size of the memory blob in bytes (including the guest /// page-table tail, if any). Equal to `self.memory.mem_size()`. @@ -202,6 +210,14 @@ pub(super) struct OciSnapshotConfig { pub(super) snapshot_generation: u64, } +#[cfg(target_arch = "x86_64")] +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct OciSnapshotMsrState { + pub(super) msrs: Vec, + pub(super) allowed_msrs: Vec, +} + /// Sizes and permissions of the regions inside the snapshot blob, /// enough for the loader to rebuild a `SandboxMemoryLayout`. #[derive(Serialize, Deserialize)] @@ -636,6 +652,47 @@ mod tests { assert_eq!(restored, expected); } + /// Captured MSRs survive the serde round-trip through the config, + /// including index and value for every entry. + #[cfg(target_arch = "x86_64")] + #[test] + fn msrs_round_trip_preserves_every_entry() { + let original = gating_config_with_msrs(Some(vec![ + MsrEntry { + index: 0xC000_0102, + value: 0xDEAD_BEEF, + }, + MsrEntry { + index: 0x10, + value: 0x1234_5678_9ABC_DEF0, + }, + ])); + let json = serde_json::to_vec(&original).unwrap(); + let restored: OciSnapshotConfig = serde_json::from_slice(&json).unwrap(); + assert_eq!(restored.msr_state.msrs, original.msr_state.msrs); + assert_eq!( + restored.msr_state.allowed_msrs, + original.msr_state.allowed_msrs + ); + } + + /// A config JSON with no MSR state deserializes empty state. + #[cfg(target_arch = "x86_64")] + #[test] + fn config_without_msr_state_deserializes_to_empty_state() { + let with = gating_config_with_msrs(Some(vec![MsrEntry { + index: 0x10, + value: 1, + }])); + let mut json: serde_json::Value = + serde_json::from_slice(&serde_json::to_vec(&with).unwrap()).unwrap(); + assert!(json.as_object_mut().unwrap().remove("msr_state").is_some()); + + let restored: OciSnapshotConfig = serde_json::from_value(json).unwrap(); + assert!(restored.msr_state.msrs.is_empty()); + assert!(restored.msr_state.allowed_msrs.is_empty()); + } + /// Every `ParameterType` survives the round-trip through its serde /// mirror, guarding against a transposed variant in either match. #[test] @@ -721,6 +778,8 @@ mod tests { entrypoint_addr: SandboxMemoryLayout::BASE_ADDRESS as u64, original_entrypoint_addr: 0, sregs: distinct_sregs(), + #[cfg(target_arch = "x86_64")] + msr_state: OciSnapshotMsrState::default(), layout: MemoryLayout { input_data_size: 0, output_data_size: 0, @@ -738,6 +797,18 @@ mod tests { } } + /// `gating_config` with a chosen MSR set, for serde tests. + #[cfg(target_arch = "x86_64")] + fn gating_config_with_msrs(msrs: Option>) -> OciSnapshotConfig { + OciSnapshotConfig { + msr_state: OciSnapshotMsrState { + msrs: msrs.unwrap_or_default(), + allowed_msrs: Vec::new(), + }, + ..gating_config() + } + } + /// A snapshot built for a different architecture is rejected. #[test] fn validate_for_load_rejects_arch_mismatch() { @@ -934,6 +1005,22 @@ mod schema_pin { 11 ] }, + "msr_state": { + "msrs": [ + { + "index": 16, + "value": 42 + }, + { + "index": 3221225474, + "value": 3735928559 + } + ], + "allowed_msrs": [ + 16, + 3221225474 + ] + }, "layout": { "input_data_size": 1, "output_data_size": 2, @@ -1012,12 +1099,15 @@ mod schema_pin { ]"#; fn assert_round_trip(pinned: &str) { + let pinned_value: serde_json::Value = + serde_json::from_str(pinned).expect("pinned JSON must deserialize as a value"); let parsed: OciSnapshotConfig = - serde_json::from_str(pinned).expect("pinned JSON must deserialize"); + serde_json::from_value(pinned_value.clone()).expect("pinned JSON must deserialize"); let actual = serde_json::to_string_pretty(&parsed).expect("serialize"); + let actual_value: serde_json::Value = + serde_json::from_str(&actual).expect("serialized config must deserialize"); assert_eq!( - actual.trim(), - pinned.trim(), + actual_value, pinned_value, "Snapshot config JSON schema changed. If the change can break \ existing snapshots on disk, bump `MT_CONFIG_V1` in \ `super::media_types` and follow `docs/snapshot-versioning.md`. \ diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs index 7ea72d919..4920826cc 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs @@ -32,6 +32,8 @@ use oci_spec::image::{ ImageManifestBuilder, MediaType, SCHEMA_VERSION, }; +#[cfg(target_arch = "x86_64")] +use self::config::OciSnapshotMsrState; use self::config::{Arch, CpuVendor, HostFunction, Hypervisor, MemoryLayout, OciSnapshotConfig}; use self::digest::{Digest256, oci_digest, parse_oci_digest, verify_blob_bytes, verify_blob_file}; use self::fsutil::{put_blob, put_blob_if_absent, read_bounded, replace_file_atomic}; @@ -42,6 +44,8 @@ pub(super) use self::media_types::{ MT_CONFIG_CURRENT, MT_CONFIG_V1, MT_SNAPSHOT_CURRENT, MT_SNAPSHOT_V1, SNAPSHOT_ABI_VERSION, }; use self::reference::{OciDigest, OciReference, OciTag}; +#[cfg(target_arch = "x86_64")] +use super::SnapshotMsrState; use super::{NextAction, Snapshot}; use crate::mem::layout::SandboxMemoryLayout; use crate::mem::memory_region::MemoryRegionFlags; @@ -611,6 +615,17 @@ impl Snapshot { entrypoint_addr, original_entrypoint_addr: self.original_entrypoint, sregs: *sregs, + #[cfg(target_arch = "x86_64")] + msr_state: { + let state = self + .msr_state + .as_ref() + .ok_or_else(|| crate::new_error!("snapshot has no MSR state"))?; + OciSnapshotMsrState { + msrs: state.msrs.clone(), + allowed_msrs: state.allowed_msrs.clone(), + } + }, layout: MemoryLayout { input_data_size: l.input_data_size(), output_data_size: l.output_data_size(), @@ -897,6 +912,11 @@ impl Snapshot { load_info: crate::mem::exe::LoadInfo::dummy(), stack_top_gva: cfg.stack_top_gva, sregs: Some(cfg.sregs), + #[cfg(target_arch = "x86_64")] + msr_state: Some(SnapshotMsrState::new( + cfg.msr_state.msrs, + cfg.msr_state.allowed_msrs, + )), next_action, original_entrypoint: cfg.original_entrypoint_addr, snapshot_generation, diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs index e577b0d77..2152da41b 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -208,6 +208,200 @@ fn snapshot_generation_round_trip() { assert_eq!(loaded3.snapshot_generation(), gen3); } +/// A disk round-trip preserves captured MSR reset state. +#[cfg(target_arch = "x86_64")] +#[test] +fn msrs_round_trip_via_disk() { + let snap = create_snapshot(); + let original = snap.msrs().cloned(); + assert!( + original.as_ref().is_some_and(|m| !m.is_empty()), + "a running snapshot should capture a non-empty MSR reset set" + ); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.save(&path, &OciTag::new("latest").unwrap()).unwrap(); + + let loaded = Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap(); + assert_eq!(loaded.msrs(), original.as_ref()); + // The allow list travels with the MSRs and is Some exactly when they are. + assert_eq!(loaded.allowed_msrs(), Some(&[][..])); +} + +/// A config with no `msr_state` key loads and uses the destination reset set. +#[cfg(target_arch = "x86_64")] +#[test] +fn snapshot_without_msrs_key_loads_and_runs() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let obj = cfg.as_object_mut().unwrap(); + assert!( + obj.remove("msr_state").is_some(), + "a running x86_64 snapshot config should carry msr_state to remove" + ); + }); + + let loaded = Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap(); + assert_eq!(loaded.msrs(), Some(&Vec::new())); + assert_eq!(loaded.allowed_msrs(), Some(&[][..])); + + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); +} + +/// A snapshot whose reset set includes an allowed MSR carries that +/// MSR's guest-written value across a disk round-trip. Loading with a +/// matching allow config restores the value, and restore-in-place +/// returns it after an overwrite. +#[cfg(target_arch = "x86_64")] +#[test] +fn disk_snapshot_restores_allowed_msr_value() { + const SYSENTER_CS: u32 = 0x174; + let sentinel: u64 = 0xDEAD_BEEF; + + let mut cfg = crate::sandbox::SandboxConfiguration::default(); + cfg.allow_msrs(&[SYSENTER_CS]).unwrap(); + let mut source = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().unwrap()), + Some(cfg), + ) + .unwrap() + .evolve() + .unwrap(); + source + .call::<()>("WriteMSR", (SYSENTER_CS, sentinel)) + .unwrap(); + let snap = source.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.save(&path, &OciTag::new("latest").unwrap()).unwrap(); + let loaded = Arc::new(Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap()); + + let mut cfg = crate::sandbox::SandboxConfiguration::default(); + cfg.allow_msrs(&[SYSENTER_CS]).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), Some(cfg)) + .unwrap(); + assert_eq!(sbox.call::("ReadMSR", SYSENTER_CS).unwrap(), sentinel); + + sbox.call::<()>("WriteMSR", (SYSENTER_CS, sentinel ^ 0x55)) + .unwrap(); + sbox.restore(loaded).unwrap(); + assert_eq!(sbox.call::("ReadMSR", SYSENTER_CS).unwrap(), sentinel); +} + +/// A disk snapshot restores into a destination with a superset allow list. +#[cfg(target_arch = "x86_64")] +#[test] +fn disk_snapshot_restores_into_superset_allow_list() { + const SYSENTER_CS: u32 = 0x174; + const SYSENTER_ESP: u32 = 0x175; + let sentinel: u64 = 0xDEAD_BEEF; + + let mut cfg = crate::sandbox::SandboxConfiguration::default(); + cfg.allow_msrs(&[SYSENTER_CS]).unwrap(); + let mut source = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().unwrap()), + Some(cfg), + ) + .unwrap() + .evolve() + .unwrap(); + source + .call::<()>("WriteMSR", (SYSENTER_CS, sentinel)) + .unwrap(); + let snap = source.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.save(&path, &OciTag::new("latest").unwrap()).unwrap(); + let loaded = Arc::new(Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap()); + + let mut dest_cfg = crate::sandbox::SandboxConfiguration::default(); + dest_cfg.allow_msrs(&[SYSENTER_CS, SYSENTER_ESP]).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(loaded, HostFunctions::default(), Some(dest_cfg)).unwrap(); + assert_eq!(sbox.call::("ReadMSR", SYSENTER_CS).unwrap(), sentinel); +} + +/// A disk snapshot is rejected when it allows an MSR the destination does not. +#[cfg(target_arch = "x86_64")] +#[test] +fn disk_snapshot_non_superset_allow_list_rejected() { + const SYSENTER_CS: u32 = 0x174; + let sentinel: u64 = 0x1234; + + let mut cfg = crate::sandbox::SandboxConfiguration::default(); + cfg.allow_msrs(&[SYSENTER_CS]).unwrap(); + let mut source = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().unwrap()), + Some(cfg), + ) + .unwrap() + .evolve() + .unwrap(); + source + .call::<()>("WriteMSR", (SYSENTER_CS, sentinel)) + .unwrap(); + let snap = source.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.save(&path, &OciTag::new("latest").unwrap()).unwrap(); + let loaded = Arc::new(Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap()); + + // The destination allows nothing, so the snapshot's allowed MSR is not + // a subset of the destination allow list. + let err = MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None) + .expect_err("loading into a non-superset allow list must fail"); + assert!( + format!("{err:?}").contains("SnapshotMsrNotAllowed"), + "expected an MSR allow-list mismatch, got: {err:?}" + ); + + let mut target = create_test_sandbox(); + let err = target + .restore(loaded) + .expect_err("restore into a non-superset allow list must fail"); + assert!( + format!("{err:?}").contains("SnapshotMsrNotAllowed"), + "expected an MSR allow-list mismatch, got: {err:?}" + ); + assert!(target.poisoned()); +} + +/// An MSR state without `allowed_msrs` cannot enforce the allow-list check. +#[cfg(target_arch = "x86_64")] +#[test] +fn disk_snapshot_msrs_without_allow_list_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + assert!( + cfg.as_object_mut() + .unwrap() + .get_mut("msr_state") + .unwrap() + .as_object_mut() + .unwrap() + .remove("allowed_msrs") + .is_some(), + "a running x86_64 snapshot config should carry allowed_msrs to remove" + ); + }); + + let err = unwrap_err_snapshot(Snapshot::checked_load( + &path, + OciTag::new("latest").unwrap(), + )); + assert!( + format!("{err:?}").contains("allowed_msrs"), + "expected a missing allowed_msrs error, got: {err:?}" + ); +} + #[test] fn pre_init_snapshot_cannot_be_persisted() { let snap = Snapshot::from_env( diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index c062efad7..bff08ec71 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -32,6 +32,8 @@ use tracing::{Span, instrument}; use crate::Result; use crate::hypervisor::regs::CommonSpecialRegisters; +#[cfg(target_arch = "x86_64")] +use crate::hypervisor::regs::MsrEntry; use crate::mem::exe::{ExeInfo, LoadInfo}; use crate::mem::layout::SandboxMemoryLayout; use crate::mem::memory_region::{GuestMemoryRegion, MemoryRegion, MemoryRegionFlags}; @@ -65,6 +67,20 @@ pub enum NextAction { None, } +#[cfg(target_arch = "x86_64")] +#[derive(Clone)] +pub(crate) struct SnapshotMsrState { + msrs: Vec, + allowed_msrs: Vec, +} + +#[cfg(target_arch = "x86_64")] +impl SnapshotMsrState { + pub(crate) fn new(msrs: Vec, allowed_msrs: Vec) -> Self { + Self { msrs, allowed_msrs } + } +} + /// A wrapper around a `SharedMemory` reference and a snapshot /// of the memory therein pub struct Snapshot { @@ -92,6 +108,10 @@ pub struct Snapshot { /// tables are relocated during snapshot. sregs: Option, + /// MSR reset state and capture-time allow list from a running vCPU. + #[cfg(target_arch = "x86_64")] + msr_state: Option, + /// The next action that should be performed on this snapshot next_action: NextAction, @@ -392,6 +412,8 @@ impl Snapshot { load_info, stack_top_gva: exn_stack_top_gva, sregs: None, + #[cfg(target_arch = "x86_64")] + msr_state: None, next_action: NextAction::Initialise(entrypoint_gva), original_entrypoint: entrypoint_gva, snapshot_generation: 0, @@ -419,6 +441,7 @@ impl Snapshot { root_pt_gpas: &[u64], stack_top_gva: u64, sregs: CommonSpecialRegisters, + #[cfg(target_arch = "x86_64")] msr_state: SnapshotMsrState, next_action: NextAction, original_entrypoint: u64, snapshot_generation: u64, @@ -573,6 +596,8 @@ impl Snapshot { load_info, stack_top_gva, sregs: Some(sregs), + #[cfg(target_arch = "x86_64")] + msr_state: Some(msr_state), next_action, original_entrypoint, snapshot_generation, @@ -617,6 +642,20 @@ impl Snapshot { self.sregs.as_ref() } + /// Returns the captured MSR reset state. + #[cfg(target_arch = "x86_64")] + pub(crate) fn msrs(&self) -> Option<&Vec> { + self.msr_state.as_ref().map(|state| &state.msrs) + } + + /// Returns the capturing sandbox's allow list. + #[cfg(target_arch = "x86_64")] + pub(crate) fn allowed_msrs(&self) -> Option<&[u32]> { + self.msr_state + .as_ref() + .map(|state| state.allowed_msrs.as_slice()) + } + pub(crate) fn next_action(&self) -> NextAction { self.next_action } @@ -782,6 +821,8 @@ mod tests { &[pt_base], 0, default_sregs(), + #[cfg(target_arch = "x86_64")] + super::SnapshotMsrState::new(Vec::new(), Vec::new()), super::NextAction::None, 0, 1, @@ -800,6 +841,8 @@ mod tests { &[pt_base], 0, default_sregs(), + #[cfg(target_arch = "x86_64")] + super::SnapshotMsrState::new(Vec::new(), Vec::new()), super::NextAction::None, 0, 2, diff --git a/src/tests/rust_guests/simpleguest/src/main.rs b/src/tests/rust_guests/simpleguest/src/main.rs index 2da2eb7ca..677286d91 100644 --- a/src/tests/rust_guests/simpleguest/src/main.rs +++ b/src/tests/rust_guests/simpleguest/src/main.rs @@ -1095,6 +1095,221 @@ fn call_host_expect_error(hostfuncname: String) -> Result<()> { Ok(()) } +#[guest_function("ReadMSR")] +#[cfg(target_arch = "x86_64")] +fn read_msr(msr: u32) -> u64 { + let (read_eax, read_edx): (u32, u32); + // SAFETY: The test guest runs at CPL0, and the operands declare every register used. + unsafe { + core::arch::asm!( + "rdmsr", + in("ecx") msr, + out("eax") read_eax, + out("edx") read_edx, + options(nostack, nomem) + ); + } + ((read_edx as u64) << 32) | (read_eax as u64) +} + +#[guest_function("IncrementActiveSsp")] +#[cfg(target_arch = "x86_64")] +fn increment_active_ssp() -> u64 { + const MSR_IA32_S_CET: u32 = 0x6A2; + const CR4_CET: u64 = 1 << 23; + + let shadow_stack_gpa = unsafe { hyperlight_guest::prim_alloc::alloc_phys_pages(1) }; + let shadow_stack = hyperlight_guest_bin::paging::phys_to_virt(shadow_stack_gpa) + .expect("allocated shadow stack is outside scratch"); + let restore_token = (shadow_stack as u64 + 8) | 1; + unsafe { + core::ptr::write_volatile(shadow_stack.cast::(), restore_token); + hyperlight_guest_bin::paging::map_region( + shadow_stack_gpa, + shadow_stack, + hyperlight_common::vmem::PAGE_SIZE as u64, + MappingKind::Basic(BasicMapping { + readable: true, + writable: false, + executable: false, + }), + ); + core::arch::asm!("invlpg [{shadow_stack}]", shadow_stack = in(reg) shadow_stack); + } + + let original_s_cet = read_msr(MSR_IA32_S_CET); + let original_s_cet_low = original_s_cet as u32; + let original_s_cet_high = (original_s_cet >> 32) as u32; + let after: u64; + + // SAFETY: The test guest runs at CPL0. The restore token is on a read-only, dirty page, + // and CET is disabled before the function returns. + unsafe { + core::arch::asm!( + "mov {original_cr4}, cr4", + "mov {cet_cr4}, {original_cr4}", + "or {cet_cr4}, {cr4_cet}", + "mov cr4, {cet_cr4}", + "mov ecx, {s_cet}", + "mov eax, 1", + "xor edx, edx", + "wrmsr", + "rstorssp [{shadow_stack}]", + "incsspq {increment}", + "rdsspq {after}", + "mov ecx, {s_cet}", + "mov eax, {original_s_cet_low:e}", + "mov edx, {original_s_cet_high:e}", + "wrmsr", + "mov cr4, {original_cr4}", + original_cr4 = out(reg) _, + cet_cr4 = out(reg) _, + after = out(reg) after, + shadow_stack = in(reg) shadow_stack, + increment = in(reg) 1_u64, + original_s_cet_low = in(reg) original_s_cet_low, + original_s_cet_high = in(reg) original_s_cet_high, + s_cet = const MSR_IA32_S_CET, + cr4_cet = const CR4_CET, + out("eax") _, + out("ecx") _, + out("edx") _, + options(nostack) + ); + } + + after +} + +#[guest_function("CetShadowStackSupported")] +#[cfg(target_arch = "x86_64")] +fn cet_shadow_stack_supported() -> bool { + core::arch::x86_64::__cpuid(0).eax >= 7 + && core::arch::x86_64::__cpuid_count(7, 0).ecx & (1 << 7) != 0 +} + +#[guest_function("ReadActiveSsp")] +#[cfg(target_arch = "x86_64")] +fn read_active_ssp() -> u64 { + const MSR_IA32_S_CET: u32 = 0x6A2; + const CR4_CET: u64 = 1 << 23; + + let original_s_cet = read_msr(MSR_IA32_S_CET); + let original_s_cet_low = original_s_cet as u32; + let original_s_cet_high = (original_s_cet >> 32) as u32; + let ssp: u64; + + // SAFETY: The test guest runs at CPL0. RDSSPQ only reads the SSP register, + // and CET is disabled before the function returns. + unsafe { + core::arch::asm!( + "mov {original_cr4}, cr4", + "mov {cet_cr4}, {original_cr4}", + "or {cet_cr4}, {cr4_cet}", + "mov cr4, {cet_cr4}", + "mov ecx, {s_cet}", + "mov eax, 1", + "xor edx, edx", + "wrmsr", + "rdsspq {ssp}", + "mov ecx, {s_cet}", + "mov eax, {original_s_cet_low:e}", + "mov edx, {original_s_cet_high:e}", + "wrmsr", + "mov cr4, {original_cr4}", + original_cr4 = out(reg) _, + cet_cr4 = out(reg) _, + ssp = out(reg) ssp, + original_s_cet_low = in(reg) original_s_cet_low, + original_s_cet_high = in(reg) original_s_cet_high, + s_cet = const MSR_IA32_S_CET, + cr4_cet = const CR4_CET, + out("eax") _, + out("ecx") _, + out("edx") _, + options(nostack) + ); + } + + ssp +} + +#[guest_function("NestedVirtualizationCpuid")] +#[cfg(target_arch = "x86_64")] +#[allow(unused_unsafe)] +fn nested_virtualization_cpuid() -> u32 { + // SAFETY: CPUID leaves 0, 1, and 0x80000000 are always available on x86_64. + let vmx = unsafe { core::arch::x86_64::__cpuid(1) }.ecx & (1 << 5) != 0; + let max_extended = unsafe { core::arch::x86_64::__cpuid(0x8000_0000) }.eax; + let svm = max_extended >= 0x8000_0001 + && unsafe { core::arch::x86_64::__cpuid(0x8000_0001) }.ecx & (1 << 2) != 0; + u32::from(vmx) | (u32::from(svm) << 1) +} + +#[guest_function("WriteKernelGsBaseViaSwapgs")] +#[cfg(target_arch = "x86_64")] +fn write_kernel_gs_base_via_swapgs(value: u64) { + // SAFETY: The test guest runs at CPL0, and CR4 is restored before returning. + unsafe { + core::arch::asm!( + "mov {original_cr4}, cr4", + "mov {enabled_cr4}, {original_cr4}", + "or {enabled_cr4}, {fsgsbase}", + "mov cr4, {enabled_cr4}", + "wrgsbase {value}", + "swapgs", + "mov cr4, {original_cr4}", + original_cr4 = out(reg) _, + enabled_cr4 = out(reg) _, + fsgsbase = const 1 << 16, + value = in(reg) value, + options(nostack) + ); + } +} + +#[guest_function("ReadKernelGsBaseViaSwapgs")] +#[cfg(target_arch = "x86_64")] +fn read_kernel_gs_base_via_swapgs() -> u64 { + let value: u64; + // SAFETY: The test guest runs at CPL0, and SWAPGS and CR4 are restored before returning. + unsafe { + core::arch::asm!( + "mov {original_cr4}, cr4", + "mov {enabled_cr4}, {original_cr4}", + "or {enabled_cr4}, {fsgsbase}", + "mov cr4, {enabled_cr4}", + "swapgs", + "rdgsbase {value}", + "swapgs", + "mov cr4, {original_cr4}", + original_cr4 = out(reg) _, + enabled_cr4 = out(reg) _, + value = lateout(reg) value, + fsgsbase = const 1 << 16, + options(nostack) + ); + } + value +} + +#[guest_function("WriteMSR")] +#[cfg(target_arch = "x86_64")] +fn write_msr(msr: u32, value: u64) { + let eax = (value & 0xFFFFFFFF) as u32; + let edx = ((value >> 32) & 0xFFFFFFFF) as u32; + // SAFETY: The test guest runs at CPL0, and the operands declare every register used. + unsafe { + core::arch::asm!( + "wrmsr", + in("ecx") msr, + in("eax") eax, + in("edx") edx, + options(nostack, nomem) + ); + } +} + #[hyperlight_guest_bin::main] #[instrument(skip_all, parent = Span::current(), level= "Trace")] fn main() {