From 0295ef2410d0c2d58034aa5bbca8e61aecd8799a Mon Sep 17 00:00:00 2001 From: Sangho Lee Date: Wed, 21 Oct 2026 20:04:30 -0700 Subject: [PATCH 1/3] Parse the dyld shared cache format read-only --- litebox_common_macos/src/lib.rs | 1 + litebox_common_macos/src/shared_cache.rs | 1212 +++++++++++++++++ .../src/shared_cache/tests.rs | 856 ++++++++++++ .../tests/live_shared_cache.rs | 151 ++ 4 files changed, 2220 insertions(+) create mode 100644 litebox_common_macos/src/shared_cache.rs create mode 100644 litebox_common_macos/src/shared_cache/tests.rs create mode 100644 litebox_common_macos/tests/live_shared_cache.rs diff --git a/litebox_common_macos/src/lib.rs b/litebox_common_macos/src/lib.rs index b93817893f..aa78e29f25 100644 --- a/litebox_common_macos/src/lib.rs +++ b/litebox_common_macos/src/lib.rs @@ -9,6 +9,7 @@ extern crate alloc; pub mod errno; pub mod loader; +pub mod shared_cache; pub mod syscall; pub use litebox::user_pointers; diff --git a/litebox_common_macos/src/shared_cache.rs b/litebox_common_macos/src/shared_cache.rs new file mode 100644 index 0000000000..931c79ac87 --- /dev/null +++ b/litebox_common_macos/src/shared_cache.rs @@ -0,0 +1,1212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Read-only parsing of an on-disk dyld shared cache. +//! +//! Layouts follow `dyld/include/mach-o/dyld_cache_format.h` and +//! `mach-o/loader.h`. All fields are read explicitly as little-endian and +//! every offset, count and size is bounds-checked: malformed input yields +//! [`SharedCacheError`], never a panic or an arithmetic overflow. Files are +//! read lazily through [`CacheFileReader`], so multi-GiB cache files need not +//! be loaded. +//! +//! Intended use: parse the main cache file with [`CacheFile::parse`], open each +//! [`CacheFile::subcache_entries`] file (main path + suffix), and combine them +//! with [`SharedCache::new`] (which verifies subcache identity and layout). + +use crate::VmProtection; +use alloc::{boxed::Box, string::String, vec, vec::Vec}; +use core::ops::Range; +use zerocopy::{ + FromBytes, Immutable, KnownLayout, Unaligned, + little_endian::{U16, U32, U64}, +}; + +/// Magic of an arm64e cache file (main cache and subcaches). +pub const CACHE_MAGIC_ARM64E: [u8; 16] = *b"dyld_v1 arm64e\0"; + +/// `DYLD_CACHE_SLIDE_V5_PAGE_ATTR_NO_REBASE`. +pub const SLIDE_V5_PAGE_NO_REBASE: u16 = 0xffff; + +/// Size of a `DyldSharedCache::DynamicRegion` reservation. +pub const DYNAMIC_REGION_SIZE: usize = 0x4000; + +/// Upper bounds that reject absurd counts before allocating. +const MAX_MAPPINGS: u32 = 64; +const MAX_SUBCACHES: u32 = 1024; +const MAX_IMAGES: u32 = 1 << 20; +const MAX_LOAD_COMMAND_BYTES: u32 = 1 << 20; +const MAX_HEADER_BYTES: usize = 0x4000; +const MAX_SLIDE_INFO_BYTES: usize = 1 << 24; + +// dyld_cache_header field offsets; public only for the ABI check. +#[doc(hidden)] +pub const HEADER_MAPPING_OFFSET: usize = 0x10; +#[doc(hidden)] +pub const HEADER_UUID: usize = 0x58; +#[doc(hidden)] +pub const HEADER_CACHE_TYPE: usize = 0x68; +#[doc(hidden)] +pub const HEADER_DYLD_IN_CACHE_MH: usize = 0x78; +#[doc(hidden)] +pub const HEADER_SHARED_REGION_START: usize = 0xe0; +#[doc(hidden)] +pub const HEADER_SHARED_REGION_SIZE: usize = 0xe8; +#[doc(hidden)] +pub const HEADER_MAX_SLIDE: usize = 0xf0; +#[doc(hidden)] +pub const HEADER_MAPPING_WITH_SLIDE_OFFSET: usize = 0x138; +#[doc(hidden)] +pub const HEADER_SUBCACHE_ARRAY_OFFSET: usize = 0x188; +#[doc(hidden)] +pub const HEADER_IMAGES_OFFSET: usize = 0x1c0; +#[doc(hidden)] +pub const HEADER_CACHE_SUB_TYPE: usize = 0x1c8; +#[doc(hidden)] +pub const HEADER_DYNAMIC_DATA_OFFSET: usize = 0x1f0; +#[doc(hidden)] +pub const HEADER_DYNAMIC_DATA_MAX_SIZE: usize = 0x1f8; + +/// `dylib_use_command.marker`: the command's flags, not its `cmd`, select the +/// linkage kind. +pub const DYLIB_USE_MARKER: u32 = 0x1a74_1800; +/// The load commands that link a dependency. +pub const DYLIB_LOAD_COMMANDS: [u32; 4] = [ + object::macho::LC_LOAD_DYLIB, + object::macho::LC_LOAD_WEAK_DYLIB, + object::macho::LC_REEXPORT_DYLIB, + object::macho::LC_LOAD_UPWARD_DYLIB, +]; + +// Fixed-size on-disk records. The private dyld records are public only for +// the ABI check. + +/// `struct dyld_cache_mapping_and_slide_info`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +#[doc(hidden)] +pub struct DyldCacheMappingAndSlideInfo { + pub address: U64, + pub size: U64, + pub file_offset: U64, + pub slide_info_file_offset: U64, + pub slide_info_file_size: U64, + pub flags: U64, + pub max_prot: U32, + pub init_prot: U32, +} + +/// `struct dyld_subcache_entry`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +#[doc(hidden)] +pub struct DyldSubcacheEntry { + pub uuid: [u8; 16], + pub cache_vm_offset: U64, + pub file_suffix: [u8; 32], +} + +/// `struct dyld_cache_image_info`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +#[doc(hidden)] +pub struct DyldCacheImageInfo { + pub address: U64, + pub mod_time: U64, + pub inode: U64, + pub path_file_offset: U32, + pub pad: U32, +} + +/// The fixed part of `struct dyld_cache_slide_info5`; `page_starts` follows. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +#[doc(hidden)] +pub struct DyldCacheSlideInfo5 { + pub version: U32, + pub page_size: U32, + pub page_starts_count: U32, + /// C alignment padding before the 8-byte `value_add`. + pub pad: U32, + pub value_add: U64, +} + +/// `struct mach_header_64`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +pub struct MachHeader64 { + pub magic: U32, + pub cputype: U32, + pub cpusubtype: U32, + pub filetype: U32, + pub ncmds: U32, + pub sizeofcmds: U32, + pub flags: U32, + pub reserved: U32, +} + +/// `struct load_command`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +pub struct LoadCommand { + pub cmd: U32, + pub cmdsize: U32, +} + +/// `struct segment_command_64`; `nsects` `section_64`s follow. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +pub struct SegmentCommand64 { + pub cmd: U32, + pub cmdsize: U32, + pub segname: [u8; 16], + pub vmaddr: U64, + pub vmsize: U64, + pub fileoff: U64, + pub filesize: U64, + pub maxprot: U32, + pub initprot: U32, + pub nsects: U32, + pub flags: U32, +} + +/// `struct section_64`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +pub struct Section64 { + pub sectname: [u8; 16], + pub segname: [u8; 16], + pub addr: U64, + pub size: U64, + pub offset: U32, + pub align: U32, + pub reloff: U32, + pub nreloc: U32, + pub flags: U32, + pub reserved1: U32, + pub reserved2: U32, + pub reserved3: U32, +} + +/// `struct dylib_command`, with its `struct dylib` inlined. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +pub struct DylibCommand { + pub cmd: U32, + pub cmdsize: U32, + pub name_offset: U32, + pub timestamp: U32, + pub current_version: U32, + pub compatibility_version: U32, +} + +/// `struct dylib_use_command`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +pub struct DylibUseCommand { + pub cmd: U32, + pub cmdsize: U32, + pub nameoff: U32, + pub marker: U32, + pub current_version: U32, + pub compat_version: U32, + pub flags: U32, +} + +/// `struct uuid_command`. +#[repr(C)] +#[derive(FromBytes, KnownLayout, Immutable, Unaligned)] +pub struct UuidCommand { + pub cmd: U32, + pub cmdsize: U32, + pub uuid: [u8; 16], +} + +// Sizes from Apple's headers. +const _: () = assert!(size_of::() == 56); +const _: () = assert!(size_of::() == 56); +const _: () = assert!(size_of::() == 32); +const _: () = assert!(size_of::() == 24); +const _: () = assert!(size_of::() == 32); +const _: () = assert!(size_of::() == 8); +const _: () = assert!(size_of::() == 72); +const _: () = assert!(size_of::() == 80); +const _: () = assert!(size_of::() == 24); +const _: () = assert!(size_of::() == 28); +const _: () = assert!(size_of::() == 24); + +/// Why a shared cache was rejected. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum SharedCacheError { + #[error("cannot read {length:#x} bytes at file offset {offset:#x}")] + Read { offset: u64, length: usize }, + #[error("malformed shared cache: {0}")] + Malformed(&'static str), + #[error("unsupported shared cache: {0}")] + Unsupported(&'static str), + #[error("subcache {index} UUID does not match the main cache's subcache table")] + SubcacheUuidMismatch { index: usize }, + #[error("no cache mapping covers {length:#x} bytes at {address:#x}")] + Unmapped { address: u64, length: u64 }, + #[error("cannot parse the cache image at {address:#x}")] + Image { + address: u64, + #[source] + source: Box, + }, + /// The caller passed an argument this cache cannot describe. + #[error("invalid argument: {0}")] + InvalidArgument(&'static str), +} + +type Result = core::result::Result; + +/// Random-access, read-only view of one cache file. +pub trait CacheFileReader { + /// File length in bytes. + fn file_len(&self) -> u64; + /// Fill `buffer` from `offset`. Implementations fail rather than return + /// short reads. + fn read_exact_at(&self, offset: u64, buffer: &mut [u8]) -> Result<()>; +} + +impl CacheFileReader for [u8] { + fn file_len(&self) -> u64 { + self.len() as u64 + } + + fn read_exact_at(&self, offset: u64, buffer: &mut [u8]) -> Result<()> { + let error = || SharedCacheError::Read { + offset, + length: buffer.len(), + }; + let start = usize::try_from(offset).map_err(|_| error())?; + let end = start.checked_add(buffer.len()).ok_or_else(error)?; + buffer.copy_from_slice(self.get(start..end).ok_or_else(error)?); + Ok(()) + } +} + +impl CacheFileReader for Vec { + fn file_len(&self) -> u64 { + self.as_slice().file_len() + } + + fn read_exact_at(&self, offset: u64, buffer: &mut [u8]) -> Result<()> { + self.as_slice().read_exact_at(offset, buffer) + } +} + +impl CacheFileReader for &T { + fn file_len(&self) -> u64 { + (**self).file_len() + } + + fn read_exact_at(&self, offset: u64, buffer: &mut [u8]) -> Result<()> { + (**self).read_exact_at(offset, buffer) + } +} + +fn read_vec( + reader: &R, + offset: u64, + length: usize, +) -> Result> { + let end = offset + .checked_add(length as u64) + .ok_or(SharedCacheError::Read { offset, length })?; + if end > reader.file_len() { + return Err(SharedCacheError::Read { offset, length }); + } + let mut buffer = vec![0; length]; + reader.read_exact_at(offset, &mut buffer)?; + Ok(buffer) +} + +/// The record of type `T` at `offset`. +fn record( + bytes: &[u8], + offset: usize, +) -> Result<&T> { + bytes + .get(offset..) + .and_then(|bytes| T::ref_from_prefix(bytes).ok()) + .map(|(record, _)| record) + .ok_or(SharedCacheError::Malformed("truncated structure")) +} + +/// Read a table of `count` records of type `T` at `offset`. +fn read_records( + reader: &R, + offset: u32, + count: u32, +) -> Result> { + let bytes = read_vec(reader, u64::from(offset), count as usize * size_of::())?; + bytes + .chunks_exact(size_of::()) + .map(|chunk| { + T::read_from_bytes(chunk) + .map_err(|_| SharedCacheError::Malformed("truncated structure")) + }) + .collect() +} + +fn field(bytes: &[u8], offset: usize) -> Result<[u8; N]> { + offset + .checked_add(N) + .and_then(|end| bytes.get(offset..end)) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or(SharedCacheError::Malformed("truncated structure")) +} + +fn u32_at(bytes: &[u8], offset: usize) -> Result { + field(bytes, offset).map(u32::from_le_bytes) +} + +fn u64_at(bytes: &[u8], offset: usize) -> Result { + field(bytes, offset).map(u64::from_le_bytes) +} + +/// A NUL-terminated UTF-8 string starting at `offset`, ending before `limit`. +fn c_string(bytes: &[u8], offset: usize, limit: usize) -> Result<&str> { + let bytes = bytes + .get(offset..limit.min(bytes.len())) + .ok_or(SharedCacheError::Malformed("string offset"))?; + let length = bytes + .iter() + .position(|&byte| byte == 0) + .ok_or(SharedCacheError::Malformed("unterminated string"))?; + core::str::from_utf8(&bytes[..length]) + .map_err(|_| SharedCacheError::Malformed("non-UTF-8 string")) +} + +/// Read a NUL-terminated path at `offset` without reading past the file. +fn read_path(reader: &R, offset: u64) -> Result { + let available = reader + .file_len() + .checked_sub(offset) + .ok_or(SharedCacheError::Malformed("path offset"))?; + let length = usize::try_from(available) + .unwrap_or(usize::MAX) + .min(crate::PATH_MAX); + let bytes = read_vec(reader, offset, length)?; + c_string(&bytes, 0, bytes.len()).map(String::from) +} + +fn protection(raw: u32) -> Result { + raw.try_into() + .ok() + .and_then(VmProtection::from_bits) + .ok_or(SharedCacheError::Malformed("VM protection")) +} + +/// An `(offset, count)` table location from a cache header. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TableLocation { + pub offset: u32, + pub count: u32, +} + +/// The fields of `dyld_cache_header` this module reads. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CacheHeader { + pub magic: [u8; 16], + /// Also the header's size: fields at or beyond it are absent. + pub mapping_offset: u32, + pub uuid: [u8; 16], + pub cache_type: u64, + /// Unslid address of the in-cache dyld's Mach header, if any. + pub dyld_in_cache_mh: u64, + pub shared_region_start: u64, + pub shared_region_size: u64, + pub max_slide: u64, + pub mappings_with_slide: TableLocation, + pub subcaches: TableLocation, + pub images: TableLocation, + /// `dynamicDataOffset`/`dynamicDataMaxSize`, if the header has them. + pub dynamic_data: Option<(u64, u64)>, +} + +impl CacheHeader { + /// Parse a header from the start of `bytes`. + /// + /// Only arm64e caches whose header includes `cacheSubType` (and therefore + /// 56-byte subcache entries with file suffixes) are supported. + pub fn parse(bytes: &[u8]) -> Result { + let magic: [u8; 16] = field(bytes, 0)?; + if magic != CACHE_MAGIC_ARM64E { + return Err(SharedCacheError::Unsupported( + "cache magic is not dyld_v1 arm64e", + )); + } + let mapping_offset = u32_at(bytes, HEADER_MAPPING_OFFSET)?; + let header_size = mapping_offset as usize; + if header_size <= HEADER_CACHE_SUB_TYPE { + return Err(SharedCacheError::Unsupported("legacy cache header")); + } + if bytes.len() < header_size { + return Err(SharedCacheError::Malformed("truncated header")); + } + let table = |offset| -> Result { + Ok(TableLocation { + offset: u32_at(bytes, offset)?, + count: u32_at(bytes, offset + 4)?, + }) + }; + let dynamic_data = if header_size >= HEADER_DYNAMIC_DATA_MAX_SIZE + 8 { + Some(( + u64_at(bytes, HEADER_DYNAMIC_DATA_OFFSET)?, + u64_at(bytes, HEADER_DYNAMIC_DATA_MAX_SIZE)?, + )) + } else { + None + }; + Ok(Self { + magic, + mapping_offset, + uuid: field(bytes, HEADER_UUID)?, + cache_type: u64_at(bytes, HEADER_CACHE_TYPE)?, + dyld_in_cache_mh: u64_at(bytes, HEADER_DYLD_IN_CACHE_MH)?, + shared_region_start: u64_at(bytes, HEADER_SHARED_REGION_START)?, + shared_region_size: u64_at(bytes, HEADER_SHARED_REGION_SIZE)?, + max_slide: u64_at(bytes, HEADER_MAX_SLIDE)?, + mappings_with_slide: table(HEADER_MAPPING_WITH_SLIDE_OFFSET)?, + subcaches: table(HEADER_SUBCACHE_ARRAY_OFFSET)?, + images: table(HEADER_IMAGES_OFFSET)?, + dynamic_data, + }) + } +} + +bitflags::bitflags! { + /// `dyld_cache_mapping_and_slide_info.flags`. Unknown bits are retained. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct MappingFlags: u64 { + const AUTH_DATA = 1 << 0; + const DIRTY_DATA = 1 << 1; + const CONST_DATA = 1 << 2; + const TEXT_STUBS = 1 << 3; + const DYNAMIC_CONFIG_DATA = 1 << 4; + const READ_ONLY_DATA = 1 << 5; + const CONST_TPRO_DATA = 1 << 6; + } +} + +/// One `dyld_cache_mapping_and_slide_info` entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CacheMapping { + /// Unslid address. + pub address: u64, + pub size: u64, + pub file_offset: u64, + /// Location of this mapping's slide info in the same file; empty if none. + pub slide_info: Range, + pub flags: MappingFlags, + pub max_protection: VmProtection, + pub initial_protection: VmProtection, +} + +impl CacheMapping { + /// Unslid address range. [`CacheFile::parse`] rejects mappings whose end + /// overflows; the end saturates for one built otherwise. + pub fn address_range(&self) -> Range { + self.address..self.address.saturating_add(self.size) + } + + /// Byte range in the containing file; saturating like + /// [`Self::address_range`]. + pub fn file_range(&self) -> Range { + self.file_offset..self.file_offset.saturating_add(self.size) + } + + fn parse(entry: &DyldCacheMappingAndSlideInfo, file_len: u64) -> Result { + let address = entry.address.get(); + let size = entry.size.get(); + let file_offset = entry.file_offset.get(); + let slide_offset = entry.slide_info_file_offset.get(); + let slide_size = entry.slide_info_file_size.get(); + address + .checked_add(size) + .ok_or(SharedCacheError::Malformed("mapping address range"))?; + if size == 0 + || file_offset + .checked_add(size) + .is_none_or(|end| end > file_len) + { + return Err(SharedCacheError::Malformed("mapping file range")); + } + let slide_end = slide_offset + .checked_add(slide_size) + .filter(|end| *end <= file_len) + .ok_or(SharedCacheError::Malformed("slide info file range"))?; + Ok(Self { + address, + size, + file_offset, + slide_info: slide_offset..slide_end, + flags: MappingFlags::from_bits_retain(entry.flags.get()), + max_protection: protection(entry.max_prot.get())?, + initial_protection: protection(entry.init_prot.get())?, + }) + } +} + +/// One `dyld_subcache_entry` (the 56-byte form with a file suffix). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubcacheEntry { + pub uuid: [u8; 16], + /// Offset of the subcache's first mapping from the main cache's base. + pub cache_vm_offset: u64, + /// Appended to the main cache path to name the subcache file, e.g. `.01`. + /// Validated to be a single file-name component. + pub file_suffix: String, +} + +impl SubcacheEntry { + fn parse(entry: &DyldSubcacheEntry) -> Result { + let file_suffix = c_string(&entry.file_suffix, 0, entry.file_suffix.len())?; + if file_suffix.is_empty() + || !file_suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || file_suffix.contains("..") + { + return Err(SharedCacheError::Malformed("subcache file suffix")); + } + Ok(Self { + uuid: entry.uuid, + cache_vm_offset: entry.cache_vm_offset.get(), + file_suffix: String::from(file_suffix), + }) + } +} + +/// One cache file (main cache or subcache) and its mappings. +pub struct CacheFile { + reader: R, + header: CacheHeader, + mappings: Vec, +} + +impl core::fmt::Debug for CacheFile { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CacheFile") + .field("header", &self.header) + .field("mappings", &self.mappings) + .finish_non_exhaustive() + } +} + +impl CacheFile { + /// Parse and validate a cache file's header and mapping table. Mappings + /// must be non-empty, inside the file, and sorted without overlap. + pub fn parse(reader: R) -> Result { + let prefix = read_vec(&reader, 0, HEADER_MAPPING_OFFSET + 4)?; + let header_size = u32_at(&prefix, HEADER_MAPPING_OFFSET)? as usize; + if header_size > MAX_HEADER_BYTES { + return Err(SharedCacheError::Malformed("header size")); + } + let header_bytes = read_vec(&reader, 0, header_size.max(prefix.len()))?; + let header = CacheHeader::parse(&header_bytes)?; + let table = header.mappings_with_slide; + if table.count == 0 || table.count > MAX_MAPPINGS { + return Err(SharedCacheError::Malformed("mapping count")); + } + let file_len = reader.file_len(); + let mappings = + read_records::(&reader, table.offset, table.count)? + .iter() + .map(|entry| CacheMapping::parse(entry, file_len)) + .collect::>>()?; + if mappings + .windows(2) + .any(|pair| pair[0].address_range().end > pair[1].address) + { + return Err(SharedCacheError::Malformed( + "unsorted or overlapping mappings", + )); + } + Ok(Self { + reader, + header, + mappings, + }) + } + + pub fn header(&self) -> &CacheHeader { + &self.header + } + + pub fn mappings(&self) -> &[CacheMapping] { + &self.mappings + } + + pub fn reader(&self) -> &R { + &self.reader + } + + /// The subcache table of a main cache file, in file order. + pub fn subcache_entries(&self) -> Result> { + let table = self.header.subcaches; + if table.count > MAX_SUBCACHES { + return Err(SharedCacheError::Malformed("subcache count")); + } + read_records::(&self.reader, table.offset, table.count)? + .iter() + .map(SubcacheEntry::parse) + .collect() + } + + /// Slide info for `mapping`, which must be one of [`Self::mappings`], or + /// `None` if the mapping has none. + pub fn slide_info(&self, mapping: &CacheMapping) -> Result> { + if !self.mappings.contains(mapping) { + return Err(SharedCacheError::InvalidArgument( + "mapping is not one of this cache file's mappings", + )); + } + if mapping.slide_info.is_empty() { + return Ok(None); + } + let length = usize::try_from(mapping.slide_info.end - mapping.slide_info.start) + .ok() + .filter(|length| *length <= MAX_SLIDE_INFO_BYTES) + .ok_or(SharedCacheError::Malformed("slide info size"))?; + let bytes = read_vec(&self.reader, mapping.slide_info.start, length)?; + let info = SlideInfoV5::parse(&bytes)?; + if (info.page_count() as u64) + .checked_mul(info.page_size() as u64) + .is_none_or(|covered| covered < mapping.size) + { + return Err(SharedCacheError::Malformed( + "slide info does not cover its mapping", + )); + } + Ok(Some(info)) + } +} + +/// One entry of the main cache's image table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CacheImageEntry { + /// Unslid Mach header address. Aliases share an address. + pub address: u64, + pub path: String, +} + +bitflags::bitflags! { + /// How an image links a dependency (dyld's `LinkedDylibAttributes`); + /// empty for a regular `LC_LOAD_DYLIB`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct DylibAttributes: u8 { + const WEAK = 0x01; + const REEXPORT = 0x02; + const UPWARD = 0x04; + /// Mapped and bound at launch, but initialized only on first use. + const DELAYED_INIT = 0x08; + } +} + +impl DylibAttributes { + /// Decode like dyld's `loadCommandToDylibKind`: a `dylib_use_command`'s + /// flags take precedence over `cmd`. Illegal combinations are fixed as in + /// `JustInTimeLoader`: re-exports are never weak or delayed. A command + /// too short to be a `dylib_use_command` decodes by `cmd`: its name check + /// already rejects one whose `nameoff` claims that layout. + fn decode(command: u32, body: &[u8]) -> Self { + use object::macho; + + let use_command = DylibUseCommand::ref_from_prefix(body) + .ok() + .map(|(use_command, _)| use_command) + .filter(|use_command| { + use_command.marker.get() == DYLIB_USE_MARKER + && use_command.nameoff.get() as usize == size_of::() + }); + let mut attributes = if let Some(use_command) = use_command { + // dyld keeps the low byte (`(uint8_t)dylib2Cmd->flags`). + Self::from_bits_retain(use_command.flags.get().to_le_bytes()[0]) + } else { + match command { + macho::LC_LOAD_WEAK_DYLIB => Self::WEAK, + macho::LC_REEXPORT_DYLIB => Self::REEXPORT, + macho::LC_LOAD_UPWARD_DYLIB => Self::UPWARD, + _ => Self::empty(), + } + }; + if attributes.contains(Self::REEXPORT) { + attributes.remove(Self::WEAK | Self::DELAYED_INIT); + } + attributes + } +} + +/// A dylib load command of a cache image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CacheDependency { + pub path: String, + pub attributes: DylibAttributes, +} + +impl CacheDependency { + /// Decode `LC_LOAD_DYLIB`, `LC_LOAD_WEAK_DYLIB`, `LC_REEXPORT_DYLIB` or + /// `LC_LOAD_UPWARD_DYLIB` of a cache image (`body` is the whole command); + /// `None` for other commands. Cache images are built by Apple's cache + /// builder, so any malformed or non-UTF-8 name is an error. + pub fn from_load_command(command: u32, body: &[u8]) -> Result> { + if !DYLIB_LOAD_COMMANDS.contains(&command) { + return Ok(None); + } + Ok(Some(Self { + path: String::from(dylib_name(body)?), + attributes: DylibAttributes::decode(command, body), + })) + } +} + +/// A `section_64` of a cache image segment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CacheSection { + pub name: String, + /// Unslid address range. + pub address_range: Range, + /// `section_64.flags`: section type and attributes. + pub flags: u32, +} + +impl CacheSection { + pub const INSTRUCTION_ATTRIBUTES: u32 = + object::macho::S_ATTR_PURE_INSTRUCTIONS | object::macho::S_ATTR_SOME_INSTRUCTIONS; + + /// Whether the section contains machine instructions. + pub fn has_instructions(&self) -> bool { + self.flags & Self::INSTRUCTION_ATTRIBUTES != 0 + } +} + +/// An `LC_SEGMENT_64` of a cache image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CacheSegment { + pub name: String, + /// Unslid address range. + pub address_range: Range, + pub max_protection: VmProtection, + pub initial_protection: VmProtection, + pub sections: Vec, +} + +/// The load commands of one cache image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CacheImage { + /// Unslid Mach header address. + pub address: u64, + /// `MH_DYLIB` or `MH_DYLINKER`. + pub file_type: u32, + pub uuid: Option<[u8; 16]>, + /// `LC_ID_DYLIB` name; dyld has none. + pub install_name: Option, + pub segments: Vec, + pub dependencies: Vec, +} + +impl CacheImage { + /// Parse a Mach header and its load commands (`bytes` starts at the header). + fn parse(address: u64, bytes: &[u8]) -> Result { + use object::macho; + + let header: &MachHeader64 = record(bytes, 0)?; + if header.magic.get() != macho::MH_MAGIC_64 || header.cputype.get() != macho::CPU_TYPE_ARM64 + { + return Err(SharedCacheError::Malformed( + "cache image is not an arm64 Mach-O", + )); + } + let file_type = header.filetype.get(); + if !matches!(file_type, macho::MH_DYLIB | macho::MH_DYLINKER) { + return Err(SharedCacheError::Malformed( + "cache image is not a dylib or dyld", + )); + } + let command_count = header.ncmds.get(); + let commands_end = size_of::() + header.sizeofcmds.get() as usize; + if commands_end > bytes.len() { + return Err(SharedCacheError::Malformed( + "load commands exceed the image", + )); + } + let mut image = Self { + address, + file_type, + uuid: None, + install_name: None, + segments: Vec::new(), + dependencies: Vec::new(), + }; + let mut offset = size_of::(); + for _ in 0..command_count { + // Like dyld's forEachLoadCommand: a command must start before + // the end of sizeofcmds. + if offset >= commands_end { + return Err(SharedCacheError::Malformed("load command count")); + } + let load_command: &LoadCommand = record(&bytes[..commands_end], offset)?; + let command = load_command.cmd.get(); + let size = load_command.cmdsize.get() as usize; + let end = offset + .checked_add(size) + .filter(|end| size >= 8 && size.is_multiple_of(8) && *end <= commands_end) + .ok_or(SharedCacheError::Malformed("load command size"))?; + let body = &bytes[offset..end]; + match command { + macho::LC_SEGMENT_64 => image.segments.push(segment(body)?), + macho::LC_UUID => { + let Ok((uuid, _)) = UuidCommand::ref_from_prefix(body) else { + return Err(SharedCacheError::Malformed("LC_UUID")); + }; + if image.uuid.replace(uuid.uuid).is_some() { + return Err(SharedCacheError::Malformed("LC_UUID")); + } + } + macho::LC_ID_DYLIB => { + if image.install_name.is_some() { + return Err(SharedCacheError::Malformed("duplicate LC_ID_DYLIB")); + } + image.install_name = Some(String::from(dylib_name(body)?)); + } + // Like dyld's forEachLinkedDylib, dyld itself links nothing. + _ if file_type == macho::MH_DYLIB => { + image + .dependencies + .extend(CacheDependency::from_load_command(command, body)?); + } + _ => {} + } + offset = end; + } + if offset != commands_end { + return Err(SharedCacheError::Malformed("load command count")); + } + Ok(image) + } + + /// The segment named `name`, e.g. `__TEXT`. + pub fn segment(&self, name: &str) -> Option<&CacheSegment> { + self.segments.iter().find(|segment| segment.name == name) + } +} + +/// A fixed 16-byte, NUL-padded Mach-O name. +fn fixed_name(name: &[u8; 16]) -> Result { + let length = name + .iter() + .position(|&byte| byte == 0) + .unwrap_or(name.len()); + core::str::from_utf8(&name[..length]) + .map(String::from) + .map_err(|_| SharedCacheError::Malformed("segment or section name")) +} + +fn address_range(address: u64, size: u64) -> Result> { + Ok(address + ..address + .checked_add(size) + .ok_or(SharedCacheError::Malformed("segment address range"))?) +} + +fn segment(body: &[u8]) -> Result { + let Ok((command, rest)) = SegmentCommand64::ref_from_prefix(body) else { + return Err(SharedCacheError::Malformed("LC_SEGMENT_64 size")); + }; + let segment_range = address_range(command.vmaddr.get(), command.vmsize.get())?; + let Ok((sections, _)) = + <[Section64]>::ref_from_prefix_with_elems(rest, command.nsects.get() as usize) + else { + return Err(SharedCacheError::Malformed("LC_SEGMENT_64 sections")); + }; + let sections = sections + .iter() + .map(|section| { + let range = address_range(section.addr.get(), section.size.get())?; + if range.start < segment_range.start || range.end > segment_range.end { + return Err(SharedCacheError::Malformed("section outside its segment")); + } + Ok(CacheSection { + name: fixed_name(§ion.sectname)?, + address_range: range, + flags: section.flags.get(), + }) + }) + .collect::>>()?; + Ok(CacheSegment { + name: fixed_name(&command.segname)?, + address_range: segment_range, + max_protection: protection(command.maxprot.get())?, + initial_protection: protection(command.initprot.get())?, + sections, + }) +} + +/// The `lc_str` name of a `dylib_command`. +fn dylib_name(body: &[u8]) -> Result<&str> { + let Ok((command, _)) = DylibCommand::ref_from_prefix(body) else { + return Err(SharedCacheError::Malformed("dylib command size")); + }; + let offset = command.name_offset.get() as usize; + if offset < size_of::() { + return Err(SharedCacheError::Malformed("dylib name offset")); + } + c_string(body, offset, body.len()) +} + +/// A main cache file and its subcaches, verified to form one cache. +pub struct SharedCache { + files: Vec>, + images: Vec, +} + +impl core::fmt::Debug for SharedCache { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SharedCache") + .field("files", &self.files) + .field("image_count", &self.images.len()) + .finish_non_exhaustive() + } +} + +impl SharedCache { + /// Combine `main` with `subcaches` in [`CacheFile::subcache_entries`] order. + /// + /// Verifies each subcache's UUID and placement against the main cache's + /// subcache table, that every mapping lies inside the shared region and + /// no two mappings overlap, and parses the image table. + pub fn new(main: CacheFile, subcaches: Vec>) -> Result { + let entries = main.subcache_entries()?; + if entries.len() != subcaches.len() { + return Err(SharedCacheError::Malformed("subcache count")); + } + let start = main.header.shared_region_start; + let region = start + ..start + .checked_add(main.header.shared_region_size) + .ok_or(SharedCacheError::Malformed("shared region size"))?; + for (index, (entry, subcache)) in entries.iter().zip(&subcaches).enumerate() { + if subcache.header.magic != main.header.magic { + return Err(SharedCacheError::Malformed("subcache magic")); + } + if subcache.header.uuid != entry.uuid { + return Err(SharedCacheError::SubcacheUuidMismatch { index }); + } + if start.checked_add(entry.cache_vm_offset) != Some(subcache.mappings[0].address) { + return Err(SharedCacheError::Malformed("subcache placement")); + } + } + let mut files = vec![main]; + files.extend(subcaches); + let mut ranges: Vec<_> = files + .iter() + .flat_map(|file| file.mappings.iter().map(CacheMapping::address_range)) + .collect(); + ranges.sort_unstable_by_key(|range| range.start); + if ranges + .iter() + .any(|range| range.start < region.start || range.end > region.end) + || ranges.windows(2).any(|pair| pair[0].end > pair[1].start) + { + return Err(SharedCacheError::Malformed( + "mappings outside the region or overlapping", + )); + } + let images = Self::parse_images(&files[0])?; + Ok(Self { files, images }) + } + + fn parse_images(main: &CacheFile) -> Result> { + let table = main.header.images; + if table.count > MAX_IMAGES { + return Err(SharedCacheError::Malformed("image count")); + } + read_records::(&main.reader, table.offset, table.count)? + .iter() + .map(|entry| { + Ok(CacheImageEntry { + address: entry.address.get(), + path: read_path(&main.reader, u64::from(entry.path_file_offset.get()))?, + }) + }) + .collect() + } + + /// The main cache file followed by its subcaches. + pub fn files(&self) -> &[CacheFile] { + &self.files + } + + pub fn header(&self) -> &CacheHeader { + &self.files[0].header + } + + pub fn images(&self) -> &[CacheImageEntry] { + &self.images + } + + /// The first image table entry with exactly `path`. + pub fn image_index_by_path(&self, path: &str) -> Option { + self.images.iter().position(|image| image.path == path) + } + + /// The first image table entry at unslid `address`. + pub fn image_index_by_address(&self, address: u64) -> Option { + self.images + .iter() + .position(|image| image.address == address) + } + + /// The in-cache dyld named by `dyldInCacheMH`, if the cache has one. + pub fn dyld_image_index(&self) -> Option { + match self.header().dyld_in_cache_mh { + 0 => None, + address => self.image_index_by_address(address), + } + } + + /// The file index and mapping containing `[address, address + length)`. + pub fn mapping_containing(&self, address: u64, length: u64) -> Option<(usize, &CacheMapping)> { + let end = address.checked_add(length)?; + self.files.iter().enumerate().find_map(|(index, file)| { + file.mappings + .iter() + .find(|mapping| { + let range = mapping.address_range(); + range.start <= address && end <= range.end + }) + .map(|mapping| (index, mapping)) + }) + } + + /// Read `length` bytes at unslid `address` from the file backing it. + pub fn read_at_address(&self, address: u64, length: usize) -> Result> { + let unmapped = SharedCacheError::Unmapped { + address, + length: length as u64, + }; + let (file, mapping) = self + .mapping_containing(address, length as u64) + .ok_or(unmapped)?; + read_vec( + &self.files[file].reader, + mapping.file_offset + (address - mapping.address), + length, + ) + } + + /// Parse the load commands of the image whose Mach header is at `address`. + /// Every failure is a [`SharedCacheError::Image`] naming `address`. + pub fn parse_image(&self, address: u64) -> Result { + self.parse_image_inner(address) + .map_err(|source| SharedCacheError::Image { + address, + source: Box::new(source), + }) + } + + fn parse_image_inner(&self, address: u64) -> Result { + let header = self.read_at_address(address, size_of::())?; + let commands = record::(&header, 0)?.sizeofcmds.get(); + if commands > MAX_LOAD_COMMAND_BYTES { + return Err(SharedCacheError::Malformed("load command size")); + } + let bytes = self.read_at_address(address, size_of::() + commands as usize)?; + CacheImage::parse(address, &bytes) + } +} + +/// Parsed `dyld_cache_slide_info5` for one mapping. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SlideInfoV5 { + page_size: usize, + value_add: u64, + page_starts: Vec, +} + +impl SlideInfoV5 { + /// Parse a slide info blob. Versions other than 5 are rejected. + pub fn parse(bytes: &[u8]) -> Result { + // Every slide info version starts with its `uint32_t version`, and + // older versions have shorter headers. + if u32_at(bytes, 0)? != 5 { + return Err(SharedCacheError::Unsupported( + "slide info version other than 5", + )); + } + let Ok((header, rest)) = DyldCacheSlideInfo5::ref_from_prefix(bytes) else { + return Err(SharedCacheError::Malformed("truncated structure")); + }; + let page_size = header.page_size.get() as usize; + if !matches!(page_size, 0x1000 | 0x4000) { + return Err(SharedCacheError::Malformed("slide info page size")); + } + let Ok((starts, _)) = + <[U16]>::ref_from_prefix_with_elems(rest, header.page_starts_count.get() as usize) + else { + return Err(SharedCacheError::Malformed("slide info page starts")); + }; + Ok(Self { + page_size, + value_add: header.value_add.get(), + page_starts: starts.iter().map(|start| start.get()).collect(), + }) + } + + pub fn page_size(&self) -> usize { + self.page_size + } + + /// Number of pages described; page `i` starts at mapping address + `i * page_size`. + pub fn page_count(&self) -> usize { + self.page_starts.len() + } + + pub fn value_add(&self) -> u64 { + self.value_add + } + + /// Byte offset of the first rebase in `page_index`, or `None` for a page + /// without rebases. + pub fn page_start(&self, page_index: usize) -> Result> { + match self.page_starts.get(page_index) { + None => Err(SharedCacheError::InvalidArgument( + "slide page index beyond the slide info", + )), + Some(&SLIDE_V5_PAGE_NO_REBASE) => Ok(None), + Some(&start) => Ok(Some(start)), + } + } +} + +/// The main cache path recorded in a `DyldSharedCache::DynamicRegion` +/// (version 2 or later, magic `dyld_data v`). +pub fn dynamic_region_cache_path(region: &[u8]) -> Result<&str> { + const MAGIC_PREFIX: &[u8; 14] = b"dyld_data v"; + const CACHE_PATH_OFFSET: usize = 36; + + let magic: [u8; 16] = field(region, 0)?; + if magic[..14] != MAGIC_PREFIX[..] || magic[15] != 0 { + return Err(SharedCacheError::Malformed("dynamic region magic")); + } + let version = magic[14].wrapping_sub(b'0'); + if !(2..=9).contains(&version) { + return Err(SharedCacheError::Unsupported("dynamic region version")); + } + let offset = u32_at(region, CACHE_PATH_OFFSET)? as usize; + if offset == 0 { + return Err(SharedCacheError::Malformed( + "dynamic region has no cache path", + )); + } + let limit = region.len().min(DYNAMIC_REGION_SIZE); + c_string(region, offset, limit) +} + +#[cfg(test)] +mod tests; diff --git a/litebox_common_macos/src/shared_cache/tests.rs b/litebox_common_macos/src/shared_cache/tests.rs new file mode 100644 index 0000000000..7ecb4d4a6d --- /dev/null +++ b/litebox_common_macos/src/shared_cache/tests.rs @@ -0,0 +1,856 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Synthetic-buffer tests for shared-cache parsing. +#![allow(clippy::cast_possible_truncation)] + +use super::*; +use alloc::{format, vec}; +use object::macho; + +const REGION_START: u64 = 0x1_8000_0000; +const REGION_SIZE: u64 = 0x4_0000; +const TEXT_ADDRESS: u64 = REGION_START + 0x4000; +const TEXT_SIZE: u64 = 0x8000; +const DATA_ADDRESS: u64 = REGION_START + 0x1_0000; +const DATA_HEADER_SIZE: u64 = 0x4000; +const DATA_SIZE: u64 = 0x4000; +const DYNAMIC_DATA_OFFSET: u64 = 0x3_c000; +const MAIN_UUID: [u8; 16] = [0x11; 16]; +const TEXT_UUID: [u8; 16] = [0x22; 16]; +const DATA_UUID: [u8; 16] = [0x33; 16]; +const MAPPINGS: usize = 0x228; +const SUBCACHES: usize = 0x260; +const IMAGES: usize = 0x300; +const PATHS: usize = 0x800; +const RX: u32 = 5; +const RW: u32 = 3; + +fn put32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} +fn put64(bytes: &mut [u8], offset: usize, value: u64) { + bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +/// (address, size, file offset, slide info file range, flags, protection). +type MappingSpec = (u64, u64, u64, Range, u64, u32); + +/// A cache file header with `mappings` at [`MAPPINGS`]. +fn cache_file(size: usize, uuid: [u8; 16], mappings: &[MappingSpec]) -> Vec { + let mut file = vec![0; size]; + file[..16].copy_from_slice(&CACHE_MAGIC_ARM64E); + put32(&mut file, HEADER_MAPPING_OFFSET, MAPPINGS as u32); + file[HEADER_UUID..HEADER_UUID + 16].copy_from_slice(&uuid); + put64(&mut file, HEADER_SHARED_REGION_START, REGION_START); + put64(&mut file, HEADER_SHARED_REGION_SIZE, REGION_SIZE); + put64(&mut file, HEADER_MAX_SLIDE, 0x1000_0000); + put32(&mut file, HEADER_MAPPING_WITH_SLIDE_OFFSET, MAPPINGS as u32); + put32( + &mut file, + HEADER_MAPPING_WITH_SLIDE_OFFSET + 4, + mappings.len() as u32, + ); + put64(&mut file, HEADER_DYNAMIC_DATA_OFFSET, DYNAMIC_DATA_OFFSET); + put64(&mut file, HEADER_DYNAMIC_DATA_MAX_SIZE, 0x4000); + for (index, (address, size, offset, slide, flags, protection)) in mappings.iter().enumerate() { + let entry = MAPPINGS + index * size_of::(); + put64(&mut file, entry, *address); + put64(&mut file, entry + 8, *size); + put64(&mut file, entry + 16, *offset); + put64(&mut file, entry + 24, slide.start); + put64(&mut file, entry + 32, slide.end - slide.start); + put64(&mut file, entry + 40, *flags); + put32(&mut file, entry + 48, *protection); + put32(&mut file, entry + 52, *protection); + } + file +} + +/// One test image: (path, address, file type, dependencies). A dependency's +/// command is a load command, or [`USE`] plus `dylib_use_command` flags. +type ImageSpec<'a> = (&'a str, u64, u32, &'a [(&'a str, u32)]); + +/// Marks a test dependency as a `dylib_use_command` with these flags. +const USE: u32 = 0x100; + +/// `__TEXT,__text` relative to each synthetic image's Mach header. +const TEXT_SECTION: core::ops::Range = 0x100..0x800; + +/// Images follow the TEXT subcache's own header. +fn image_address(index: u64) -> u64 { + TEXT_ADDRESS + 0x1000 * (index + 1) +} + +const LIBSYSTEM: &str = "/usr/lib/libSystem.B.dylib"; +const IMAGES_SPEC: &[ImageSpec<'static>] = &[ + ( + LIBSYSTEM, + TEXT_ADDRESS + 0x1000, + macho::MH_DYLIB, + &[ + ("/usr/lib/system/libb.dylib", macho::LC_REEXPORT_DYLIB), + ("/usr/lib/libc_alias.dylib", macho::LC_LOAD_DYLIB), + ("/usr/lib/missing.dylib", macho::LC_LOAD_WEAK_DYLIB), + ("/usr/lib/libe.dylib", macho::LC_LOAD_UPWARD_DYLIB), + // Delayed-init targets are not initialized at launch. + ("/usr/lib/libunused.dylib", USE | 0x08), + // Re-exports are never delayed. + ("/usr/lib/libf.dylib", USE | 0x0a), + ], + ), + // Cycle back to libSystem. + ( + "/usr/lib/system/libb.dylib", + TEXT_ADDRESS + 0x2000, + macho::MH_DYLIB, + &[(LIBSYSTEM, macho::LC_LOAD_DYLIB)], + ), + // Self-cycle; also reached through an alias. + ( + "/usr/lib/libc.dylib", + TEXT_ADDRESS + 0x3000, + macho::MH_DYLIB, + &[("/usr/lib/libc.dylib", macho::LC_LOAD_DYLIB)], + ), + ( + "/usr/lib/libe.dylib", + TEXT_ADDRESS + 0x4000, + macho::MH_DYLIB, + &[], + ), + // dyld's own dylib commands are ignored, like dyld's forEachLinkedDylib. + ( + "/usr/lib/dyld", + TEXT_ADDRESS + 0x5000, + macho::MH_DYLINKER, + &[("/usr/lib/libunused.dylib", macho::LC_LOAD_DYLIB)], + ), + // Not reachable from the roots. + ( + "/usr/lib/libunused.dylib", + TEXT_ADDRESS + 0x6000, + macho::MH_DYLIB, + &[("/usr/lib/not-in-cache.dylib", macho::LC_LOAD_DYLIB)], + ), + ("/usr/lib/libc_alias.dylib", TEXT_ADDRESS + 0x3000, 0, &[]), + ( + "/usr/lib/libf.dylib", + TEXT_ADDRESS + 0x7000, + macho::MH_DYLIB, + &[], + ), +]; + +/// Write a Mach-O header with `__TEXT`, `LC_UUID`, `LC_ID_DYLIB` (dylibs only) +/// and dylib load commands at `offset`. +fn write_image(file: &mut [u8], offset: usize, image: &ImageSpec<'_>, uuid: u8) { + let (path, address, file_type, dependencies) = *image; + let mut commands = Vec::new(); + let mut segment = vec![0; size_of::() + size_of::()]; + put32(&mut segment, 0, macho::LC_SEGMENT_64); + put32( + &mut segment, + 4, + (size_of::() + size_of::()) as u32, + ); + segment[8..14].copy_from_slice(b"__TEXT"); + put64(&mut segment, 24, address); + put64(&mut segment, 32, 0x1000); + put32(&mut segment, 56, RX); + put32(&mut segment, 60, RX); + put32(&mut segment, 64, 1); + let section = &mut segment[size_of::()..]; + section[..6].copy_from_slice(b"__text"); + section[16..22].copy_from_slice(b"__TEXT"); + put64(section, 32, address + TEXT_SECTION.start); + put64(section, 40, TEXT_SECTION.end - TEXT_SECTION.start); + put32( + section, + 64, + macho::S_ATTR_PURE_INSTRUCTIONS | macho::S_ATTR_SOME_INSTRUCTIONS, + ); + commands.push(segment); + let mut uuid_command = vec![0; size_of::()]; + put32(&mut uuid_command, 0, macho::LC_UUID); + put32(&mut uuid_command, 4, size_of::() as u32); + uuid_command[8..24].fill(uuid); + commands.push(uuid_command); + let dylib = |raw: u32, name: &str| { + let (command, name_offset) = if raw & USE == 0 { + (raw, size_of::()) + } else { + (macho::LC_LOAD_DYLIB, size_of::()) + }; + let size = (name_offset + name.len() + 1).next_multiple_of(8); + let mut bytes = vec![0; size]; + put32(&mut bytes, 0, command); + put32(&mut bytes, 4, size as u32); + put32(&mut bytes, 8, name_offset as u32); + if name_offset == size_of::() { + put32(&mut bytes, 12, DYLIB_USE_MARKER); + put32(&mut bytes, 24, raw & !USE); + } + bytes[name_offset..name_offset + name.len()].copy_from_slice(name.as_bytes()); + bytes + }; + if file_type == macho::MH_DYLIB { + commands.push(dylib(macho::LC_ID_DYLIB, path)); + } + for (dependency, command) in dependencies { + commands.push(dylib(*command, dependency)); + } + let header = &mut file[offset..offset + size_of::()]; + put32(header, 0, macho::MH_MAGIC_64); + put32(header, 4, macho::CPU_TYPE_ARM64); + put32(header, 12, file_type); + put32(header, 16, commands.len() as u32); + put32( + header, + 20, + commands.iter().map(Vec::len).sum::() as u32, + ); + let mut cursor = offset + size_of::(); + for command in commands { + file[cursor..cursor + command.len()].copy_from_slice(&command); + cursor += command.len(); + } +} + +/// A slide-info v5 blob for `page_starts` with `value_add`. +fn slide_info(page_size: u32, value_add: u64, page_starts: &[u16]) -> Vec { + let mut bytes = vec![0; size_of::() + 2 * page_starts.len()]; + put32(&mut bytes, 0, 5); + put32(&mut bytes, 4, page_size); + put32(&mut bytes, 8, page_starts.len() as u32); + put64(&mut bytes, 16, value_add); + for (index, start) in page_starts.iter().enumerate() { + bytes[size_of::() + 2 * index..][..2] + .copy_from_slice(&start.to_le_bytes()); + } + bytes +} + +struct Files { + main: Vec, + text: Vec, + data: Vec, +} + +fn files() -> Files { + let mut main = cache_file(0x4000, MAIN_UUID, &[(REGION_START, 0x4000, 0, 0..0, 0, RX)]); + put64(&mut main, HEADER_DYLD_IN_CACHE_MH, image_address(4)); + put32(&mut main, HEADER_SUBCACHE_ARRAY_OFFSET, SUBCACHES as u32); + put32(&mut main, HEADER_SUBCACHE_ARRAY_OFFSET + 4, 2); + for (index, (uuid, address, suffix)) in [ + (TEXT_UUID, TEXT_ADDRESS, ".01"), + (DATA_UUID, DATA_ADDRESS, ".02.dylddata"), + ] + .iter() + .enumerate() + { + let entry = SUBCACHES + index * size_of::(); + main[entry..entry + 16].copy_from_slice(uuid); + put64(&mut main, entry + 16, address - REGION_START); + main[entry + 24..entry + 24 + suffix.len()].copy_from_slice(suffix.as_bytes()); + } + put32(&mut main, HEADER_IMAGES_OFFSET, IMAGES as u32); + put32( + &mut main, + HEADER_IMAGES_OFFSET + 4, + IMAGES_SPEC.len() as u32, + ); + let mut path_offset = PATHS; + for (index, (path, address, ..)) in IMAGES_SPEC.iter().enumerate() { + let entry = IMAGES + index * size_of::(); + put64(&mut main, entry, *address); + put32(&mut main, entry + 24, path_offset as u32); + main[path_offset..path_offset + path.len()].copy_from_slice(path.as_bytes()); + path_offset += path.len() + 1; + } + + let mut text = cache_file( + TEXT_SIZE as usize, + TEXT_UUID, + &[(TEXT_ADDRESS, TEXT_SIZE, 0, 0..0, 0, RX)], + ); + for (index, image) in IMAGES_SPEC.iter().enumerate() { + if image.2 != 0 { + let offset = (image.1 - TEXT_ADDRESS) as usize; + write_image(&mut text, offset, image, index as u8); + } + } + // Header mapping, then one DATA mapping whose slide info follows it. + let slide_offset = DATA_HEADER_SIZE + DATA_SIZE; + let info = slide_info(0x4000, 0, &[SLIDE_V5_PAGE_NO_REBASE]); + let mut data = cache_file( + (slide_offset as usize) + info.len(), + DATA_UUID, + &[ + (DATA_ADDRESS, DATA_HEADER_SIZE, 0, 0..0, 0, RX), + ( + DATA_ADDRESS + DATA_HEADER_SIZE, + DATA_SIZE, + DATA_HEADER_SIZE, + slide_offset..slide_offset + info.len() as u64, + MappingFlags::CONST_DATA.bits(), + RW, + ), + ], + ); + data[slide_offset as usize..].copy_from_slice(&info); + Files { main, text, data } +} + +fn parse(files: &Files) -> Result> { + SharedCache::new( + CacheFile::parse(files.main.as_slice())?, + vec![ + CacheFile::parse(files.text.as_slice())?, + CacheFile::parse(files.data.as_slice())?, + ], + ) +} + +#[test] +fn header_mappings_and_subcache_entries_parse() { + let files = files(); + let main = CacheFile::parse(files.main.as_slice()).unwrap(); + let header = main.header(); + assert_eq!(header.mapping_offset, MAPPINGS as u32); + assert_eq!(header.uuid, MAIN_UUID); + assert_eq!(header.shared_region_start, REGION_START); + assert_eq!(header.shared_region_size, REGION_SIZE); + assert_eq!(header.dyld_in_cache_mh, image_address(4)); + assert_eq!(header.dynamic_data, Some((DYNAMIC_DATA_OFFSET, 0x4000))); + assert_eq!( + main.subcache_entries().unwrap(), + [ + SubcacheEntry { + uuid: TEXT_UUID, + cache_vm_offset: TEXT_ADDRESS - REGION_START, + file_suffix: ".01".into(), + }, + SubcacheEntry { + uuid: DATA_UUID, + cache_vm_offset: DATA_ADDRESS - REGION_START, + file_suffix: ".02.dylddata".into(), + }, + ] + ); + let data = CacheFile::parse(files.data.as_slice()).unwrap(); + let mapping = &data.mappings()[1]; + assert_eq!( + mapping.address_range(), + DATA_ADDRESS + DATA_HEADER_SIZE..DATA_ADDRESS + DATA_HEADER_SIZE + DATA_SIZE + ); + assert_eq!( + mapping.file_range(), + DATA_HEADER_SIZE..DATA_HEADER_SIZE + DATA_SIZE + ); + assert_eq!(mapping.flags, MappingFlags::CONST_DATA); + assert_eq!( + mapping.initial_protection, + VmProtection::READ | VmProtection::WRITE + ); + let info = data.slide_info(mapping).unwrap().unwrap(); + assert_eq!((info.page_size(), info.page_count()), (0x4000, 1)); + assert_eq!(info.page_start(0), Ok(None)); + assert_eq!(data.slide_info(&data.mappings()[0]), Ok(None)); + + let cache = parse(&files).unwrap(); + assert_eq!(cache.files().len(), 3); + assert_eq!(cache.images().len(), IMAGES_SPEC.len()); + assert_eq!( + cache.image_index_by_path("/usr/lib/libc_alias.dylib"), + Some(6) + ); + assert_eq!(cache.dyld_image_index(), Some(4)); + let (file, mapping) = cache.mapping_containing(image_address(2), 8).unwrap(); + assert_eq!((file, mapping.address), (1, TEXT_ADDRESS)); + assert_eq!( + cache.read_at_address(TEXT_ADDRESS, 16).unwrap(), + CACHE_MAGIC_ARM64E + ); + assert!( + cache + .mapping_containing(TEXT_ADDRESS + TEXT_SIZE - 4, 8) + .is_none() + ); + assert_eq!( + cache.read_at_address(REGION_START + 0x3_f000, 1), + Err(SharedCacheError::Unmapped { + address: REGION_START + 0x3_f000, + length: 1 + }) + ); + let image = cache.parse_image(image_address(0)).unwrap(); + assert_eq!(image.install_name.as_deref(), Some(LIBSYSTEM)); + assert_eq!(image.uuid, Some([0; 16])); + let text = image.segment("__TEXT").unwrap(); + assert_eq!( + text.address_range, + image_address(0)..image_address(0) + 0x1000 + ); + let [section] = text.sections.as_slice() else { + panic!("__TEXT has one section") + }; + assert_eq!(section.name, "__text"); + assert_eq!( + section.address_range, + image_address(0) + TEXT_SECTION.start..image_address(0) + TEXT_SECTION.end + ); + assert!(section.has_instructions()); + assert_eq!( + image + .dependencies + .iter() + .map(|dependency| dependency.attributes) + .collect::>(), + [ + DylibAttributes::REEXPORT, + DylibAttributes::empty(), + DylibAttributes::WEAK, + DylibAttributes::UPWARD, + DylibAttributes::DELAYED_INIT, + DylibAttributes::REEXPORT, + ] + ); + let dyld = cache.parse_image(image_address(4)).unwrap(); + assert_eq!( + (dyld.file_type, dyld.install_name), + (macho::MH_DYLINKER, None) + ); + assert_eq!(dyld.dependencies, Vec::new()); +} + +#[test] +fn malformed_cache_files_are_rejected() { + let reject = |mutate: &dyn Fn(&mut Files)| { + let mut files = files(); + mutate(&mut files); + parse(&files).expect_err("malformed cache must be rejected") + }; + assert_eq!( + reject(&|files| files.main.truncate(0x100)), + SharedCacheError::Read { + offset: 0, + length: MAPPINGS + } + ); + assert_eq!( + reject(&|files| files.main[7] = b'2'), + SharedCacheError::Unsupported("cache magic is not dyld_v1 arm64e") + ); + assert_eq!( + reject(&|files| put32( + &mut files.main, + HEADER_MAPPING_OFFSET, + HEADER_CACHE_SUB_TYPE as u32 + )), + SharedCacheError::Unsupported("legacy cache header") + ); + assert_eq!( + reject(&|files| put32(&mut files.main, HEADER_MAPPING_OFFSET, u32::MAX)), + SharedCacheError::Malformed("header size") + ); + // Mapping file range past the end of the file. + assert_eq!( + reject(&|files| put64(&mut files.text, MAPPINGS + 8, TEXT_SIZE + 1)), + SharedCacheError::Malformed("mapping file range") + ); + // Address overflow. + assert_eq!( + reject(&|files| put64(&mut files.text, MAPPINGS, u64::MAX - 1)), + SharedCacheError::Malformed("mapping address range") + ); + // Overlapping mappings inside one file and across files. + assert_eq!( + reject(&|files| put64(&mut files.data, MAPPINGS + 56, DATA_ADDRESS)), + SharedCacheError::Malformed("unsorted or overlapping mappings") + ); + assert_eq!( + reject(&|files| { + put64(&mut files.text, MAPPINGS, REGION_START); + put64(&mut files.main, SUBCACHES + 16, 0); + }), + SharedCacheError::Malformed("mappings outside the region or overlapping") + ); + // Outside the shared region. + assert_eq!( + reject(&|files| { + put64(&mut files.main, HEADER_SHARED_REGION_SIZE, 0x8000); + }), + SharedCacheError::Malformed("mappings outside the region or overlapping") + ); + // Unknown protection bits and mapping counts. + assert_eq!( + reject(&|files| put32(&mut files.text, MAPPINGS + 52, 0x10)), + SharedCacheError::Malformed("VM protection") + ); + assert_eq!( + reject(&|files| put32(&mut files.text, HEADER_MAPPING_WITH_SLIDE_OFFSET + 4, 0)), + SharedCacheError::Malformed("mapping count") + ); + assert_eq!( + reject(&|files| files.data[HEADER_UUID] ^= 1), + SharedCacheError::SubcacheUuidMismatch { index: 1 } + ); + assert_eq!( + reject(&|files| put64(&mut files.main, SUBCACHES + 16, 0)), + SharedCacheError::Malformed("subcache placement") + ); + assert_eq!( + reject(&|files| files.main[SUBCACHES + 24..SUBCACHES + 28].copy_from_slice(b"/../")), + SharedCacheError::Malformed("subcache file suffix") + ); + assert_eq!( + reject(&|files| files.main[SUBCACHES + 24..SUBCACHES + 56].fill(b'1')), + SharedCacheError::Malformed("unterminated string") + ); + // Unterminated image path at the end of the main file. + assert_eq!( + reject(&|files| { + let end = files.main.len() - 1; + files.main[end] = b'x'; + put32(&mut files.main, IMAGES + 24, end as u32); + }), + SharedCacheError::Malformed("unterminated string") + ); + assert_eq!( + reject(&|files| put32(&mut files.main, HEADER_IMAGES_OFFSET + 4, u32::MAX)), + SharedCacheError::Malformed("image count") + ); + // Slide info past the end of the file. + assert_eq!( + reject(&|files| files.data.truncate(files.data.len() - 1)), + SharedCacheError::Malformed("slide info file range") + ); +} + +#[test] +fn malformed_images_are_rejected() { + let image_error = |address, source| SharedCacheError::Image { + address, + source: Box::new(source), + }; + let mut files = files(); + let cache = parse(&files).unwrap(); + let address = image_address(0); + // Zero bytes inside an image are not a Mach header. + assert_eq!( + cache.parse_image(address + 0x800), + Err(image_error( + address + 0x800, + SharedCacheError::Malformed("cache image is not an arm64 Mach-O") + )) + ); + let header = (address - TEXT_ADDRESS) as usize; + let command_count = + u32::from_le_bytes(files.text[header + 16..header + 20].try_into().unwrap()); + let first_command = header + size_of::(); + // `__TEXT` (with one section) and `LC_UUID` precede `LC_ID_DYLIB`. + let id = first_command + + size_of::() + + size_of::() + + size_of::(); + for (offset, value, expected) in [ + // Load command size not a multiple of 8, and beyond sizeofcmds. + ( + first_command + 4, + 70, + SharedCacheError::Malformed("load command size"), + ), + ( + first_command + 4, + 0x1000, + SharedCacheError::Malformed("load command size"), + ), + // More commands than sizeofcmds holds, and fewer. + ( + header + 16, + command_count + 1, + SharedCacheError::Malformed("load command count"), + ), + ( + header + 16, + command_count - 1, + SharedCacheError::Malformed("load command count"), + ), + // sizeofcmds beyond the mapping. + ( + header + 20, + 0x10_0000, + SharedCacheError::Unmapped { + address, + length: 0x10_0000 + size_of::() as u64, + }, + ), + // Section count beyond the segment command. + ( + first_command + 64, + 2, + SharedCacheError::Malformed("LC_SEGMENT_64 sections"), + ), + // Section extending beyond its segment. + ( + first_command + size_of::() + 40, + 0x1000, + SharedCacheError::Malformed("section outside its segment"), + ), + // Dylib name offset inside the fixed command fields. + (id + 8, 8, SharedCacheError::Malformed("dylib name offset")), + ] { + let original = files.text.clone(); + put32(&mut files.text, offset, value); + let cache = parse(&files).unwrap(); + assert_eq!( + cache.parse_image(address), + Err(image_error(address, expected)), + "offset {offset:#x} = {value:#x}" + ); + files.text = original; + } + // Missing NUL in an install name. + let size = u32::from_le_bytes(files.text[id + 4..id + 8].try_into().unwrap()) as usize; + files.text[id + size_of::()..id + size].fill(b'a'); + let cache = parse(&files).unwrap(); + assert_eq!( + cache.parse_image(address), + Err(image_error( + address, + SharedCacheError::Malformed("unterminated string") + )) + ); +} + +#[test] +fn slide_v5_parse_rejects_bad_headers() { + let mut wrong_version = slide_info(0x1000, 0, &[0]); + put32(&mut wrong_version, 0, 3); + assert_eq!( + SlideInfoV5::parse(&wrong_version), + Err(SharedCacheError::Unsupported( + "slide info version other than 5" + )) + ); + assert_eq!( + SlideInfoV5::parse(&slide_info(0x2000, 0, &[0])), + Err(SharedCacheError::Malformed("slide info page size")) + ); + let truncated = slide_info(0x1000, 0, &[0, 0]); + assert_eq!( + SlideInfoV5::parse(&truncated[..truncated.len() - 1]), + Err(SharedCacheError::Malformed("slide info page starts")) + ); + assert_eq!( + SlideInfoV5::parse(&truncated[..8]), + Err(SharedCacheError::Malformed("truncated structure")) + ); +} + +#[test] +fn slide_info_must_cover_its_mapping() { + let mut files = files(); + let slide = (DATA_HEADER_SIZE + DATA_SIZE) as usize; + files.data[slide..].copy_from_slice(&slide_info(0x1000, 0, &[SLIDE_V5_PAGE_NO_REBASE])); + let data = CacheFile::parse(files.data.as_slice()).unwrap(); + assert_eq!( + data.slide_info(&data.mappings()[1]), + Err(SharedCacheError::Malformed( + "slide info does not cover its mapping" + )) + ); +} + +#[test] +fn caller_mistakes_are_invalid_arguments() { + let files = files(); + let text = CacheFile::parse(files.text.as_slice()).unwrap(); + let data = CacheFile::parse(files.data.as_slice()).unwrap(); + let foreign = + SharedCacheError::InvalidArgument("mapping is not one of this cache file's mappings"); + assert_eq!(data.slide_info(&text.mappings()[0]), Err(foreign.clone())); + // A mapping like one of this file's, but naming other slide info bytes. + let mut moved = data.mappings()[1].clone(); + moved.slide_info = 0..moved.slide_info.end - moved.slide_info.start; + assert_eq!(data.slide_info(&moved), Err(foreign)); + let info = data.slide_info(&data.mappings()[1]).unwrap().unwrap(); + assert_eq!( + info.page_start(info.page_count()), + Err(SharedCacheError::InvalidArgument( + "slide page index beyond the slide info" + )) + ); +} + +#[test] +fn mapping_ranges_saturate_for_unvalidated_mappings() { + let mapping = CacheMapping { + address: u64::MAX - 1, + size: 0x10, + file_offset: u64::MAX, + slide_info: 0..0, + flags: MappingFlags::empty(), + max_protection: VmProtection::READ, + initial_protection: VmProtection::READ, + }; + assert_eq!(mapping.address_range(), u64::MAX - 1..u64::MAX); + assert_eq!(mapping.file_range(), u64::MAX..u64::MAX); +} + +#[test] +fn debug_output_omits_file_contents() { + let files = files(); + let cache = parse(&files).unwrap(); + let debug = format!("{cache:?}"); + assert!(debug.starts_with("SharedCache { files: [CacheFile { header: CacheHeader {")); + assert!(debug.ends_with(&format!("image_count: {}, .. }}", IMAGES_SPEC.len()))); + assert!(!debug.contains("reader"), "{debug}"); +} + +/// xorshift64, a deterministic generator for the no-panic test. +struct XorShift(u64); + +impl XorShift { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound as u64) as usize + } + + /// Overwrite one to four random bytes or words inside `region` of `bytes` + /// with random or boundary values. + fn mutate(&mut self, bytes: &mut [u8], region: Range) { + for _ in 0..=self.below(4) { + let offset = region.start + self.below(region.len()); + let value = match self.below(4) { + 0 => 0, + 1 => u32::MAX, + 2 => 1 << self.below(32), + _ => self.next() as u32, + }; + let width = if self.below(2) == 0 { 1 } else { 4 }; + let width = width.min(bytes.len() - offset); + bytes[offset..offset + width].copy_from_slice(&value.to_le_bytes()[..width]); + } + } +} + +/// Random bytes and randomly mutated valid inputs fail with errors, never +/// panics. +#[test] +fn random_and_mutated_input_never_panics() { + let mut random = XorShift(0x9e37_79b9_7f4a_7c15); + let valid = files(); + let slide_start = (DATA_HEADER_SIZE + DATA_SIZE) as usize; + for _ in 0..2000 { + let length = random.below(0x400); + let bytes: Vec = (0..length).map(|_| random.next() as u8).collect(); + let _ = CacheHeader::parse(&bytes); + let _ = SlideInfoV5::parse(&bytes); + let _ = CacheFile::parse(bytes.as_slice()); + let _ = CacheFile::parse(&valid.main[..random.below(valid.main.len())]); + + let mut header = valid.main[..MAPPINGS].to_vec(); + random.mutate(&mut header, 0..MAPPINGS); + let _ = CacheHeader::parse(&header); + + let page_size = [0x1000, 0x4000][random.below(2)]; + let starts: Vec = (0..random.below(8)) + .map(|_| match random.below(3) { + 0 => SLIDE_V5_PAGE_NO_REBASE, + 1 => random.below(page_size) as u16 & !7, + _ => random.next() as u16, + }) + .collect(); + let mut info = slide_info(page_size as u32, random.next(), &starts); + let info_length = info.len(); + random.mutate(&mut info, 0..info_length); + let _ = SlideInfoV5::parse(&info[..random.below(info_length + 1)]); + let _ = SlideInfoV5::parse(&info); + + let mut mutated = Files { + main: valid.main.clone(), + text: valid.text.clone(), + data: valid.data.clone(), + }; + random.mutate(&mut mutated.main, 0..PATHS + 0x100); + random.mutate(&mut mutated.data, 0..MAPPINGS + 0x100); + let data_length = mutated.data.len(); + random.mutate(&mut mutated.data, slide_start..data_length); + let image = (IMAGES_SPEC[random.below(IMAGES_SPEC.len())].1 - TEXT_ADDRESS) as usize; + random.mutate(&mut mutated.text, image..image + 0x200); + if let Ok(main) = CacheFile::parse(mutated.main.as_slice()) { + let _ = main.subcache_entries(); + } + if let Ok(data) = CacheFile::parse(mutated.data.as_slice()) { + for mapping in data.mappings() { + let _ = data.slide_info(mapping); + } + } + let text_only = Files { + main: valid.main.clone(), + text: mutated.text.clone(), + data: valid.data.clone(), + }; + for files in [&text_only, &mutated] { + let Ok(cache) = parse(files) else { + continue; + }; + for image in cache.images() { + let _ = cache.parse_image(image.address); + } + let _ = cache.parse_image(TEXT_ADDRESS + random.below(TEXT_SIZE as usize) as u64); + } + } +} + +#[test] +fn dynamic_region_cache_path_is_bounded() { + let mut region = vec![0u8; 0x100]; + region[..16].copy_from_slice(b"dyld_data v3\0"); + put32(&mut region, 36, 0x80); + let path = b"/System/Library/dyld/dyld_shared_cache_arm64e"; + region[0x80..0x80 + path.len()].copy_from_slice(path); + assert_eq!( + dynamic_region_cache_path(®ion), + Ok(core::str::from_utf8(path).unwrap()) + ); + for (offset, bytes, expected) in [ + ( + 0, + &b"dyld_data v1"[..], + SharedCacheError::Unsupported("dynamic region version"), + ), + ( + 0, + b"dyld_dota v3", + SharedCacheError::Malformed("dynamic region magic"), + ), + ( + 36, + &0u32.to_le_bytes(), + SharedCacheError::Malformed("dynamic region has no cache path"), + ), + ( + 36, + &0x101u32.to_le_bytes(), + SharedCacheError::Malformed("string offset"), + ), + ] { + let mut copy = region.clone(); + copy[offset..offset + bytes.len()].copy_from_slice(bytes); + assert_eq!(dynamic_region_cache_path(©), Err(expected), "{offset}"); + } + region[0x80 + path.len()..].fill(b'a'); + assert_eq!( + dynamic_region_cache_path(®ion), + Err(SharedCacheError::Malformed("unterminated string")) + ); +} diff --git a/litebox_common_macos/tests/live_shared_cache.rs b/litebox_common_macos/tests/live_shared_cache.rs new file mode 100644 index 0000000000..830da199ed --- /dev/null +++ b/litebox_common_macos/tests/live_shared_cache.rs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Parse this host's on-disk dyld shared cache, located through the live +//! cache's DynamicRegion. Skips (with a printed reason) if the host does not +//! expose the expected layout or its cache format is unsupported. + +#![cfg(all(target_os = "macos", target_arch = "aarch64"))] + +use litebox_common_macos::shared_cache::{ + CacheFile, CacheFileReader, CacheHeader, DYNAMIC_REGION_SIZE, SharedCache, SharedCacheError, + dynamic_region_cache_path, +}; +use std::os::unix::fs::FileExt as _; + +unsafe extern "C" { + fn _dyld_get_shared_cache_range(length: *mut usize) -> *const u8; + fn mach_task_self() -> u32; + fn mach_vm_read_overwrite( + task: u32, + address: u64, + size: u64, + data: u64, + out_size: *mut u64, + ) -> i32; +} + +struct File(std::fs::File, u64); + +impl CacheFileReader for File { + fn file_len(&self) -> u64 { + self.1 + } + + fn read_exact_at(&self, offset: u64, buffer: &mut [u8]) -> Result<(), SharedCacheError> { + self.0 + .read_exact_at(buffer, offset) + .map_err(|_| SharedCacheError::Read { + offset, + length: buffer.len(), + }) + } +} + +fn open(path: &str) -> File { + let file = std::fs::File::open(path).unwrap_or_else(|error| panic!("{path}: {error}")); + let length = file.metadata().unwrap().len(); + File(file, length) +} + +/// Copy live process memory without faulting on unmapped addresses. +fn read_live(address: usize, length: usize) -> Option> { + let mut bytes = vec![0u8; length]; + let mut copied = 0; + // SAFETY: bytes is writable for `length` bytes; Mach validates the source. + let result = unsafe { + mach_vm_read_overwrite( + mach_task_self(), + address as u64, + length as u64, + bytes.as_mut_ptr() as u64, + &raw mut copied, + ) + }; + (result == 0 && copied == length as u64).then_some(bytes) +} + +/// `Some(value)`, or `None` after printing why the test skips an unsupported +/// cache. Other errors fail the test. +fn supported(result: Result) -> Option { + match result { + Ok(value) => Some(value), + Err(SharedCacheError::Unsupported(reason)) => { + println!("skipped: unsupported shared cache: {reason}"); + None + } + Err(error) => panic!("{error}"), + } +} + +struct LiveCache { + /// The live cache's header. + live: CacheHeader, + /// The on-disk cache the live DynamicRegion names. + cache: SharedCache, +} + +/// Parse the on-disk cache of this process's live shared cache, or `None` +/// after printing why the test skips. +fn live_cache() -> Option { + let mut length = 0; + // SAFETY: length is writable; the call only reports the live cache range. + let base = unsafe { _dyld_get_shared_cache_range(&raw mut length) } as usize; + if base == 0 { + println!("skipped: this process has no dyld shared cache"); + return None; + } + let live = read_live(base, 0x1000).expect("live cache header is readable"); + let live = supported(CacheHeader::parse(&live))?; + let Some((dynamic_offset, _)) = live.dynamic_data else { + println!("skipped: the live cache header has no dynamicDataOffset"); + return None; + }; + let Some(region) = usize::try_from(dynamic_offset) + .ok() + .and_then(|offset| read_live(base.checked_add(offset)?, DYNAMIC_REGION_SIZE)) + else { + println!("skipped: the live DynamicRegion is not readable"); + return None; + }; + let path = match dynamic_region_cache_path(®ion) { + Ok(path) => path.to_owned(), + Err(error) => { + println!("skipped: the live DynamicRegion has no cache path: {error}"); + return None; + } + }; + println!("main cache: {path}"); + + let main = supported(CacheFile::parse(open(&path)))?; + let mut subcaches = Vec::new(); + for entry in main.subcache_entries().unwrap() { + subcaches.push(supported(CacheFile::parse(open(&format!( + "{path}{}", + entry.file_suffix + ))))?); + } + // Verifies every subcache UUID against the main cache's subcache table. + let cache = supported(SharedCache::new(main, subcaches))?; + Some(LiveCache { live, cache }) +} + +#[test] +fn live_host_cache_parses() { + let Some(LiveCache { live, cache }) = live_cache() else { + return; + }; + assert_eq!( + cache.header().uuid, + live.uuid, + "on-disk main cache is the live cache" + ); + for file in cache.files() { + for mapping in file.mappings() { + // Each DATA mapping's slide info is version 5. + if supported(file.slide_info(mapping)).is_none() { + return; + } + } + } +} From ce63423e45aab421c8054ea4e705854bc0d019a5 Mon Sep 17 00:00:00 2001 From: Sangho Lee Date: Wed, 21 Oct 2026 20:04:30 -0700 Subject: [PATCH 2/3] Rebase shared-cache DATA pages with slide info v5 --- litebox_common_macos/src/shared_cache.rs | 114 +++++++++++- .../src/shared_cache/tests.rs | 163 +++++++++++++++++- .../tests/live_shared_cache.rs | 90 +++++++++- 3 files changed, 356 insertions(+), 11 deletions(-) diff --git a/litebox_common_macos/src/shared_cache.rs b/litebox_common_macos/src/shared_cache.rs index 931c79ac87..a1255e44e9 100644 --- a/litebox_common_macos/src/shared_cache.rs +++ b/litebox_common_macos/src/shared_cache.rs @@ -11,8 +11,9 @@ //! be loaded. //! //! Intended use: parse the main cache file with [`CacheFile::parse`], open each -//! [`CacheFile::subcache_entries`] file (main path + suffix), and combine them -//! with [`SharedCache::new`] (which verifies subcache identity and layout). +//! [`CacheFile::subcache_entries`] file (main path + suffix), combine them with +//! [`SharedCache::new`] (which verifies subcache identity and layout), and +//! rebase DATA pages with [`SlideInfoV5::rebase_page`]. use crate::VmProtection; use alloc::{boxed::Box, string::String, vec, vec::Vec}; @@ -258,6 +259,8 @@ pub enum SharedCacheError { /// The caller passed an argument this cache cannot describe. #[error("invalid argument: {0}")] InvalidArgument(&'static str), + #[error("slide chain leaves page {page_index} at offset {offset:#x}")] + ChainOutOfPage { page_index: usize, offset: usize }, } type Result = core::result::Result; @@ -1121,6 +1124,36 @@ impl SharedCache { } } +/// Which A key signs an authenticated pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointerAuthKey { + /// Instruction key A (`pacia`). + Ia, + /// Data key A (`pacda`). + Da, +} + +/// Signs rebased authenticated pointers for the task that will use them. +pub trait PointerSigner { + fn sign(&self, value: u64, key: PointerAuthKey, discriminator: u64) -> u64; +} + +/// Leaves authenticated pointers unsigned, as XNU does for a task without +/// JOP keys (an arm64 process, where pointer authentication is disabled). +#[derive(Clone, Copy, Debug, Default)] +pub struct UnsignedPointers; + +impl PointerSigner for UnsignedPointers { + fn sign(&self, value: u64, _: PointerAuthKey, _: u64) -> u64 { + value + } +} + +/// `__builtin_ptrauth_blend_discriminator(slot_address, diversity)`. +pub const fn blended_discriminator(slot_address: u64, diversity: u16) -> u64 { + (slot_address & 0x0000_ffff_ffff_ffff) | ((diversity as u64) << 48) +} + /// Parsed `dyld_cache_slide_info5` for one mapping. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SlideInfoV5 { @@ -1182,6 +1215,83 @@ impl SlideInfoV5 { Some(&start) => Ok(Some(start)), } } + + /// Rebase one DATA page like xnu `vm_shared_region_slide_page_v5`. + /// + /// `page` holds the page's file bytes, `page_index` is its index within + /// the mapping, `slide` is the instance base minus `sharedRegionStart`, and + /// `page_address` is the page's slid address, used for address-diversified + /// discriminators. `slide` is unsigned, as in XNU, so the instance must lie + /// at or above `sharedRegionStart`. `page` is left unchanged on error. + pub fn rebase_page( + &self, + page: &mut [u8], + page_index: usize, + slide: u64, + page_address: u64, + signer: &impl PointerSigner, + ) -> Result<()> { + const TARGET_MASK: u64 = 0x3_ffff_ffff; + const AUTHENTICATED: u64 = 1 << 63; + const ADDRESS_DIVERSIFIED: u64 = 1 << 50; + const KEY_IS_DATA: u64 = 1 << 51; + + if page.len() != self.page_size { + return Err(SharedCacheError::InvalidArgument( + "slide page length differs from the slide info's page size", + )); + } + let Some(start) = self.page_start(page_index)? else { + return Ok(()); + }; + let overflow = SharedCacheError::Malformed("rebased pointer overflows"); + let mut rebased = page.to_vec(); + let mut offset = usize::from(start); + loop { + let out_of_page = SharedCacheError::ChainOutOfPage { page_index, offset }; + if !offset.is_multiple_of(size_of::()) { + return Err(out_of_page); + } + let slot: [u8; 8] = offset + .checked_add(size_of::()) + .and_then(|end| rebased.get(offset..end)) + .and_then(|slot| slot.try_into().ok()) + .ok_or(out_of_page)?; + let raw = u64::from_le_bytes(slot); + let next = ((raw >> 52) & 0x7ff) as usize; + let target = (raw & TARGET_MASK) + .checked_add(self.value_add) + .and_then(|value| value.checked_add(slide)) + .ok_or(overflow.clone())?; + let value = if raw & AUTHENTICATED == 0 { + let high8 = (raw << 22) & 0xff00_0000_0000_0000; + target.checked_add(high8).ok_or(overflow.clone())? + } else { + let diversity = ((raw >> 34) & 0xffff) as u16; + let discriminator = if raw & ADDRESS_DIVERSIFIED == 0 { + u64::from(diversity) + } else { + let slot_address = page_address + .checked_add(offset as u64) + .ok_or(overflow.clone())?; + blended_discriminator(slot_address, diversity) + }; + let key = if raw & KEY_IS_DATA == 0 { + PointerAuthKey::Ia + } else { + PointerAuthKey::Da + }; + signer.sign(target, key, discriminator) + }; + rebased[offset..offset + size_of::()].copy_from_slice(&value.to_le_bytes()); + if next == 0 { + break; + } + offset += next * size_of::(); + } + page.copy_from_slice(&rebased); + Ok(()) + } } /// The main cache path recorded in a `DyldSharedCache::DynamicRegion` diff --git a/litebox_common_macos/src/shared_cache/tests.rs b/litebox_common_macos/src/shared_cache/tests.rs index 7ecb4d4a6d..4157e9a224 100644 --- a/litebox_common_macos/src/shared_cache/tests.rs +++ b/litebox_common_macos/src/shared_cache/tests.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! Synthetic-buffer tests for shared-cache parsing. +//! Synthetic-buffer tests for shared-cache parsing and v5 rebasing. #![allow(clippy::cast_possible_truncation)] use super::*; @@ -624,8 +624,151 @@ fn malformed_images_are_rejected() { ); } +/// A plain v5 chain entry; `next` counts 8-byte strides. +fn regular(target: u64, high8: u8, next: u64) -> u64 { + target | (u64::from(high8) << 34) | (next << 52) +} + +fn authenticated(target: u64, diversity: u16, address: bool, data: bool, next: u64) -> u64 { + target + | (u64::from(diversity) << 34) + | (u64::from(address) << 50) + | (u64::from(data) << 51) + | (next << 52) + | (1 << 63) +} + +struct RecordingSigner(core::cell::RefCell>); + +impl PointerSigner for RecordingSigner { + fn sign(&self, value: u64, key: PointerAuthKey, discriminator: u64) -> u64 { + self.0.borrow_mut().push((value, key, discriminator)); + value ^ 0x00a5_0000_0000_0000 + } +} + +fn page_with(words: &[(usize, u64)]) -> Vec { + let mut page = vec![0; 0x1000]; + for (offset, value) in words { + put64(&mut page, *offset, *value); + } + page +} + +#[test] +fn slide_v5_rebases_regular_and_authenticated_chains() { + const VALUE_ADD: u64 = 0x1_8000_0000; + const SLIDE: u64 = 0xe_8000_0000; + const PAGE_ADDRESS: u64 = 0x10_0000_4000; + let info = SlideInfoV5::parse(&slide_info( + 0x1000, + VALUE_ADD, + &[SLIDE_V5_PAGE_NO_REBASE, 0x10], + )) + .unwrap(); + assert_eq!(info.value_add(), VALUE_ADD); + let original = page_with(&[ + (0x10, regular(0x1234, 0, 1)), + (0x18, regular(0x2_0000_0000, 0x5a, 2)), + (0x28, authenticated(0x3000, 0xbeef, true, false, 3)), + (0x40, authenticated(0x4000, 0x1234, false, true, 0)), + (0x48, 0xdead_beef), + ]); + let signer = RecordingSigner(core::cell::RefCell::new(Vec::new())); + + let mut page = original.clone(); + info.rebase_page(&mut page, 0, SLIDE, PAGE_ADDRESS, &signer) + .unwrap(); + assert_eq!(page, original, "NO_REBASE pages are unchanged"); + + info.rebase_page(&mut page, 1, SLIDE, PAGE_ADDRESS, &signer) + .unwrap(); + let word = |offset: usize| u64::from_le_bytes(page[offset..offset + 8].try_into().unwrap()); + let base = VALUE_ADD + SLIDE; + assert_eq!(word(0x10), base + 0x1234); + assert_eq!(word(0x18), (base + 0x2_0000_0000) | (0x5a << 56)); + assert_eq!(word(0x28), (base + 0x3000) ^ 0x00a5_0000_0000_0000); + assert_eq!(word(0x40), (base + 0x4000) ^ 0x00a5_0000_0000_0000); + assert_eq!(word(0x48), 0xdead_beef, "chain ended before this slot"); + assert_eq!( + *signer.0.borrow(), + [ + ( + base + 0x3000, + PointerAuthKey::Ia, + blended_discriminator(PAGE_ADDRESS + 0x28, 0xbeef) + ), + (base + 0x4000, PointerAuthKey::Da, 0x1234), + ] + ); + assert_eq!( + blended_discriminator(0xffff_1234_5678_9abc, 0xbeef), + 0xbeef_1234_5678_9abc + ); + + // Unsigned pointers keep authenticated values plain, as XNU without JOP keys. + let mut page = original; + info.rebase_page(&mut page, 1, SLIDE, PAGE_ADDRESS, &UnsignedPointers) + .unwrap(); + assert_eq!( + u64::from_le_bytes(page[0x28..0x30].try_into().unwrap()), + base + 0x3000 + ); +} + #[test] -fn slide_v5_parse_rejects_bad_headers() { +fn slide_v5_rejects_chains_leaving_the_page_and_bad_input() { + let info = SlideInfoV5::parse(&slide_info(0x1000, 0, &[0xff8, 0x10, 0x14, 0x1000])).unwrap(); + // The second pointer would start at 0x1000, past the page. + let original = page_with(&[(0xff8, regular(1, 0, 1))]); + let mut page = original.clone(); + assert_eq!( + info.rebase_page(&mut page, 0, 0, 0, &UnsignedPointers), + Err(SharedCacheError::ChainOutOfPage { + page_index: 0, + offset: 0x1000 + }) + ); + assert_eq!(page, original, "failed rebases leave the page unchanged"); + // A long chain that runs off the page. + let mut page = page_with(&[(0x10, regular(1, 0, 0x7ff))]); + assert_eq!( + info.rebase_page(&mut page, 1, 0, 0, &UnsignedPointers), + Err(SharedCacheError::ChainOutOfPage { + page_index: 1, + offset: 0x10 + 0x7ff * 8 + }) + ); + // Misaligned (with a whole slot inside the page) and out-of-page starts. + for (index, offset) in [(2, 0x14), (3, 0x1000)] { + let mut page = page_with(&[]); + assert_eq!( + info.rebase_page(&mut page, index, 0, 0, &UnsignedPointers), + Err(SharedCacheError::ChainOutOfPage { + page_index: index, + offset + }) + ); + } + // Bad page index, page length and arithmetic overflow. + let mut page = page_with(&[(0x10, regular(1, 0, 0))]); + assert_eq!( + info.rebase_page(&mut page, 4, 0, 0, &UnsignedPointers), + Err(SharedCacheError::InvalidArgument( + "slide page index beyond the slide info" + )) + ); + assert_eq!( + info.rebase_page(&mut page[..0x800], 1, 0, 0, &UnsignedPointers), + Err(SharedCacheError::InvalidArgument( + "slide page length differs from the slide info's page size" + )) + ); + assert_eq!( + info.rebase_page(&mut page, 1, u64::MAX, 0, &UnsignedPointers), + Err(SharedCacheError::Malformed("rebased pointer overflows")) + ); + // Header validation. let mut wrong_version = slide_info(0x1000, 0, &[0]); put32(&mut wrong_version, 0, 3); assert_eq!( @@ -773,7 +916,21 @@ fn random_and_mutated_input_never_panics() { let info_length = info.len(); random.mutate(&mut info, 0..info_length); let _ = SlideInfoV5::parse(&info[..random.below(info_length + 1)]); - let _ = SlideInfoV5::parse(&info); + if let Ok(info) = SlideInfoV5::parse(&info) { + let mut page: Vec = (0..info.page_size()).map(|_| random.next() as u8).collect(); + let length = if random.below(8) == 0 { + random.below(page.len()) + } else { + page.len() + }; + let _ = info.rebase_page( + &mut page[..length], + random.below(info.page_count() + 1), + random.next() >> random.below(64), + random.next(), + &UnsignedPointers, + ); + } let mut mutated = Files { main: valid.main.clone(), diff --git a/litebox_common_macos/tests/live_shared_cache.rs b/litebox_common_macos/tests/live_shared_cache.rs index 830da199ed..854ac34199 100644 --- a/litebox_common_macos/tests/live_shared_cache.rs +++ b/litebox_common_macos/tests/live_shared_cache.rs @@ -2,14 +2,15 @@ // Licensed under the MIT license. //! Parse this host's on-disk dyld shared cache, located through the live -//! cache's DynamicRegion. Skips (with a printed reason) if the host does not -//! expose the expected layout or its cache format is unsupported. +//! cache's DynamicRegion, and check its DATA rebasing against the live cache. +//! Skips (with a printed reason) if the host does not expose the expected +//! layout or its cache format is unsupported. #![cfg(all(target_os = "macos", target_arch = "aarch64"))] use litebox_common_macos::shared_cache::{ - CacheFile, CacheFileReader, CacheHeader, DYNAMIC_REGION_SIZE, SharedCache, SharedCacheError, - dynamic_region_cache_path, + CacheFile, CacheFileReader, CacheHeader, DYNAMIC_REGION_SIZE, MappingFlags, SharedCache, + SharedCacheError, UnsignedPointers, dynamic_region_cache_path, }; use std::os::unix::fs::FileExt as _; @@ -79,6 +80,8 @@ fn supported(result: Result) -> Option { } struct LiveCache { + /// Address of the live cache in this process. + base: usize, /// The live cache's header. live: CacheHeader, /// The on-disk cache the live DynamicRegion names. @@ -127,12 +130,12 @@ fn live_cache() -> Option { } // Verifies every subcache UUID against the main cache's subcache table. let cache = supported(SharedCache::new(main, subcaches))?; - Some(LiveCache { live, cache }) + Some(LiveCache { base, live, cache }) } #[test] fn live_host_cache_parses() { - let Some(LiveCache { live, cache }) = live_cache() else { + let Some(LiveCache { live, cache, .. }) = live_cache() else { return; }; assert_eq!( @@ -149,3 +152,78 @@ fn live_host_cache_parses() { } } } + +#[test] +fn live_host_cache_rebases_const_data_like_the_kernel() { + // Every fourth page of each mapping, which bounds a debug build to a few + // seconds; the whole cache has tens of millions of slots. + const PAGE_STRIDE: usize = 4; + let Some(LiveCache { base, live, cache }) = live_cache() else { + return; + }; + if cache.header().uuid != live.uuid { + println!("skipped: the on-disk main cache is not the live cache"); + return; + } + let slide = (base as u64) + .checked_sub(cache.header().shared_region_start) + .expect("the live cache lies above sharedRegionStart"); + let (mut pages, mut slots, mut authenticated) = (0usize, 0usize, 0usize); + for file in cache.files() { + for mapping in file.mappings() { + if !mapping.flags.contains(MappingFlags::CONST_DATA) { + continue; + } + let Some(info) = supported(file.slide_info(mapping)) else { + return; + }; + let Some(info) = info else { + continue; + }; + let page_size = info.page_size(); + let page_count = usize::try_from(mapping.size).unwrap().div_ceil(page_size); + for page_index in (0..page_count).step_by(PAGE_STRIDE) { + let Some(start) = info.page_start(page_index).unwrap() else { + continue; + }; + let offset = (page_index * page_size) as u64; + let length = page_size.min(usize::try_from(mapping.size - offset).unwrap()); + let mut page = vec![0u8; page_size]; + file.reader() + .read_exact_at(mapping.file_offset + offset, &mut page[..length]) + .unwrap(); + // Count the chain's slots independently of `rebase_page`. + let mut slot = usize::from(start); + loop { + let raw = u64::from_le_bytes(page[slot..slot + 8].try_into().unwrap()); + slots += 1; + authenticated += usize::from(raw >> 63 == 1); + let next = ((raw >> 52) & 0x7ff) as usize; + if next == 0 { + break; + } + slot += next * 8; + } + let address = mapping.address + offset + slide; + // This test process is arm64, which has no JOP keys, so XNU + // leaves its authenticated pointers unsigned + // (`vm_shared_region_slide_page_v5`). + info.rebase_page(&mut page, page_index, slide, address, &UnsignedPointers) + .unwrap(); + let live_page = read_live(usize::try_from(address).unwrap(), length) + .unwrap_or_else(|| panic!("live page at {address:#x} is readable")); + if let Some(at) = (0..length).find(|&at| page[at] != live_page[at]) { + panic!( + "page at {address:#x} differs from the live cache at offset {at:#x}: \ + rebased {:#018x}, live {:#018x}", + u64::from_le_bytes(page[at & !7..(at & !7) + 8].try_into().unwrap()), + u64::from_le_bytes(live_page[at & !7..(at & !7) + 8].try_into().unwrap()), + ); + } + pages += 1; + } + } + } + println!("rebased {pages} pages: {slots} slots, {authenticated} authenticated"); + assert!(slots > 0, "the live cache has no CONST_DATA rebases"); +} From 44b3caad4794a05cdfa7f43a9d5f0ca4eeaf971f Mon Sep 17 00:00:00 2001 From: Sangho Lee Date: Wed, 21 Oct 2026 20:04:30 -0700 Subject: [PATCH 3/3] Compute shared-cache image closures --- litebox_common_macos/src/shared_cache.rs | 91 ++++++++++++++++++- .../src/shared_cache/tests.rs | 57 +++++++++++- .../tests/live_shared_cache.rs | 57 +++++++++++- 3 files changed, 197 insertions(+), 8 deletions(-) diff --git a/litebox_common_macos/src/shared_cache.rs b/litebox_common_macos/src/shared_cache.rs index a1255e44e9..b589e3ec42 100644 --- a/litebox_common_macos/src/shared_cache.rs +++ b/litebox_common_macos/src/shared_cache.rs @@ -12,11 +12,18 @@ //! //! Intended use: parse the main cache file with [`CacheFile::parse`], open each //! [`CacheFile::subcache_entries`] file (main path + suffix), combine them with -//! [`SharedCache::new`] (which verifies subcache identity and layout), and -//! rebase DATA pages with [`SlideInfoV5::rebase_page`]. +//! [`SharedCache::new`] (which verifies subcache identity and layout), compute +//! the libSystem closure with [`SharedCache::closure`], and rebase DATA pages +//! with [`SlideInfoV5::rebase_page`]. use crate::VmProtection; -use alloc::{boxed::Box, string::String, vec, vec::Vec}; +use alloc::{ + boxed::Box, + collections::{BTreeMap, BTreeSet}, + string::String, + vec, + vec::Vec, +}; use core::ops::Range; use zerocopy::{ FromBytes, Immutable, KnownLayout, Unaligned, @@ -259,6 +266,8 @@ pub enum SharedCacheError { /// The caller passed an argument this cache cannot describe. #[error("invalid argument: {0}")] InvalidArgument(&'static str), + #[error("{image} requires {dependency}, which is not in the shared cache")] + MissingDependency { image: String, dependency: String }, #[error("slide chain leaves page {page_index} at offset {offset:#x}")] ChainOutOfPage { page_index: usize, offset: usize }, } @@ -962,6 +971,14 @@ fn dylib_name(body: &[u8]) -> Result<&str> { c_string(body, offset, body.len()) } +/// One image of a dependency closure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClosureImage { + /// Index of the image's first entry in [`SharedCache::images`]. + pub index: usize, + pub image: CacheImage, +} + /// A main cache file and its subcaches, verified to form one cache. pub struct SharedCache { files: Vec>, @@ -1122,6 +1139,74 @@ impl SharedCache { let bytes = self.read_at_address(address, size_of::() + commands as usize)?; CacheImage::parse(address, &bytes) } + + /// The transitive dylib closure of `roots` (image table indices): the + /// images dyld initializes at launch. It follows regular, weak, re-export + /// and upward links, but not [`DylibAttributes::DELAYED_INIT`] links, whose + /// targets dyld initializes only on first use. Aliases resolve to one + /// image, cycles are visited once, and only missing weak dependencies are + /// tolerated. Results are sorted by address. + pub fn closure(&self, roots: &[usize]) -> Result> { + self.closure_with(roots, false) + } + + /// Like [`Self::closure`], but also following + /// [`DylibAttributes::DELAYED_INIT`] links: every image dyld creates a + /// loader for (maps, binds and exposes to debuggers) at launch, including + /// those whose initializers it delays. + pub fn launch_set(&self, roots: &[usize]) -> Result> { + self.closure_with(roots, true) + } + + fn closure_with( + &self, + roots: &[usize], + follow_delayed_init: bool, + ) -> Result> { + let mut pending = Vec::new(); + for &root in roots { + pending.push( + self.images + .get(root) + .ok_or(SharedCacheError::InvalidArgument( + "closure root is not an image table index", + ))? + .address, + ); + } + let mut visited = BTreeMap::new(); + let mut queued: BTreeSet = pending.iter().copied().collect(); + while let Some(address) = pending.pop() { + let index = self + .image_index_by_address(address) + .ok_or(SharedCacheError::Malformed("closure image address"))?; + let image = self.parse_image(address)?; + for dependency in &image.dependencies { + if !follow_delayed_init + && dependency + .attributes + .contains(DylibAttributes::DELAYED_INIT) + { + continue; + } + let Some(target) = self.image_index_by_path(&dependency.path) else { + if dependency.attributes.contains(DylibAttributes::WEAK) { + continue; + } + return Err(SharedCacheError::MissingDependency { + image: self.images[index].path.clone(), + dependency: dependency.path.clone(), + }); + }; + let target = self.images[target].address; + if queued.insert(target) { + pending.push(target); + } + } + visited.insert(address, ClosureImage { index, image }); + } + Ok(visited.into_values().collect()) + } } /// Which A key signs an authenticated pointer. diff --git a/litebox_common_macos/src/shared_cache/tests.rs b/litebox_common_macos/src/shared_cache/tests.rs index 4157e9a224..8b868994be 100644 --- a/litebox_common_macos/src/shared_cache/tests.rs +++ b/litebox_common_macos/src/shared_cache/tests.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! Synthetic-buffer tests for shared-cache parsing and v5 rebasing. +//! Synthetic-buffer tests for shared-cache parsing, closure and v5 rebasing. #![allow(clippy::cast_possible_truncation)] use super::*; @@ -424,6 +424,61 @@ fn header_mappings_and_subcache_entries_parse() { assert_eq!(dyld.dependencies, Vec::new()); } +#[test] +fn closure_follows_all_dylib_commands_across_cycles_and_aliases() { + let files = files(); + let cache = parse(&files).unwrap(); + let roots = [ + cache.image_index_by_path(LIBSYSTEM).unwrap(), + cache.dyld_image_index().unwrap(), + ]; + let closure = cache.closure(&roots).unwrap(); + assert_eq!( + closure + .iter() + .map(|image| cache.images()[image.index].path.as_str()) + .collect::>(), + [ + LIBSYSTEM, + "/usr/lib/system/libb.dylib", + "/usr/lib/libc.dylib", + "/usr/lib/libe.dylib", + "/usr/lib/dyld", + "/usr/lib/libf.dylib", + ] + ); + assert!( + closure + .windows(2) + .all(|pair| pair[0].image.address < pair[1].image.address) + ); + + let unused = cache + .image_index_by_path("/usr/lib/libunused.dylib") + .unwrap(); + assert_eq!( + cache.closure(&[unused]), + Err(SharedCacheError::MissingDependency { + image: "/usr/lib/libunused.dylib".into(), + dependency: "/usr/lib/not-in-cache.dylib".into(), + }) + ); + let bad_root = SharedCacheError::InvalidArgument("closure root is not an image table index"); + assert_eq!(cache.closure(&[IMAGES_SPEC.len()]), Err(bad_root.clone())); + assert_eq!(cache.launch_set(&[0, IMAGES_SPEC.len()]), Err(bad_root)); + // The launch set also follows the delayed-init link to libunused, whose + // own non-weak dependency is missing. + assert_eq!( + cache.launch_set(&roots), + Err(SharedCacheError::MissingDependency { + image: "/usr/lib/libunused.dylib".into(), + dependency: "/usr/lib/not-in-cache.dylib".into(), + }) + ); + let libe = cache.image_index_by_path("/usr/lib/libe.dylib").unwrap(); + assert_eq!(cache.launch_set(&[libe]), cache.closure(&[libe])); +} + #[test] fn malformed_cache_files_are_rejected() { let reject = |mutate: &dyn Fn(&mut Files)| { diff --git a/litebox_common_macos/tests/live_shared_cache.rs b/litebox_common_macos/tests/live_shared_cache.rs index 854ac34199..0a94cc9f56 100644 --- a/litebox_common_macos/tests/live_shared_cache.rs +++ b/litebox_common_macos/tests/live_shared_cache.rs @@ -8,9 +8,12 @@ #![cfg(all(target_os = "macos", target_arch = "aarch64"))] -use litebox_common_macos::shared_cache::{ - CacheFile, CacheFileReader, CacheHeader, DYNAMIC_REGION_SIZE, MappingFlags, SharedCache, - SharedCacheError, UnsignedPointers, dynamic_region_cache_path, +use litebox_common_macos::{ + VmProtection, + shared_cache::{ + CacheFile, CacheFileReader, CacheHeader, DYNAMIC_REGION_SIZE, MappingFlags, SharedCache, + SharedCacheError, UnsignedPointers, dynamic_region_cache_path, + }, }; use std::os::unix::fs::FileExt as _; @@ -134,7 +137,7 @@ fn live_cache() -> Option { } #[test] -fn live_host_cache_parses() { +fn live_host_cache_closure_covers_libsystem() { let Some(LiveCache { live, cache, .. }) = live_cache() else { return; }; @@ -151,6 +154,52 @@ fn live_host_cache_parses() { } } } + + let libsystem = cache + .image_index_by_path("/usr/lib/libSystem.B.dylib") + .expect("libSystem is in the cache"); + let dyld = cache.dyld_image_index().expect("dyld is in the cache"); + let closure = cache.closure(&[libsystem, dyld]).unwrap(); + let paths: Vec<_> = closure + .iter() + .map(|image| cache.images()[image.index].path.as_str()) + .collect(); + println!("closure: {} images including dyld", closure.len()); + // Delayed-init links (such as libxpc -> CoreFoundation) are not followed. + assert!( + paths.iter().all(|path| path.starts_with("/usr/lib/")), + "closure reaches beyond /usr/lib: {paths:?}" + ); + for required in [ + "/usr/lib/dyld", + "/usr/lib/libSystem.B.dylib", + "/usr/lib/system/libsystem_kernel.dylib", + "/usr/lib/system/libsystem_pthread.dylib", + "/usr/lib/system/libsystem_platform.dylib", + "/usr/lib/system/libsystem_c.dylib", + "/usr/lib/system/libsystem_malloc.dylib", + "/usr/lib/system/libdispatch.dylib", + "/usr/lib/system/libxpc.dylib", + ] { + assert!(paths.contains(&required), "closure lacks {required}"); + } + for image in &closure { + let text = image + .image + .segment("__TEXT") + .unwrap_or_else(|| panic!("{} has no __TEXT", cache.images()[image.index].path)); + let start = text.address_range.start; + let size = text.address_range.end - start; + let (_, mapping) = cache + .mapping_containing(start, size) + .unwrap_or_else(|| panic!("__TEXT at {start:#x} is not in one mapping")); + assert!( + mapping + .initial_protection + .contains(VmProtection::READ | VmProtection::EXECUTE), + "__TEXT at {start:#x} is not in an executable mapping" + ); + } } #[test]