From 9a416afa37055f6f84a12ac20c6c7fcde11a6b8f Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:17:44 -0800 Subject: [PATCH] Explicitly error on guest-host version mismatch Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../how-to-build-a-hyperlight-guest-binary.md | 12 ++ src/hyperlight_common/src/lib.rs | 3 + src/hyperlight_common/src/version_note.rs | 84 +++++++++++ src/hyperlight_guest_bin/src/lib.rs | 16 ++ src/hyperlight_host/src/error.rs | 15 ++ src/hyperlight_host/src/mem/elf.rs | 34 +++++ src/hyperlight_host/src/mem/exe.rs | 137 ++++++++++++++++++ src/hyperlight_host/src/sandbox/snapshot.rs | 11 ++ 8 files changed, 312 insertions(+) create mode 100644 src/hyperlight_common/src/version_note.rs diff --git a/docs/how-to-build-a-hyperlight-guest-binary.md b/docs/how-to-build-a-hyperlight-guest-binary.md index a76e43d4b..eb62acc5e 100644 --- a/docs/how-to-build-a-hyperlight-guest-binary.md +++ b/docs/how-to-build-a-hyperlight-guest-binary.md @@ -30,3 +30,15 @@ latest release page that contain: the `hyperlight_guest.h` header and the C API library. The `hyperlight_guest.h` header contains the corresponding APIs to register guest functions and call host functions from within the guest. + +## Version compatibility + +Guest binaries built with `hyperlight-guest-bin` automatically embed the crate +version in an ELF note section (`.note.hyperlight.version`). When the host +loads a guest binary, it checks this version and rejects the binary if it does +not match the host's version of `hyperlight-host`. + +Hyperlight currently provides no backwards compatibility guarantees for guest +binaries — the guest and host crate versions must match exactly. If you see a +`GuestBinVersionMismatch` error, rebuild the guest binary with a matching +version of `hyperlight-guest-bin`. diff --git a/src/hyperlight_common/src/lib.rs b/src/hyperlight_common/src/lib.rs index 478aeef8b..77ba1312e 100644 --- a/src/hyperlight_common/src/lib.rs +++ b/src/hyperlight_common/src/lib.rs @@ -44,3 +44,6 @@ pub mod func; // cbindgen:ignore pub mod vmem; + +/// ELF note types for embedding hyperlight version metadata in guest binaries. +pub mod version_note; diff --git a/src/hyperlight_common/src/version_note.rs b/src/hyperlight_common/src/version_note.rs new file mode 100644 index 000000000..6c8bf7ebc --- /dev/null +++ b/src/hyperlight_common/src/version_note.rs @@ -0,0 +1,84 @@ +/* +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. +*/ + +//! ELF note types for embedding hyperlight version metadata in guest binaries. +//! +//! Guest binaries built with `hyperlight-guest-bin` include a `.note.hyperlight.version` +//! ELF note section containing the crate version they were compiled against. +//! The host reads this section at load time to verify ABI compatibility. + +/// The ELF note section name used to embed the hyperlight-guest-bin version in guest binaries. +pub const HYPERLIGHT_VERSION_SECTION: &str = ".note.hyperlight.version"; + +/// The owner name used in the ELF note header for hyperlight version metadata. +pub const HYPERLIGHT_NOTE_NAME: &str = "Hyperlight"; + +/// The note type value used in the ELF note header for hyperlight version metadata. +pub const HYPERLIGHT_NOTE_TYPE: u32 = 1; + +/// A byte array with 4-byte alignment, used for ELF note name/descriptor +/// fields. The compiler inserts trailing padding automatically so that the +/// next field starts at a 4-byte boundary. +#[repr(C, align(4))] +struct Aligned4(pub(self) [u8; N]); + +/// An ELF note structure suitable for embedding in a `#[link_section]` static. +/// +/// `NAME_SZ` and `DESC_SZ` must include the null terminator. +/// The `+ 1` can't be hidden inside the struct because stable Rust doesn't +/// allow `[u8; N + 1]` in struct fields. [`Aligned4`] handles the 4-byte +/// alignment padding required by the note format. +#[repr(C)] +pub struct ElfNote { + namesz: u32, + descsz: u32, + n_type: u32, + name: Aligned4, + desc: Aligned4, +} + +// SAFETY: ElfNote contains only plain data (`u32` and `[u8; N]`). +// Required because ElfNote is used in a `static` (for `#[link_section]`), +// and `static` values must be `Sync`. +unsafe impl Sync for ElfNote {} + +impl ElfNote { + /// Create a new ELF note from a name string, descriptor string, and type. + /// + /// `NAME_SZ` and `DESC_SZ` must equal `name.len() + 1` and `desc.len() + 1` + /// respectively (the `+ 1` accounts for the null terminator). + pub const fn new(name: &str, desc: &str, n_type: u32) -> Self { + Self { + namesz: NAME_SZ as u32, + descsz: DESC_SZ as u32, + n_type, + name: Aligned4(pad_str_to_array(name)), + desc: Aligned4(pad_str_to_array(desc)), + } + } +} + +/// Copy a string into a zero-initialised byte array at compile time. +const fn pad_str_to_array(s: &str) -> [u8; N] { + let bytes = s.as_bytes(); + let mut result = [0u8; N]; + let mut i = 0; + while i < bytes.len() { + result[i] = bytes[i]; + i += 1; + } + result +} diff --git a/src/hyperlight_guest_bin/src/lib.rs b/src/hyperlight_guest_bin/src/lib.rs index 69ffc37af..4320ea656 100644 --- a/src/hyperlight_guest_bin/src/lib.rs +++ b/src/hyperlight_guest_bin/src/lib.rs @@ -119,6 +119,22 @@ pub static mut GUEST_HANDLE: GuestHandle = GuestHandle::new(); pub(crate) static mut REGISTERED_GUEST_FUNCTIONS: GuestFunctionRegister = GuestFunctionRegister::new(); +const VERSION_STR: &str = env!("CARGO_PKG_VERSION"); +const VERSION_NUL_LEN: usize = VERSION_STR.len() + 1; + +// Embed the hyperlight-guest-bin crate version as a proper ELF note so the +// host can verify ABI compatibility at load time. +#[used] +#[unsafe(link_section = ".note.hyperlight.version")] +static HYPERLIGHT_VERSION_NOTE: hyperlight_common::version_note::ElfNote< + { hyperlight_common::version_note::HYPERLIGHT_NOTE_NAME.len() + 1 }, + VERSION_NUL_LEN, +> = hyperlight_common::version_note::ElfNote::new( + hyperlight_common::version_note::HYPERLIGHT_NOTE_NAME, + VERSION_STR, + hyperlight_common::version_note::HYPERLIGHT_NOTE_TYPE, +); + /// The size of one page in the host OS, which may have some impacts /// on how buffers for host consumption should be aligned. Code only /// working with the guest page tables should use diff --git a/src/hyperlight_host/src/error.rs b/src/hyperlight_host/src/error.rs index 18fb62c74..c813f19c6 100644 --- a/src/hyperlight_host/src/error.rs +++ b/src/hyperlight_host/src/error.rs @@ -108,6 +108,20 @@ pub enum HyperlightError { #[error("The guest offset {0} is invalid.")] GuestOffsetIsInvalid(usize), + /// The guest binary was built with a different hyperlight-guest-bin version than the host expects. + /// Hyperlight currently provides no backwards compatibility guarantees for guest binaries, + /// so the guest and host versions must match exactly. This might change in the future. + #[error( + "Guest binary was built with hyperlight-guest-bin {guest_bin_version}, \ + but the host is running hyperlight {host_version}" + )] + GuestBinVersionMismatch { + /// Version of hyperlight-guest-bin the guest was compiled against. + guest_bin_version: String, + /// Version of hyperlight-host. + host_version: String, + }, + /// A Host function was called by the guest but it was not registered. #[error("HostFunction {0} was not found")] HostFunctionNotFound(String), @@ -345,6 +359,7 @@ impl HyperlightError { | HyperlightError::Error(_) | HyperlightError::FailedToGetValueFromParameter() | HyperlightError::FieldIsMissingInGuestLogData(_) + | HyperlightError::GuestBinVersionMismatch { .. } | HyperlightError::GuestError(_, _) | HyperlightError::GuestExecutionHungOnHostFunctionCall() | HyperlightError::GuestFunctionCallAlreadyInProgress() diff --git a/src/hyperlight_host/src/mem/elf.rs b/src/hyperlight_host/src/mem/elf.rs index 62ee0e904..beb4c01b7 100644 --- a/src/hyperlight_host/src/mem/elf.rs +++ b/src/hyperlight_host/src/mem/elf.rs @@ -45,6 +45,9 @@ pub(crate) struct ElfInfo { shdrs: Vec, entry: u64, relocs: Vec, + /// The hyperlight version string embedded by `hyperlight-guest-bin`, if + /// present. Used to detect version/ABI mismatches between guest and host. + guest_bin_version: Option, } #[cfg(feature = "mem_profile")] @@ -120,6 +123,11 @@ impl ElfInfo { { log_then_return!("ELF must have at least one PT_LOAD header"); } + + // Look for the hyperlight version note embedded by + // hyperlight-guest-bin. + let guest_bin_version = Self::read_version_note(&elf, bytes); + Ok(ElfInfo { payload: bytes.to_vec(), phdrs: elf.program_headers, @@ -138,11 +146,37 @@ impl ElfInfo { .collect(), entry: elf.entry, relocs, + guest_bin_version, }) } + + /// Read the hyperlight version note from the ELF binary + fn read_version_note<'a>(elf: &Elf<'a>, bytes: &'a [u8]) -> Option { + use hyperlight_common::version_note::{ + HYPERLIGHT_NOTE_NAME, HYPERLIGHT_NOTE_TYPE, HYPERLIGHT_VERSION_SECTION, + }; + + let notes = elf.iter_note_sections(bytes, Some(HYPERLIGHT_VERSION_SECTION))?; + for note in notes { + let Ok(note) = note else { continue }; + if note.name == HYPERLIGHT_NOTE_NAME && note.n_type == HYPERLIGHT_NOTE_TYPE { + let desc = core::str::from_utf8(note.desc).ok()?; + return Some(desc.trim_end_matches('\0').to_string()); + } + } + None + } + pub(crate) fn entrypoint_va(&self) -> u64 { self.entry } + + /// Returns the hyperlight version string embedded in the guest binary, if + /// present. Used to detect version/ABI mismatches between guest and host. + pub(crate) fn guest_bin_version(&self) -> Option<&str> { + self.guest_bin_version.as_deref() + } + pub(crate) fn get_base_va(&self) -> u64 { #[allow(clippy::unwrap_used)] // guaranteed not to panic because of the check in new() let min_phdr = self diff --git a/src/hyperlight_host/src/mem/exe.rs b/src/hyperlight_host/src/mem/exe.rs index 95a21ed31..0a4b672b0 100644 --- a/src/hyperlight_host/src/mem/exe.rs +++ b/src/hyperlight_host/src/mem/exe.rs @@ -93,6 +93,15 @@ impl ExeInfo { ExeInfo::Elf(elf) => elf.get_va_size(), } } + + /// Returns the hyperlight version string embedded in the guest binary, if + /// the binary was built with a version of `hyperlight-guest-bin` that + /// supports version tagging. + pub fn guest_bin_version(&self) -> Option<&str> { + match self { + ExeInfo::Elf(elf) => elf.guest_bin_version(), + } + } // todo: this doesn't morally need to be &mut self, since we're // copying into target, but the PE loader chooses to apply // relocations in its owned representation of the PE contents, @@ -103,3 +112,131 @@ impl ExeInfo { } } } + +#[cfg(test)] +mod tests { + use hyperlight_testing::{dummy_guest_as_string, simple_guest_as_string}; + + use super::ExeInfo; + + /// Read the simpleguest binary and patch the version note descriptor to `"0.0.0"`. + fn simpleguest_with_patched_version() -> Vec { + let path = simple_guest_as_string().expect("failed to locate simpleguest"); + let mut bytes = std::fs::read(path).expect("failed to read simpleguest"); + + let elf = goblin::elf::Elf::parse(&bytes).expect("failed to parse ELF"); + + // Use goblin's note iterator to locate the version note. + let note = elf + .iter_note_sections( + &bytes, + Some(hyperlight_common::version_note::HYPERLIGHT_VERSION_SECTION), + ) + .expect("note section should exist") + .find_map(|n| n.ok()) + .expect("should contain a valid note"); + + // Compute byte offsets from the slice pointers goblin gives us. + let desc_offset = note.desc.as_ptr() as usize - bytes.as_ptr() as usize; + // Walk backwards from desc: skip padded name (4-byte aligned) and + // the descsz + n_type fields (4 bytes each) to reach the descsz offset. + let descsz_offset = desc_offset - (note.name.len() + 1).next_multiple_of(4) - 8; + + let fake_version = b"0.0.0\0"; + assert!(fake_version.len() <= note.desc.len()); + + bytes[desc_offset..desc_offset + fake_version.len()].copy_from_slice(fake_version); + bytes[descsz_offset..descsz_offset + 4] + .copy_from_slice(&(fake_version.len() as u32).to_le_bytes()); + bytes + } + + #[test] + fn exe_info_exposes_guest_bin_version() { + let path = simple_guest_as_string().expect("failed to locate simpleguest"); + let info = ExeInfo::from_file(&path).expect("failed to load ELF"); + + let version = info + .guest_bin_version() + .expect("simpleguest should have a version note"); + assert_eq!(version, env!("CARGO_PKG_VERSION")); + } + + #[test] + fn dummyguest_has_no_version_section() { + let path = dummy_guest_as_string().expect("failed to locate dummyguest"); + let info = ExeInfo::from_file(&path).expect("failed to load ELF"); + + assert!( + info.guest_bin_version().is_none(), + "dummyguest should not have a version note" + ); + } + + /// A guest not built with hyperlight-guest-bin has no version note and + /// should be accepted (no version check is performed). + #[test] + fn from_env_accepts_guest_without_version_note() { + let path = dummy_guest_as_string().expect("failed to locate dummyguest"); + + let result = crate::sandbox::snapshot::Snapshot::from_env( + crate::GuestBinary::FilePath(path), + crate::sandbox::SandboxConfiguration::default(), + ); + + assert!(result.is_ok(), "should accept guest without version note"); + } + + /// Patch the version section in-memory to simulate a version mismatch. + #[test] + fn patched_version_reports_mismatch() { + let bytes = simpleguest_with_patched_version(); + + let info = ExeInfo::from_buf(&bytes).expect("failed to load patched ELF"); + assert_eq!(info.guest_bin_version(), Some("0.0.0")); + assert_ne!( + info.guest_bin_version().unwrap(), + env!("CARGO_PKG_VERSION"), + "patched version should differ from host version" + ); + } + + /// Load an unpatched simpleguest through `Snapshot::from_env` and verify + /// that it succeeds when the embedded version matches the host version. + #[test] + fn from_env_accepts_matching_version() { + let path = simple_guest_as_string().expect("failed to locate simpleguest"); + + let result = crate::sandbox::snapshot::Snapshot::from_env( + crate::GuestBinary::FilePath(path), + crate::sandbox::SandboxConfiguration::default(), + ); + + assert!(result.is_ok(), "should accept matching version"); + } + + /// Load a patched guest binary through `Snapshot::from_env` and verify + /// that a version mismatch produces `GuestBinVersionMismatch`. + #[test] + fn from_env_rejects_version_mismatch() { + let bytes = simpleguest_with_patched_version(); + + let result = crate::sandbox::snapshot::Snapshot::from_env( + crate::GuestBinary::Buffer(&bytes), + crate::sandbox::SandboxConfiguration::default(), + ); + + assert!(result.is_err(), "should reject mismatched version"); + let err = result.err().expect("already checked is_err"); + assert!( + matches!( + err, + crate::HyperlightError::GuestBinVersionMismatch { + ref guest_bin_version, + ref host_version, + } if guest_bin_version == "0.0.0" && host_version == env!("CARGO_PKG_VERSION") + ), + "expected GuestBinVersionMismatch, got: {err}" + ); + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot.rs b/src/hyperlight_host/src/sandbox/snapshot.rs index 20cd046ad..7350f1cb2 100644 --- a/src/hyperlight_host/src/sandbox/snapshot.rs +++ b/src/hyperlight_host/src/sandbox/snapshot.rs @@ -352,6 +352,17 @@ impl Snapshot { GuestBinary::Buffer(buffer) => ExeInfo::from_buf(buffer)?, }; + // Check guest/host version compatibility. + let host_version = env!("CARGO_PKG_VERSION"); + if let Some(v) = exe_info.guest_bin_version() + && v != host_version + { + return Err(crate::HyperlightError::GuestBinVersionMismatch { + guest_bin_version: v.to_string(), + host_version: host_version.to_string(), + }); + } + let guest_blob_size = blob.as_ref().map(|b| b.data.len()).unwrap_or(0); let guest_blob_mem_flags = blob.as_ref().map(|b| b.permissions);