From c2a773a9a7da2243e26ccc7d4fa8d74f63693228 Mon Sep 17 00:00:00 2001 From: Tomasz Andrzejak Date: Mon, 9 Nov 2026 17:36:39 +0100 Subject: [PATCH] fix: avoid apple hypercalls and synchronize snapshot instruction caches HVF handles some hvc #0 calls internally based on x0. Guest register values can select these calls, causing the tlb flush trampoline to continue past its hvc and fault. Signed-off-by: Tomasz Andrzejak --- CHANGELOG.md | 2 + .../src/hypervisor/virtual_machine/hvf/mod.rs | 59 ++++++++++++++++++- src/hyperlight_host/src/mem/shared_mem.rs | 59 +++++++++++++++++-- 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 118c3a861..75534bbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed * Fix restore on WHP not rewinding the guest TSC on hosts with `TSC_ADJUST` support. +* HVF sandbox initialization accepts seeds that match Apple hypercall identifiers. +* Host-written snapshot memory has instruction-cache coherence before guest execution. ## [v0.19.0] - 2026-11-04 diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs index 9756c3953..afe2014af 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs @@ -764,6 +764,7 @@ struct TlbiRegion { insn_memory: ReadonlySharedMemory, } impl TlbiRegion { + /// Prepare the TLB-flush instructions for execution on any vCPU. fn new() -> Result { let mut bytes = vec![0; page_size::get()]; bytes[0..20].copy_from_slice(&[ @@ -771,7 +772,7 @@ impl TlbiRegion { 0x1f, 0x87, 0x08, 0xd5, // tlbi vmalle1 0x9f, 0x3b, 0x03, 0xd5, // dsb ish 0xdf, 0x3f, 0x03, 0xd5, // isb sy - 0x02, 0x00, 0x00, 0xd4, // hvc #0 + 0x82, 0x09, 0x09, 0xd4, // hvc #0x484c ('HL'), outside Apple's HVC #0 ABI ]); Ok(Self { insn_memory: ReadonlySharedMemory::from_bytes(&bytes, page_size::get())?, @@ -922,7 +923,7 @@ impl LoadedMemorySpace { use bindings::hv_exit_reason_t; match exit.reason { hv_exit_reason_t::HV_EXIT_REASON_EXCEPTION - if exit.exception.syndrome.0 == 0x5a000000 => {} // hvc #0 is the success case + if exit.exception.syndrome.0 == 0x5a00484c => {} // hvc #0x484c is the success case hv_exit_reason_t::HV_EXIT_REASON_CANCELED => { return Err(TimeoutableError::Timeout); } @@ -1234,3 +1235,57 @@ impl VirtualMachine for HvfVm { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::time::Instant; + + use super::*; + use crate::hypervisor::HvfInterruptHandle; + use crate::sandbox::SandboxConfiguration; + + #[test] + fn tlbi_does_not_invoke_apple_hypercalls() { + let config = SandboxConfiguration::default(); + let interrupt_handle = + Arc::new(HvfInterruptHandle::new(config.get_interrupt_retry_delay())); + let _vm = HvfVm::new(interrupt_handle).unwrap(); + let deadline = Instant::now() + Duration::from_secs(60); + + let mut cpu = loop { + match HvfCpu::new() { + Ok((cpu, _)) => break cpu, + Err(TimeoutableError::Timeout) => { + assert!( + Instant::now() < deadline, + "timed out waiting for an HVF vCPU" + ); + } + Err(TimeoutableError::Error(error)) => panic!("creating HVF vCPU: {error}"), + } + }; + + // Apple PAC_NOP and OEM UID hypercalls. + for x0 in [0xc100_00f0, 0x8300_ff01] { + cpu.hv_vcpu_set_reg(bindings::hv_reg_t::HV_REG_X0, x0) + .unwrap(); + let space = MemorySpace::new(); + let mut loaded = CURRENT_LOADED_MEMORY_SPACE.write(); + + loop { + match loaded.sync_space(&space, &mut cpu) { + Ok(()) => break, + Err(TimeoutableError::Timeout) => { + assert!(Instant::now() < deadline, "timed out flushing the TLB"); + } + Err(TimeoutableError::Error(error)) => panic!("flushing the TLB: {error}"), + } + } + + assert_eq!( + cpu.hv_vcpu_get_reg(bindings::hv_reg_t::HV_REG_X0).unwrap(), + x0 + ); + } + } +} diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 69ef78732..7363d4500 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -604,6 +604,49 @@ pub trait SharedMemory { } } +/// Prepare host-written code in a guest-visible prefix for execution. +/// +/// - Linux x86-64 relies on hardware instruction/data-cache coherence for KVM and MSHV. +/// - On ARM64, KVM cleans the data cache as needed when installing stage-2 mappings. +/// It invalidates the instruction cache before installing an executable mapping or +/// adding execute permission to an existing mapping. +/// +/// See Linux: +/// -[stage-2 page-table code](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/kvm/hyp/pgtable.c) +/// -[cache helpers](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/include/asm/kvm_mmu.h). +fn sync_instr_cache(memory: &impl SharedMemory, size: usize) -> Result<()> { + bounds_check!(0usize, size, memory.mem_size()); + + if size == 0 { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + unsafe extern "C" { + fn sys_icache_invalidate(start: *mut c_void, len: usize); + } + + // SAFETY: The checked range is within a live host mapping. + unsafe { + sys_icache_invalidate(memory.base_ptr().cast(), size); + } + } + + #[cfg(target_os = "windows")] + { + use windows::Win32::System::Diagnostics::Debug::FlushInstructionCache; + use windows::Win32::System::Threading::GetCurrentProcess; + + // SAFETY: The checked range belongs to the current process and its mapping is live. + unsafe { + FlushInstructionCache(GetCurrentProcess(), Some(memory.base_ptr().cast()), size)?; + } + } + + Ok(()) +} + fn mapping_at( s: &impl SharedMemory, gpa: u64, @@ -968,6 +1011,8 @@ impl ExclusiveSharedMemory { return Err(SharedMemoryError::SharedMemoryNotExclusive); } + sync_instr_cache(&self, guest_mapped_size)?; + // This is the only Arc left. To make another one you have to clone this // one, and it lives in `self`, which this function owns. Ok(ReadonlySharedMemory { @@ -1573,10 +1618,7 @@ impl ReadonlySharedMemory { let mut anon = ExclusiveSharedMemory::new(contents.len().next_multiple_of(page_size::get()))?; anon.copy_from_slice(contents, 0)?; - Ok(ReadonlySharedMemory { - region: anon.region, - guest_mapped_size, - }) + anon.freeze(guest_mapped_size) } /// The number of bytes that should be mapped into guest PA space. @@ -1616,10 +1658,13 @@ impl ReadonlySharedMemory { } let region = Self::map_file(file, len)?; - Ok(ReadonlySharedMemory { + let memory = Self { region, guest_mapped_size, - }) + }; + sync_instr_cache(&memory, guest_mapped_size)?; + + Ok(memory) } /// Linux: reserve `[guard][blob][guard]` as one anonymous @@ -1778,6 +1823,8 @@ impl ReadonlySharedMemory { pub(crate) fn copy_to_writable(&self) -> Result { let mut writable = ExclusiveSharedMemory::new(self.guest_mapped_size)?; writable.copy_from_slice(&self.as_slice()[..self.guest_mapped_size], 0)?; + sync_instr_cache(&writable, self.guest_mapped_size)?; + Ok(writable) }