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..0661742ac9 --- /dev/null +++ b/litebox_common_macos/src/shared_cache.rs @@ -0,0 +1,1175 @@ +// 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`. 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), combine them with +//! [`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::{ + collections::{BTreeMap, BTreeSet}, + string::String, + vec, + vec::Vec, +}; +use core::ops::Range; + +/// 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. +const HEADER_MAPPING_OFFSET: usize = 0x10; +const HEADER_UUID: usize = 0x58; +const HEADER_CACHE_TYPE: usize = 0x68; +const HEADER_DYLD_IN_CACHE_MH: usize = 0x78; +const HEADER_SHARED_REGION_START: usize = 0xe0; +const HEADER_SHARED_REGION_SIZE: usize = 0xe8; +const HEADER_MAX_SLIDE: usize = 0xf0; +const HEADER_MAPPING_WITH_SLIDE_OFFSET: usize = 0x138; +const HEADER_SUBCACHE_ARRAY_OFFSET: usize = 0x188; +const HEADER_IMAGES_OFFSET: usize = 0x1c0; +const HEADER_CACHE_SUB_TYPE: usize = 0x1c8; +const HEADER_DYNAMIC_DATA_OFFSET: usize = 0x1f0; +const HEADER_DYNAMIC_DATA_MAX_SIZE: usize = 0x1f8; + +const MAPPING_AND_SLIDE_INFO_BYTES: usize = 56; +const SUBCACHE_ENTRY_BYTES: usize = 56; +const IMAGE_INFO_BYTES: usize = 32; +const SLIDE_INFO_V5_HEADER_BYTES: usize = 24; + +const MACH_HEADER_64_BYTES: usize = 32; +const SEGMENT_COMMAND_64_BYTES: usize = 72; +const SECTION_64_BYTES: usize = 80; +const DYLIB_COMMAND_BYTES: usize = 24; +const DYLIB_USE_COMMAND_BYTES: usize = 28; +/// `dylib_use_command.marker`: the command's flags, not its `cmd`, select the +/// linkage kind. +const DYLIB_USE_MARKER: u32 = 0x1a74_1800; +const UUID_COMMAND_BYTES: usize = 24; +/// The load commands that link a dependency. +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, +]; + +/// Why a shared cache was rejected. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +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("{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 }, +} + +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) +} + +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. + pub fn address_range(&self) -> Range { + // Validated by `CacheFile::parse`. + self.address..self.address + self.size + } + + /// Byte range in the containing file. + pub fn file_range(&self) -> Range { + self.file_offset..self.file_offset + self.size + } + + fn parse(bytes: &[u8], file_len: u64) -> Result { + let address = u64_at(bytes, 0)?; + let size = u64_at(bytes, 8)?; + let file_offset = u64_at(bytes, 16)?; + let slide_offset = u64_at(bytes, 24)?; + let slide_size = u64_at(bytes, 32)?; + 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(u64_at(bytes, 40)?), + max_protection: protection(u32_at(bytes, 48)?)?, + initial_protection: protection(u32_at(bytes, 52)?)?, + }) + } +} + +/// One 56-byte `dyld_subcache_entry`. +#[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(bytes: &[u8]) -> Result { + let file_suffix = c_string(bytes, 24, SUBCACHE_ENTRY_BYTES)?; + 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: field(bytes, 0)?, + cache_vm_offset: u64_at(bytes, 16)?, + 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 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 bytes = read_vec( + &reader, + u64::from(table.offset), + table.count as usize * MAPPING_AND_SLIDE_INFO_BYTES, + )?; + let file_len = reader.file_len(); + let mappings = bytes + .as_chunks::() + .0 + .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")); + } + let bytes = read_vec( + &self.reader, + u64::from(table.offset), + table.count as usize * SUBCACHE_ENTRY_BYTES, + )?; + bytes + .as_chunks::() + .0 + .iter() + .map(|entry| SubcacheEntry::parse(entry)) + .collect() + } + + /// Slide info for `mapping`, or `None` if the mapping has none. + pub fn slide_info(&self, mapping: &CacheMapping) -> Result> { + 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. + fn decode(command: u32, body: &[u8]) -> Result { + use object::macho; + + let mut attributes = if u32_at(body, 12)? == DYLIB_USE_MARKER + && u32_at(body, 8)? as usize == DYLIB_USE_COMMAND_BYTES + { + Self::from_bits_retain(field::<1>(body, 24)?[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); + } + Ok(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 { + /// `S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS`. + const INSTRUCTION_ATTRIBUTES: u32 = 0x8000_0400; + + /// 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; + + if u32_at(bytes, 0)? != macho::MH_MAGIC_64 || u32_at(bytes, 4)? != macho::CPU_TYPE_ARM64 { + return Err(SharedCacheError::Malformed( + "cache image is not an arm64 Mach-O", + )); + } + let file_type = u32_at(bytes, 12)?; + 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 = u32_at(bytes, 16)?; + let commands_end = MACH_HEADER_64_BYTES + u32_at(bytes, 20)? 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 = MACH_HEADER_64_BYTES; + for _ in 0..command_count { + let command = u32_at(bytes, offset)?; + let size = u32_at(bytes, offset + 4)? 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 => { + if body.len() < UUID_COMMAND_BYTES || image.uuid.is_some() { + return Err(SharedCacheError::Malformed("LC_UUID")); + } + image.uuid = Some(field(body, 8)?); + } + 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(bytes: &[u8], offset: usize) -> Result { + let name: [u8; 16] = field(bytes, offset)?; + 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 { + if body.len() < SEGMENT_COMMAND_64_BYTES { + return Err(SharedCacheError::Malformed("LC_SEGMENT_64 size")); + } + let address_range = address_range(u64_at(body, 24)?, u64_at(body, 32)?)?; + let section_count = u32_at(body, 64)? as usize; + if section_count + .checked_mul(SECTION_64_BYTES) + .and_then(|bytes| bytes.checked_add(SEGMENT_COMMAND_64_BYTES)) + .is_none_or(|needed| needed > body.len()) + { + return Err(SharedCacheError::Malformed("LC_SEGMENT_64 sections")); + } + let mut sections = Vec::with_capacity(section_count); + for index in 0..section_count { + let section = SEGMENT_COMMAND_64_BYTES + index * SECTION_64_BYTES; + let range = address_range_of_section(body, section)?; + if range.start < address_range.start || range.end > address_range.end { + return Err(SharedCacheError::Malformed("section outside its segment")); + } + sections.push(CacheSection { + name: fixed_name(body, section)?, + address_range: range, + flags: u32_at(body, section + 64)?, + }); + } + Ok(CacheSegment { + name: fixed_name(body, 8)?, + address_range, + max_protection: protection(u32_at(body, 56)?)?, + initial_protection: protection(u32_at(body, 60)?)?, + sections, + }) +} + +/// The unslid address range of the `section_64` at `offset` in a segment command. +fn address_range_of_section(body: &[u8], offset: usize) -> Result> { + address_range(u64_at(body, offset + 32)?, u64_at(body, offset + 40)?) +} + +/// The `lc_str` name of a `dylib_command`. +fn dylib_name(body: &[u8]) -> Result<&str> { + if body.len() < DYLIB_COMMAND_BYTES { + return Err(SharedCacheError::Malformed("dylib command size")); + } + let offset = u32_at(body, 8)? as usize; + if offset < DYLIB_COMMAND_BYTES { + return Err(SharedCacheError::Malformed("dylib name offset")); + } + 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>, + images: Vec, +} + +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")); + } + let bytes = read_vec( + &main.reader, + u64::from(table.offset), + table.count as usize * IMAGE_INFO_BYTES, + )?; + bytes + .as_chunks::() + .0 + .iter() + .map(|entry| { + Ok(CacheImageEntry { + address: u64_at(entry, 0)?, + path: read_path(&main.reader, u64::from(u32_at(entry, 24)?))?, + }) + }) + .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`. + pub fn parse_image(&self, address: u64) -> Result { + let header = self.read_at_address(address, MACH_HEADER_64_BYTES)?; + let commands = u32_at(&header, 20)?; + if commands > MAX_LOAD_COMMAND_BYTES { + return Err(SharedCacheError::Malformed("load command size")); + } + let bytes = self.read_at_address(address, MACH_HEADER_64_BYTES + 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::Malformed("closure root 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. +#[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 { + 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 { + let version = u32_at(bytes, 0)?; + if version != 5 { + return Err(SharedCacheError::Unsupported( + "slide info version other than 5", + )); + } + let page_size = u32_at(bytes, 4)? as usize; + if !matches!(page_size, 0x1000 | 0x4000) { + return Err(SharedCacheError::Malformed("slide info page size")); + } + let count = u32_at(bytes, 8)? as usize; + let starts = count + .checked_mul(2) + .and_then(|length| length.checked_add(SLIDE_INFO_V5_HEADER_BYTES)) + .and_then(|end| bytes.get(SLIDE_INFO_V5_HEADER_BYTES..end)) + .ok_or(SharedCacheError::Malformed("slide info page starts"))?; + Ok(Self { + page_size, + value_add: u64_at(bytes, 16)?, + page_starts: starts + .as_chunks::<2>() + .0 + .iter() + .map(|start| u16::from_le_bytes(*start)) + .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::Malformed("slide page index")), + Some(&SLIDE_V5_PAGE_NO_REBASE) => Ok(None), + 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. `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::Malformed("slide page length")); + } + 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` +/// (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..0ac99b1d3d --- /dev/null +++ b/litebox_common_macos/src/shared_cache/tests.rs @@ -0,0 +1,808 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Synthetic-buffer tests for shared-cache parsing, closure and v5 rebasing. +#![allow(clippy::cast_possible_truncation)] + +use super::*; +use alloc::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 * MAPPING_AND_SLIDE_INFO_BYTES; + 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; SEGMENT_COMMAND_64_BYTES + SECTION_64_BYTES]; + put32(&mut segment, 0, macho::LC_SEGMENT_64); + put32( + &mut segment, + 4, + (SEGMENT_COMMAND_64_BYTES + SECTION_64_BYTES) 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[SEGMENT_COMMAND_64_BYTES..]; + 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; UUID_COMMAND_BYTES]; + put32(&mut uuid_command, 0, macho::LC_UUID); + put32(&mut uuid_command, 4, UUID_COMMAND_BYTES 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, DYLIB_COMMAND_BYTES) + } else { + (macho::LC_LOAD_DYLIB, DYLIB_USE_COMMAND_BYTES) + }; + 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 == DYLIB_USE_COMMAND_BYTES { + 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 + MACH_HEADER_64_BYTES]; + 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 + MACH_HEADER_64_BYTES; + 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; SLIDE_INFO_V5_HEADER_BYTES + 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[SLIDE_INFO_V5_HEADER_BYTES + 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 * SUBCACHE_ENTRY_BYTES; + 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 * IMAGE_INFO_BYTES; + 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 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(), + }) + ); + assert!(cache.closure(&[IMAGES_SPEC.len()]).is_err()); + // 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)| { + let mut files = files(); + mutate(&mut files); + parse(&files) + .err() + .expect("malformed cache must be rejected") + }; + assert_eq!( + reject(&|files| files.main.truncate(0x100)), + SharedCacheError::Read { + offset: 0, + length: MAPPINGS + } + ); + assert!(matches!( + reject(&|files| files.main[7] = b'2'), + SharedCacheError::Unsupported(_) + )); + assert!(matches!( + reject(&|files| put32( + &mut files.main, + HEADER_MAPPING_OFFSET, + HEADER_CACHE_SUB_TYPE as u32 + )), + SharedCacheError::Unsupported(_) + )); + assert!(matches!( + reject(&|files| put32(&mut files.main, HEADER_MAPPING_OFFSET, u32::MAX)), + SharedCacheError::Malformed(_) + )); + // Mapping file range past the end of the file. + assert!(matches!( + reject(&|files| put64(&mut files.text, MAPPINGS + 8, TEXT_SIZE + 1)), + SharedCacheError::Malformed(_) + )); + // Address overflow. + assert!(matches!( + reject(&|files| put64(&mut files.text, MAPPINGS, u64::MAX - 1)), + SharedCacheError::Malformed(_) + )); + // Overlapping mappings inside one file and across files. + assert!(matches!( + reject(&|files| put64(&mut files.data, MAPPINGS + 56, DATA_ADDRESS)), + SharedCacheError::Malformed(_) + )); + assert!(matches!( + 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!(matches!( + 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!(matches!( + reject(&|files| put32(&mut files.text, MAPPINGS + 52, 0x10)), + SharedCacheError::Malformed(_) + )); + assert!(matches!( + reject(&|files| put32(&mut files.text, HEADER_MAPPING_WITH_SLIDE_OFFSET + 4, 0)), + SharedCacheError::Malformed(_) + )); + assert_eq!( + reject(&|files| files.data[HEADER_UUID] ^= 1), + SharedCacheError::SubcacheUuidMismatch { index: 1 } + ); + assert!(matches!( + reject(&|files| put64(&mut files.main, SUBCACHES + 16, 0)), + SharedCacheError::Malformed(_) + )); + assert!(matches!( + reject(&|files| files.main[SUBCACHES + 24..SUBCACHES + 28].copy_from_slice(b"/../")), + SharedCacheError::Malformed(_) + )); + assert!(matches!( + reject(&|files| files.main[SUBCACHES + 24..SUBCACHES + 56].fill(b'1')), + SharedCacheError::Malformed(_) + )); + // Unterminated image path at the end of the main file. + assert!(matches!( + reject(&|files| { + let end = files.main.len() - 1; + files.main[end] = b'x'; + put32(&mut files.main, IMAGES + 24, end as u32); + }), + SharedCacheError::Malformed(_) + )); + assert!(matches!( + reject(&|files| put32(&mut files.main, HEADER_IMAGES_OFFSET + 4, u32::MAX)), + SharedCacheError::Malformed(_) + )); + let mut files = files(); + files.data.truncate(files.data.len() - 1); + assert!(matches!(parse(&files), Err(SharedCacheError::Malformed(_)))); +} + +#[test] +fn malformed_images_are_rejected() { + let mut files = files(); + let cache = parse(&files).unwrap(); + // Zero bytes inside an image are not a Mach header. + assert!(matches!( + cache.parse_image(image_address(0) + 0x800), + Err(SharedCacheError::Malformed(_)) + )); + let header = (image_address(0) - TEXT_ADDRESS) as usize; + let first_command = header + MACH_HEADER_64_BYTES; + for (offset, value) in [ + // Load command size not a multiple of 8, and beyond sizeofcmds. + (first_command + 4, 70), + (first_command + 4, 0x1000), + // More commands than sizeofcmds holds. + (header + 16, 100), + // sizeofcmds beyond the mapping. + (header + 20, 0x10_0000), + // Section count beyond the segment command. + (first_command + 64, 2), + // Section extending beyond its segment. + (first_command + 72 + 40, 0x1000), + // Dylib name offset inside the fixed command fields. + (first_command + 152 + 24 + 8, 8), + ] { + let original = files.text.clone(); + put32(&mut files.text, offset, value); + let cache = parse(&files).unwrap(); + assert!( + cache.parse_image(image_address(0)).is_err(), + "offset {offset:#x} = {value:#x}" + ); + files.text = original; + } + // Missing NUL in an install name. + let id = first_command + 152 + 24; + let size = u32::from_le_bytes(files.text[id + 4..id + 8].try_into().unwrap()) as usize; + files.text[id + DYLIB_COMMAND_BYTES..id + size].fill(b'a'); + let cache = parse(&files).unwrap(); + assert!(cache.parse_image(image_address(0)).is_err()); +} + +/// 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_rejects_chains_leaving_the_page_and_bad_input() { + let info = SlideInfoV5::parse(&slide_info(0x1000, 0, &[0xff8, 0x10, 0xffc, 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!(matches!( + info.rebase_page(&mut page, 1, 0, 0, &UnsignedPointers), + Err(SharedCacheError::ChainOutOfPage { .. }) + )); + // Misaligned and out-of-page starts. + for index in [2, 3] { + let mut page = page_with(&[]); + assert!(matches!( + info.rebase_page(&mut page, index, 0, 0, &UnsignedPointers), + Err(SharedCacheError::ChainOutOfPage { .. }) + )); + } + // Bad page index, page length and arithmetic overflow. + let mut page = page_with(&[(0x10, regular(1, 0, 0))]); + assert!( + info.rebase_page(&mut page, 4, 0, 0, &UnsignedPointers) + .is_err() + ); + assert!( + info.rebase_page(&mut page[..0x800], 1, 0, 0, &UnsignedPointers) + .is_err() + ); + assert!( + info.rebase_page(&mut page, 1, u64::MAX, 0, &UnsignedPointers) + .is_err() + ); + // Header validation. + let mut wrong_version = slide_info(0x1000, 0, &[0]); + put32(&mut wrong_version, 0, 3); + assert!(matches!( + SlideInfoV5::parse(&wrong_version), + Err(SharedCacheError::Unsupported(_)) + )); + assert!(SlideInfoV5::parse(&slide_info(0x2000, 0, &[0])).is_err()); + let truncated = slide_info(0x1000, 0, &[0, 0]); + assert!(SlideInfoV5::parse(&truncated[..truncated.len() - 1]).is_err()); + assert!(SlideInfoV5::parse(&truncated[..8]).is_err()); +} + +#[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!(matches!( + data.slide_info(&data.mappings()[1]), + Err(SharedCacheError::Malformed(_)) + )); +} + +#[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) in [ + (0, &b"dyld_data v1"[..]), + (0, b"dyld_dota v3"), + (36, &0u32.to_le_bytes()), + (36, &0x100u32.to_le_bytes()), + ] { + let mut copy = region.clone(); + copy[offset..offset + bytes.len()].copy_from_slice(bytes); + assert!(dynamic_region_cache_path(©).is_err(), "{offset}"); + } + region[0x80 + path.len()..].fill(b'a'); + assert!(dynamic_region_cache_path(®ion).is_err()); +} 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..0a94cc9f56 --- /dev/null +++ b/litebox_common_macos/tests/live_shared_cache.rs @@ -0,0 +1,278 @@ +// 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, 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::{ + VmProtection, + shared_cache::{ + CacheFile, CacheFileReader, CacheHeader, DYNAMIC_REGION_SIZE, MappingFlags, SharedCache, + SharedCacheError, UnsignedPointers, 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 { + /// 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. + 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 { base, live, cache }) +} + +#[test] +fn live_host_cache_closure_covers_libsystem() { + 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; + } + } + } + + 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] +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"); +} diff --git a/litebox_platform_macos_userland/src/lib.rs b/litebox_platform_macos_userland/src/lib.rs index 95e8624dbe..72eaca7fb2 100644 --- a/litebox_platform_macos_userland/src/lib.rs +++ b/litebox_platform_macos_userland/src/lib.rs @@ -13,8 +13,8 @@ use std::sync::{ use std::time::Duration; use litebox::platform::page_mgmt::{ - AllocationError, DeallocationError, FixedAddressBehavior, MemoryRegionPermissions, - PermissionUpdateError, + AllocationError, CowAllocationError, DeallocationError, FixedAddressBehavior, + MemoryRegionPermissions, PermissionUpdateError, }; use litebox::platform::{ArchSpecificError, ArchSpecificRegister, RawConstPointer as _}; use litebox::shim::{ContinueOperation, EnterShim, Exception, ExceptionInfo}; @@ -125,6 +125,23 @@ impl PageTable { } } + fn allocate_copy_on_write( + &mut self, + start: usize, + source: &'static [u8], + permissions: MemoryRegionPermissions, + behavior: FixedAddressBehavior, + ) -> Result { + match self { + Self::Native(pages) => { + pages.allocate_copy_on_write(start, source, permissions, behavior) + } + // A 4 KiB guest page cannot own a host page of the copy, so callers copy instead. + #[cfg(feature = "subpage_compat")] + Self::Subpage(_) => Err(CowAllocationError::UnsupportedByPlatform), + } + } + #[cfg_attr( not(feature = "subpage_compat"), expect(clippy::unnecessary_wraps, reason = "subpage deallocation can fail") @@ -670,12 +687,11 @@ bitflags::bitflags! { struct MachVmFlags: i32 { /// Mach uses zero to request the supplied fixed address. const FIXED = 0; - #[cfg(feature = "subpage_compat")] const ANYWHERE = 0x0000_0001; + const OVERWRITE = 0x0000_4000; } } -#[cfg(any(test, feature = "subpage_compat"))] bitflags::bitflags! { #[repr(transparent)] struct MachVmProtection: i32 { @@ -686,23 +702,20 @@ bitflags::bitflags! { } } -#[cfg(any(test, feature = "subpage_compat"))] #[derive(Clone, Copy)] #[repr(transparent)] struct MachBoolean(i32); -#[cfg(any(test, feature = "subpage_compat"))] impl MachBoolean { - #[cfg(feature = "subpage_compat")] const FALSE: Self = Self(0); - #[cfg(test)] const TRUE: Self = Self(1); } -#[cfg(feature = "subpage_compat")] #[derive(Clone, Copy)] #[repr(u32)] enum MachVmInheritance { + Copy = 1, + #[cfg(feature = "subpage_compat")] None = 2, } @@ -720,7 +733,6 @@ unsafe extern "C" { set_maximum: MachBoolean, protection: MachVmProtection, ) -> KernReturn; - #[cfg(feature = "subpage_compat")] fn mach_vm_remap( task: u32, target: *mut u64, @@ -868,6 +880,35 @@ impl litebox::platform::PageManagementProvider( + &self, + replaced_reservations: impl FnOnce() -> Reservations, + suggested_start: usize, + source_data: &'static [u8], + permissions: MemoryRegionPermissions, + fixed_address_behavior: FixedAddressBehavior, + ) -> Result, CowAllocationError> + where + Reservations: Iterator>, + { + let base = self.pages.lock().unwrap().allocate_copy_on_write( + suggested_start, + source_data, + permissions, + fixed_address_behavior, + )?; + if fixed_address_behavior == FixedAddressBehavior::Replace { + replaced_reservations().for_each(drop); + } + // SAFETY: Pages acquired exclusive ownership of this exact guest extent. + Ok(unsafe { MacosUserlandReservation::new(base..base + source_data.len()) }) + } + unsafe fn protect_pages<'reservation, Reservations>( &self, _covering_reservations: impl FnOnce() -> Reservations, @@ -4159,4 +4200,719 @@ mod tests { unsafe { p.protect_pages(core::iter::empty, range.clone(), RW) }.unwrap(); assert_eq!(run(44), 44); } + + /// Leaked page-aligned anonymous host memory filled with `fill`, as a copy-on-write source. + fn cow_source(len: usize, fill: u8) -> &'static [u8] { + // SAFETY: a fresh anonymous mapping, leaked for the program lifetime. + let source = unsafe { + libc::mmap( + core::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANON, + -1, + 0, + ) + }; + assert_ne!(source, libc::MAP_FAILED); + // SAFETY: the test exclusively owns the fresh writable mapping, which is never unmapped + // and never written after the shared slice is created. + unsafe { + core::ptr::write_bytes(source.cast::(), fill, len); + core::slice::from_raw_parts(source.cast(), len) + } + } + + fn native_owns_any( + p: &MacosUserlandWithPageSize, + range: &Range, + ) -> bool { + match &*p.pages.lock().unwrap() { + PageTable::Native(pages) => pages.owns_any(range), + #[cfg(feature = "subpage_compat")] + PageTable::Subpage(_) => unreachable!("native instance"), + } + } + + #[repr(C, packed(4))] + #[derive(Default)] + struct VmRegionBasicInfo64 { + protection: i32, + max_protection: i32, + inheritance: u32, + shared: i32, + reserved: i32, + offset: u64, + behavior: i32, + user_wired_count: u16, + } + + const VM_REGION_BASIC_INFO_64: i32 = 9; + + unsafe extern "C" { + fn mach_vm_region( + task: u32, + address: *mut u64, + size: *mut u64, + flavor: i32, + info: *mut VmRegionBasicInfo64, + count: *mut u32, + object_name: *mut u32, + ) -> KernReturn; + } + + /// The inheritance of the host mapping at `address`. + fn inheritance_at(address: usize) -> u32 { + let mut region = address as u64; + let mut size = 0; + let mut info = VmRegionBasicInfo64::default(); + let mut count = u32::try_from(size_of::() / size_of::()).unwrap(); + let mut object_name = 0; + // SAFETY: all outputs are valid; the basic flavor returns no object name right. + let result = unsafe { + mach_vm_region( + mach_task_self(), + &raw mut region, + &raw mut size, + VM_REGION_BASIC_INFO_64, + &raw mut info, + &raw mut count, + &raw mut object_name, + ) + }; + assert_eq!(result, KernReturn::SUCCESS); + assert!(region <= address as u64, "{address:#x} is unmapped"); + info.inheritance + } + + #[test] + fn copy_on_write_pages_are_private_and_downgrade() { + let p = MacosUserland::new(); + let source = cow_source(2 * HOST_PAGE_SIZE, 0x11); + // Keep this copy apart from other tests' hinted pages. + let hint = TASK_ADDR_MIN + (4 << 40); + // SAFETY: Hint placement is refused before any side effect. + let hinted = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("Hint replaces nothing") }, + 0, + source, + RW, + FixedAddressBehavior::Hint(AllocationDirection::TopDown), + ) + }; + assert!(matches!( + hinted, + Err(CowAllocationError::UnsupportedByPlatform) + )); + // SAFETY: NoReplace placement replaces nothing. + let copy = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("NoReplace replaces nothing") }, + hint, + source, + RW, + FixedAddressBehavior::NoReplace, + ) + } + .unwrap(); + let range = copy.range(); + assert_eq!(range, hint..hint + source.len()); + let _unmap = litebox::utils::defer(|| { + // SAFETY: the test owns the copy and no longer accesses it. + unsafe { p.release_pages(range.clone()) }.unwrap(); + }); + // Guest copies inherit like mmap'd guest pages (VM_INHERIT_DEFAULT). + assert_eq!(inheritance_at(range.start), MachVmInheritance::Copy as u32); + let ptr = + ::RawMutPointer::::from_usize( + range.start, + ); + assert_eq!(ptr.read_at_offset(0), Some(0x11)); + for offset in [0, HOST_PAGE_SIZE] { + assert_eq!(ptr.write_at_offset(offset.cast_signed(), 0x22), Some(())); + assert_eq!(source[offset], 0x11, "writes never reach the source"); + } + // SAFETY: the test owns the idle copy. + unsafe { + p.protect_pages( + || core::iter::once(©), + range.clone(), + MemoryRegionPermissions::READ, + ) + } + .unwrap(); + for offset in [0, HOST_PAGE_SIZE - 1, HOST_PAGE_SIZE] { + assert_eq!(ptr.write_at_offset(offset.cast_signed(), 0x33), None); + } + assert_eq!(ptr.read_at_offset(0), Some(0x22)); + assert_eq!(ptr.read_at_offset(HOST_PAGE_SIZE.cast_signed()), Some(0x22)); + // Unaligned sources and W+X are refused before any side effect, here on free pages. + for (source, permissions, unaligned) in [ + ( + &source[1..=HOST_PAGE_SIZE], + MemoryRegionPermissions::READ, + true, + ), + ( + &source[..HOST_PAGE_SIZE - 1], + MemoryRegionPermissions::READ, + true, + ), + (source, RW | MemoryRegionPermissions::EXEC, false), + ] { + // SAFETY: NoReplace placement replaces nothing. + let result = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("NoReplace replaces nothing") }, + range.end, + source, + permissions, + FixedAddressBehavior::NoReplace, + ) + }; + if unaligned { + assert!(matches!(result, Err(CowAllocationError::Unaligned))); + } else { + assert!(matches!(result, Err(CowAllocationError::InternalFailure))); + } + } + } + + #[test] + fn released_copy_on_write_pages_leave_the_owned_set() { + let p = MacosUserland::new(); + // Keep these pages apart from other tests' hinted pages: the test frees them. + let hint = TASK_ADDR_MIN + (5 << 40); + // SAFETY: Hint allocation chooses fresh pages that this test exclusively owns. + let reservation = unsafe { + p.reserve_and_commit_pages( + core::iter::empty, + hint..hint + 2 * HOST_PAGE_SIZE, + MemoryRegionPermissions::empty(), + false, + false, + FixedAddressBehavior::Hint(AllocationDirection::BottomUp), + ) + } + .unwrap(); + let range = reservation.range(); + let source = cow_source(range.len(), 0x11); + // SAFETY: the test-owned reservation is idle. + let copy = unsafe { + p.try_allocate_cow_pages( + || core::iter::once(reservation), + range.start, + source, + MemoryRegionPermissions::READ, + FixedAddressBehavior::Replace, + ) + } + .unwrap(); + assert_eq!(copy.range(), range); + assert!(native_owns_any(p, &range)); + assert_eq!(inheritance_at(range.start), MachVmInheritance::Copy as u32); + let ptr = + ::RawMutPointer::::from_usize( + range.start, + ); + assert_eq!(ptr.read_at_offset(HOST_PAGE_SIZE.cast_signed()), Some(0x11)); + assert_eq!(ptr.write_at_offset(0, 0x22), None); + // SAFETY: the test owns the idle copy; later probes use fallible accesses. + unsafe { p.release_pages(copy.range()) }.unwrap(); + assert!(!native_owns_any(p, &range)); + assert_eq!(ptr.read_at_offset(0), None); + // SAFETY: the pages this test just released are free. + let fresh = unsafe { + p.reserve_and_commit_pages( + core::iter::empty, + range.clone(), + RW, + false, + true, + FixedAddressBehavior::NoReplace, + ) + } + .unwrap(); + assert_eq!(ptr.read_at_offset(0), Some(0)); + // SAFETY: the test owns the idle pages. + unsafe { p.release_pages(fresh.range()) }.unwrap(); + } + + #[test] + fn failed_copy_on_write_replacement_keeps_the_target_mapping() { + let p = MacosUserland::new(); + // SAFETY: Hint allocation chooses a fresh page that this test exclusively owns. + let reservation = unsafe { + p.reserve_and_commit_pages( + core::iter::empty, + TASK_ADDR_MIN..TASK_ADDR_MIN + HOST_PAGE_SIZE, + RW, + false, + true, + FixedAddressBehavior::Hint(AllocationDirection::BottomUp), + ) + } + .unwrap(); + let target = reservation.range(); + let _unmap = litebox::utils::defer(|| { + // SAFETY: the test owns the page and no longer accesses it. + unsafe { p.release_pages(target.clone()) }.unwrap(); + }); + let ptr = + ::RawMutPointer::::from_usize( + target.start, + ); + assert_eq!(ptr.write_at_offset(0, 0x5a), Some(())); + // A READ-only maximum fails the staged copy's protection check before the target is + // touched. + let source = cow_source(HOST_PAGE_SIZE, 0x11); + // SAFETY: the test exclusively owns the leaked source mapping. + let result = unsafe { + mach_vm_protect( + mach_task_self(), + source.as_ptr() as u64, + HOST_PAGE_SIZE as u64, + MachBoolean::TRUE, + MachVmProtection::READ, + ) + }; + assert_eq!(result, KernReturn::SUCCESS); + // SAFETY: the test-owned page is idle; a failed replacement must leave it intact. + let replaced = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("the replacement failed") }, + target.start, + source, + RW, + FixedAddressBehavior::Replace, + ) + }; + assert!(matches!( + replaced, + Err(CowAllocationError::UnsupportedSourceRegion) + )); + assert!(native_owns_any(p, &target)); + assert_eq!(ptr.read_at_offset(0), Some(0x5a)); + assert_eq!(ptr.write_at_offset(1, 0x6b), Some(())); + } + + #[test] + fn copy_on_write_never_replaces_unowned_memory() { + let p = MacosUserland::new(); + // Keep these pages apart from other tests' hinted pages: the test frees one of them. + let hint = TASK_ADDR_MIN + (6 << 40); + // SAFETY: Hint allocation chooses fresh pages that this test exclusively owns. + let base = unsafe { + p.reserve_and_commit_pages( + core::iter::empty, + hint..hint + 3 * HOST_PAGE_SIZE, + RW, + false, + true, + FixedAddressBehavior::Hint(AllocationDirection::BottomUp), + ) + } + .unwrap() + .range() + .start; + let _unmap = litebox::utils::defer(|| { + // SAFETY: the test-owned pages are no longer accessed. + unsafe { p.release_pages(base..base + 3 * HOST_PAGE_SIZE) }.unwrap(); + }); + let ptr = + ::RawMutPointer::::from_usize( + base, + ); + let page = |index: usize| (index * HOST_PAGE_SIZE).cast_signed(); + let span = |indices: Range| { + base + indices.start * HOST_PAGE_SIZE..base + indices.end * HOST_PAGE_SIZE + }; + let markers = [0x10, 0x11]; + for (index, marker) in markers.into_iter().enumerate() { + assert_eq!(ptr.write_at_offset(page(index), marker), Some(())); + } + + // Layout: page0 owned | page1 free | page2 host. + // SAFETY: the test-owned page is idle; later probes use fallible accesses. + unsafe { p.release_pages(span(2..3)) }.unwrap(); + let mut host = span(2..3).start as u64; + // SAFETY: a FIXED allocation without OVERWRITE fails instead of replacing a mapping. + let result = unsafe { + mach_vm_allocate( + mach_task_self(), + &raw mut host, + HOST_PAGE_SIZE as u64, + MachVmFlags::FIXED, + ) + }; + assert_eq!(result, KernReturn::SUCCESS); + let _unmap_host = litebox::utils::defer(|| { + // SAFETY: the test exclusively owns the host page, which is no longer accessed. + unsafe { libc::munmap(host as *mut _, HOST_PAGE_SIZE) }; + }); + let host_marker = 0x42; + assert_eq!(ptr.write_at_offset(page(2), host_marker), Some(())); + // SAFETY: the test-owned page is idle; later probes use fallible accesses. + unsafe { p.release_pages(span(1..2)) }.unwrap(); + + let source = cow_source(2 * HOST_PAGE_SIZE, 0x77); + for (start, behavior) in [ + // NoReplace refuses owned pages. Over free page 1 and host page 2, the reservation of + // their single unowned gap fails without replacing the host page. + (span(0..1).start, FixedAddressBehavior::NoReplace), + (span(1..2).start, FixedAddressBehavior::NoReplace), + (span(1..2).start, FixedAddressBehavior::Replace), + ] { + // SAFETY: the test-owned pages are idle; the host page must be rejected. + let result = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("the allocation failed") }, + start, + source, + MemoryRegionPermissions::READ, + behavior, + ) + }; + assert!(matches!(result, Err(CowAllocationError::InternalFailure))); + } + assert_eq!(ptr.read_at_offset(page(0)), Some(markers[0])); + assert_eq!(ptr.read_at_offset(page(1)), None); + assert!(!native_owns_any(p, &span(1..3))); + assert_eq!(ptr.read_at_offset(page(2)), Some(host_marker)); + assert_eq!(ptr.write_at_offset(page(2), host_marker), Some(())); + } + + #[test] + fn failed_copy_on_write_replacement_releases_reserved_gaps() { + let p = MacosUserland::new(); + // Keep these pages apart from other tests' hinted pages: the test frees one of them. + let hint = TASK_ADDR_MIN + (8 << 40); + // SAFETY: Hint allocation chooses fresh pages that this test exclusively owns. + let base = unsafe { + p.reserve_and_commit_pages( + core::iter::empty, + hint..hint + 2 * HOST_PAGE_SIZE, + RW, + false, + true, + FixedAddressBehavior::Hint(AllocationDirection::BottomUp), + ) + } + .unwrap() + .range() + .start; + let owned = base..base + HOST_PAGE_SIZE; + let free = owned.end..owned.end + HOST_PAGE_SIZE; + let _unmap = litebox::utils::defer(|| { + // SAFETY: the test-owned page is no longer accessed. + unsafe { p.release_pages(owned.clone()) }.unwrap(); + }); + let ptr = + ::RawMutPointer::::from_usize( + base, + ); + assert_eq!(ptr.write_at_offset(0, 0x5a), Some(())); + // SAFETY: the test-owned page is idle; later probes use fallible accesses. + unsafe { p.release_pages(free.clone()) }.unwrap(); + // The call reserves the free page, then staging fails: the source's READ-only maximum + // protection fails the staged copy's protection check. + let source = cow_source(2 * HOST_PAGE_SIZE, 0x11); + // SAFETY: the test exclusively owns the leaked source mapping. + let result = unsafe { + mach_vm_protect( + mach_task_self(), + source.as_ptr() as u64, + source.len() as u64, + MachBoolean::TRUE, + MachVmProtection::READ, + ) + }; + assert_eq!(result, KernReturn::SUCCESS); + // SAFETY: the test-owned page is idle; a failed replacement must leave it intact. + let replaced = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("the replacement failed") }, + base, + source, + RW, + FixedAddressBehavior::Replace, + ) + }; + assert!(matches!( + replaced, + Err(CowAllocationError::UnsupportedSourceRegion) + )); + assert!(!native_owns_any(p, &free)); + assert_eq!(ptr.read_at_offset(HOST_PAGE_SIZE.cast_signed()), None); + assert!(native_owns_any(p, &owned)); + assert_eq!(ptr.read_at_offset(0), Some(0x5a)); + } + + #[test] + fn copy_on_write_cost_is_independent_of_size() { + const LEN: usize = 64 << 30; + let p = MacosUserland::new(); + // Untouched anonymous memory has no pages to copy. + // SAFETY: a fresh readable anonymous mapping, leaked for the program lifetime and never + // written. + let source: &'static [u8] = unsafe { + let source = libc::mmap( + core::ptr::null_mut(), + LEN, + libc::PROT_READ, + libc::MAP_PRIVATE | libc::MAP_ANON, + -1, + 0, + ); + assert_ne!(source, libc::MAP_FAILED); + core::slice::from_raw_parts(source.cast(), LEN) + }; + // CPU time of this thread, kernel time included, so host load does not inflate it. + let cpu_time = || { + // SAFETY: zero is a valid timespec. + let mut time = unsafe { core::mem::zeroed::() }; + // SAFETY: `time` is valid output storage. + assert_eq!( + unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &raw mut time) }, + 0 + ); + Duration::new( + time.tv_sec.cast_unsigned(), + time.tv_nsec.try_into().unwrap(), + ) + }; + let started = cpu_time(); + // SAFETY: Hint allocation chooses fresh pages that this test exclusively owns. + let reservation = unsafe { + p.reserve_and_commit_pages( + core::iter::empty, + TASK_ADDR_MIN..TASK_ADDR_MIN + LEN, + MemoryRegionPermissions::empty(), + false, + false, + FixedAddressBehavior::Hint(AllocationDirection::BottomUp), + ) + } + .unwrap(); + let range = reservation.range(); + // SAFETY: the test-owned reservation is idle. + let replaced = unsafe { + p.try_allocate_cow_pages( + || core::iter::once(reservation), + range.start, + source, + MemoryRegionPermissions::READ, + FixedAddressBehavior::Replace, + ) + } + .unwrap(); + // Keep this copy apart from other tests' hinted pages. + let free = TASK_ADDR_MIN + (9 << 40); + // SAFETY: NoReplace placement replaces nothing. + let reserved = unsafe { + p.try_allocate_cow_pages( + core::iter::empty, + free, + source, + MemoryRegionPermissions::empty(), + FixedAddressBehavior::NoReplace, + ) + } + .unwrap(); + // SAFETY: the test owns both idle copies. + unsafe { + p.release_pages(replaced.range()).unwrap(); + p.release_pages(reserved.range()).unwrap(); + } + // A per-page implementation needs millions of system calls here, about 0.5 µs each. + let elapsed = cpu_time().checked_sub(started).unwrap(); + assert!(elapsed < Duration::from_secs(1), "{elapsed:?}"); + assert!(!native_owns_any(p, &range)); + } + + /// A test-owned RW host range of `len` bytes holding `marker` at offset 0 of each host page, + /// released when the guard drops. + fn owned_marked_range( + p: &'static MacosUserland, + len: usize, + marker: u8, + ) -> (MacosUserlandReservation, impl Drop + use<>) { + // SAFETY: Hint allocation chooses fresh pages that this test exclusively owns. + let reservation = unsafe { + p.reserve_and_commit_pages( + core::iter::empty, + TASK_ADDR_MIN..TASK_ADDR_MIN + len, + RW, + false, + true, + FixedAddressBehavior::Hint(AllocationDirection::BottomUp), + ) + } + .unwrap(); + let range = reservation.range(); + let ptr = + ::RawMutPointer::::from_usize( + range.start, + ); + for offset in (0..len).step_by(HOST_PAGE_SIZE) { + assert_eq!(ptr.write_at_offset(offset.cast_signed(), marker), Some(())); + } + let release = litebox::utils::defer(move || { + // SAFETY: the test owns the pages and no longer accesses them. + unsafe { p.release_pages(range) }.unwrap(); + }); + (reservation, release) + } + + #[test] + fn executable_copy_on_write_pages_are_refused() { + let p = MacosUserland::new(); + let (target, _release) = owned_marked_range(p, HOST_PAGE_SIZE, 0x5a); + let range = target.range(); + let ptr = + ::RawMutPointer::::from_usize( + range.start, + ); + let source = cow_source(HOST_PAGE_SIZE, 0); + // SAFETY: the test-owned page is idle; a refused replacement must leave it intact. + let result = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("executable copies are refused") }, + range.start, + source, + MemoryRegionPermissions::READ | MemoryRegionPermissions::EXEC, + FixedAddressBehavior::Replace, + ) + }; + assert!(matches!( + result, + Err(CowAllocationError::UnsupportedByPlatform) + )); + assert!(native_owns_any(p, &range)); + assert_eq!(ptr.read_at_offset(0), Some(0x5a)); + assert_eq!(ptr.write_at_offset(1, 0x6b), Some(())); + } + + /// A leaked read-only file mapping filled with `fill` whose host page `i` is mapped with + /// `mmap` flags `page_flags[i]`. Callers in one process pass distinct `fill`. + fn file_source(page_flags: &[i32], fill: u8) -> &'static [u8] { + let len = page_flags.len() * HOST_PAGE_SIZE; + let path = + std::env::temp_dir().join(format!("litebox-cow-source-{}-{fill}", std::process::id())); + std::fs::write(&path, vec![fill; len]).unwrap(); + let source_file = std::fs::File::open(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + let fd = std::os::fd::AsRawFd::as_raw_fd(&source_file); + // SAFETY: a fresh file mapping, leaked for the program lifetime and never written. + let base = unsafe { + libc::mmap( + core::ptr::null_mut(), + len, + libc::PROT_READ, + page_flags[0], + fd, + 0, + ) + }; + assert_ne!(base, libc::MAP_FAILED); + for (index, flags) in page_flags.iter().enumerate().skip(1) { + let offset = index * HOST_PAGE_SIZE; + // SAFETY: MAP_FIXED replaces a page of this call's own mapping, which has no users. + let page = unsafe { + libc::mmap( + base.byte_add(offset), + HOST_PAGE_SIZE, + libc::PROT_READ, + flags | libc::MAP_FIXED, + fd, + i64::try_from(offset).unwrap(), + ) + }; + assert_ne!(page, libc::MAP_FAILED); + } + // SAFETY: the mapping is readable for `len` bytes for the program lifetime. + unsafe { core::slice::from_raw_parts(base.cast(), len) } + } + + #[test] + fn copy_on_write_sources_must_allow_read_write_and_execute() { + let p = MacosUserland::new(); + let (target, _release) = owned_marked_range(p, 2 * HOST_PAGE_SIZE, 0x5a); + let range = target.range(); + let ptr = + ::RawMutPointer::::from_usize( + range.start, + ); + // A read-only MAP_SHARED file mapping's maximum protection lacks WRITE. A source that + // spans it and a MAP_PRIVATE page gets the intersection of both maximums. + let shared = file_source(&[libc::MAP_SHARED], 0x11); + let mixed = file_source(&[libc::MAP_PRIVATE, libc::MAP_SHARED], 0x22); + for (source, permissions) in [ + (shared, MemoryRegionPermissions::READ), + (shared, RW), + (mixed, MemoryRegionPermissions::READ), + ] { + // SAFETY: the test-owned pages are idle; a failed replacement must leave them intact. + let result = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("the replacement failed") }, + range.start, + source, + permissions, + FixedAddressBehavior::Replace, + ) + }; + assert!(matches!( + result, + Err(CowAllocationError::UnsupportedSourceRegion) + )); + assert!(native_owns_any(p, &range)); + for offset in [0, HOST_PAGE_SIZE.cast_signed()] { + assert_eq!(ptr.read_at_offset(offset), Some(0x5a)); + assert_eq!(ptr.write_at_offset(offset + 1, 0x6b), Some(())); + } + } + + // A MAP_PRIVATE file mapping allows read, write and execute; writes stay in the copy. + let private = file_source(&[libc::MAP_PRIVATE, libc::MAP_PRIVATE], 0x33); + // SAFETY: the test-owned pages are idle. + let copy = unsafe { + p.try_allocate_cow_pages( + || core::iter::once(target), + range.start, + private, + MemoryRegionPermissions::READ, + FixedAddressBehavior::Replace, + ) + } + .unwrap(); + assert_eq!(ptr.read_at_offset(0), Some(0x33)); + // SAFETY: the test owns the idle copy. + unsafe { p.protect_pages(|| core::iter::once(©), range.clone(), RW) }.unwrap(); + assert_eq!(ptr.write_at_offset(0, 0x44), Some(())); + assert_eq!(ptr.read_at_offset(0), Some(0x44)); + assert_eq!(private[0], 0x33, "writes never reach the source"); + } + + #[test] + #[cfg(feature = "subpage_compat")] + fn subpage_instances_leave_copy_on_write_to_callers() { + let p = MacosUserland4K::new(); + let source = cow_source(HOST_PAGE_SIZE, 0x11); + // SAFETY: an unsupported request has no side effects. + let result = unsafe { + p.try_allocate_cow_pages( + || -> core::iter::Empty<_> { unreachable!("unsupported") }, + TASK_ADDR_MIN, + source, + MemoryRegionPermissions::READ, + FixedAddressBehavior::Replace, + ) + }; + assert!(matches!( + result, + Err(CowAllocationError::UnsupportedByPlatform) + )); + } } diff --git a/litebox_platform_macos_userland/src/native.rs b/litebox_platform_macos_userland/src/native.rs index 22ee2f1d9f..b7219ed888 100644 --- a/litebox_platform_macos_userland/src/native.rs +++ b/litebox_platform_macos_userland/src/native.rs @@ -7,16 +7,21 @@ //! constant number of syscalls, plus one per unowned gap or owned run where the semantics //! require it. +use litebox::utils::TruncateExt as _; use std::ops::Range; use rangemap::RangeSet; use super::{ - AllocationError, DeallocationError, FixedAddressBehavior, KernReturn, MachVmFlags, + AllocationError, CowAllocationError, DeallocationError, FixedAddressBehavior, HOST_PAGE_SIZE, + KernReturn, MachBoolean, MachVmFlags, MachVmInheritance, MachVmProtection, MemoryRegionPermissions as Perm, PermissionUpdateError, TASK_ADDR_MAX, TASK_ADDR_MIN, - mach_task_self, mach_vm_allocate, prot_flags, sys_icache_invalidate, + mach_task_self, mach_vm_allocate, mach_vm_remap, prot_flags, sys_icache_invalidate, }; +/// Guest copy-on-write pages inherit like `mmap`'d guest pages (`VM_INHERIT_DEFAULT`). +const GUEST_INHERITANCE: MachVmInheritance = MachVmInheritance::Copy; + #[derive(Default)] pub(super) struct Pages { /// Mappings created by this instance. `mprotect`, `munmap`, and `MAP_FIXED` may target only @@ -103,7 +108,82 @@ unsafe fn map_fixed(range: &Range, permissions: Perm) -> Result<(), Permi Ok(()) } +fn in_task_range(base: usize, len: usize) -> bool { + base >= TASK_ADDR_MIN + && base + .checked_add(len) + .is_some_and(|end| end <= TASK_ADDR_MAX) +} + +/// Map a private copy of `source` with `prot` wherever the kernel places it. +/// `copy=TRUE` gives the result its own copy-on-write object, so later writes on either side stay +/// private (`vm_map_remap`, osfmk/vm/vm_map.c). Legacy remap keeps each source entry's maximum +/// protection and reports their intersection (`vm_map_remap_extract`). The VMA layer records +/// every may-flag for a private copy, so a maximum without read, write and execute is refused. +fn remap_copy(source: &'static [u8], prot: i32) -> Result { + let len = source.len(); + let mut target = 0; + let mut current = MachVmProtection::empty(); + let mut maximum = MachVmProtection::empty(); + // SAFETY: ANYWHERE never overwrites existing mappings; `source` stays mapped and all outputs + // are valid. + let result = unsafe { + mach_vm_remap( + mach_task_self(), + &raw mut target, + len as u64, + 0, + MachVmFlags::ANYWHERE, + mach_task_self(), + source.as_ptr() as u64, + MachBoolean::TRUE, + &raw mut current, + &raw mut maximum, + GUEST_INHERITANCE, + ) + }; + if result != KernReturn::SUCCESS { + return Err(CowAllocationError::InternalFailure); + } + // SAFETY: mach_vm_remap just created this mapping. + let mapping = unsafe { Mapping::new(target.trunc(), len) }; + if !maximum + .contains(MachVmProtection::READ | MachVmProtection::WRITE | MachVmProtection::EXECUTE) + { + return Err(CowAllocationError::UnsupportedSourceRegion); + } + // SAFETY: the mapping was just created by this call and has no users. + if unsafe { libc::mprotect(mapping.base() as *mut _, len, prot) } != 0 { + return Err(CowAllocationError::InternalFailure); + } + Ok(mapping) +} + impl Pages { + /// Reserve every unowned gap of `range` with a FIXED allocation, which fails instead of + /// overwriting existing (host) memory. Dropping the result releases the reservations. + fn reserve_gaps(&self, range: &Range) -> Result, AllocationError> { + let mut reservations = Vec::new(); + for gap in self.owned.gaps(range) { + let mut address = gap.start as u64; + // SAFETY: a FIXED reservation fails instead of overwriting existing (host) memory. + let result = unsafe { + mach_vm_allocate( + mach_task_self(), + &raw mut address, + gap.len() as u64, + MachVmFlags::FIXED, + ) + }; + if result != KernReturn::SUCCESS { + return Err(result.into()); + } + // SAFETY: mach_vm_allocate just reserved this gap. + reservations.push(unsafe { Mapping::new(gap.start, gap.len()) }); + } + Ok(reservations) + } + pub(super) fn allocate( &mut self, range: Range, @@ -139,11 +219,7 @@ impl Pages { // SAFETY: mmap just created this mapping. let mapping = unsafe { Mapping::new(mapped as usize, range.len()) }; let base = mapping.base(); - if base < TASK_ADDR_MIN - || base - .checked_add(range.len()) - .is_none_or(|end| end > TASK_ADDR_MAX) - { + if !in_task_range(base, range.len()) { continue; } std::mem::forget(mapping); @@ -156,24 +232,7 @@ impl Pages { if behavior == FixedAddressBehavior::NoReplace && self.owned.overlaps(&range) { return Err(AllocationError::AddressInUse); } - let mut reservations = Vec::new(); - for gap in self.owned.gaps(&range) { - let mut address = gap.start as u64; - // SAFETY: a FIXED reservation fails instead of overwriting existing (host) memory. - let result = unsafe { - mach_vm_allocate( - mach_task_self(), - &raw mut address, - gap.len() as u64, - MachVmFlags::FIXED, - ) - }; - if result != KernReturn::SUCCESS { - return Err(result.into()); - } - // SAFETY: mach_vm_allocate just reserved this gap. - reservations.push(unsafe { Mapping::new(gap.start, gap.len()) }); - } + let reservations = self.reserve_gaps(&range)?; // SAFETY: owned pages and this call's reservations cover the range; the caller // excludes users of replaced pages. unsafe { map_fixed(&range, permissions) }.map_err(allocation_error)?; @@ -182,6 +241,97 @@ impl Pages { Ok(range.start) } + /// Map a private copy-on-write copy of the host pages of `source` at `start`. Writes never + /// reach `source`, and later changes to `source` need not become visible. `Replace` + /// overwrites only owned pages and gaps this call reserves; `NoReplace` refuses owned pages. + /// `Hint` is unsupported: the kernel's ANYWHERE placement ignores the requested direction. + /// Executable copies are unsupported (see below). `source`'s host mappings must allow read, + /// write and execute, as anonymous and `MAP_PRIVATE` file memory do; otherwise the result is + /// `UnsupportedSourceRegion`. Any failure leaves the target and `owned` unchanged. + pub(super) fn allocate_copy_on_write( + &mut self, + start: usize, + source: &'static [u8], + permissions: Perm, + behavior: FixedAddressBehavior, + ) -> Result { + if matches!(behavior, FixedAddressBehavior::Hint(_)) { + return Err(CowAllocationError::UnsupportedByPlatform); + } + let len = source.len(); + if len == 0 + || !len.is_multiple_of(HOST_PAGE_SIZE) + || !start.is_multiple_of(HOST_PAGE_SIZE) + || !(source.as_ptr() as usize).is_multiple_of(HOST_PAGE_SIZE) + { + return Err(CowAllocationError::Unaligned); + } + if permissions.contains(Perm::WRITE | Perm::EXEC) { + return Err(CowAllocationError::InternalFailure); + } + // On arm64 XNU syncs the icache only on a page's first executable mapping + // (`vm_fault_enter_set_mapped`, osfmk/vm/vm_fault.c), so an executable copy of rewritten + // source pages at an address that ran other code can run stale instructions. Only + // `update_permissions` invalidates. + if permissions.contains(Perm::EXEC) { + return Err(CowAllocationError::UnsupportedByPlatform); + } + // Panics on SHARED before any side effect. + let prot = native_prot(permissions); + if !in_task_range(start, len) { + return Err(CowAllocationError::InternalFailure); + } + let range = start..start + len; + if behavior == FixedAddressBehavior::NoReplace && self.owned.overlaps(&range) { + return Err(CowAllocationError::InternalFailure); + } + // Reserve gaps before staging so the staged copy cannot land in one. + let reservations = self + .reserve_gaps(&range) + .map_err(|_| CowAllocationError::InternalFailure)?; + // Stage the copy with its final protections elsewhere first, so a failure leaves the + // target untouched. `mach_vm_remap_new` could copy straight to the target with + // FIXED|OVERWRITE, but it rejects a maximum protection holding both write and execute + // (`mach_vm_remap_new_external`, osfmk/vm/vm_user.c), so its copy could never later + // switch between RW and RX. + let staged = remap_copy(source, prot)?; + let mut target = range.start as u64; + let mut current = MachVmProtection::empty(); + let mut maximum = MachVmProtection::empty(); + // XNU extracts the staged entries, with their protections, before the atomic + // FIXED|OVERWRITE removal (`vm_map_remap`, `vm_map_locate_space_fixed`), so a failure + // leaves the target as it was. + // SAFETY: owned pages and this call's reservations cover the range; the caller excludes + // users of replaced pages. + let result = unsafe { + mach_vm_remap( + mach_task_self(), + &raw mut target, + len as u64, + 0, + MachVmFlags::FIXED | MachVmFlags::OVERWRITE, + mach_task_self(), + staged.base() as u64, + MachBoolean::FALSE, + &raw mut current, + &raw mut maximum, + GUEST_INHERITANCE, + ) + }; + if result != KernReturn::SUCCESS { + return Err(CowAllocationError::InternalFailure); + } + // The target holds its own reference to the copy; `staged` unmaps on drop. + reservations.into_iter().for_each(std::mem::forget); + self.owned.insert(range); + Ok(start) + } + + #[cfg(test)] + pub(super) fn owns_any(&self, range: &Range) -> bool { + self.owned.overlaps(range) + } + pub(super) fn update_permissions( &mut self, range: Range,