From 566e9a12a62a677bed0abbd1998d533cfa01760c Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 07:58:33 +0200 Subject: [PATCH 1/8] add mock host for tests --- src/lib.rs | 3 ++ src/runtime/mock.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++ src/runtime/mod.rs | 2 ++ 3 files changed, 79 insertions(+) create mode 100644 src/runtime/mock.rs diff --git a/src/lib.rs b/src/lib.rs index e178811c..403a41fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -127,6 +127,9 @@ #[cfg(feature = "alloc")] extern crate alloc; +#[cfg(all(test, not(target_family = "wasm")))] +extern crate std; + mod primitives; mod runtime; diff --git a/src/runtime/mock.rs b/src/runtime/mock.rs new file mode 100644 index 00000000..baf18e9a --- /dev/null +++ b/src/runtime/mock.rs @@ -0,0 +1,74 @@ +//! A fake host for tests: definitions of the wasm imports the runtime layer +//! links against, backed by in-memory images so readers can run on the host. + +use core::{cell::RefCell, num::NonZeroU64}; + +use std::vec::Vec; + +use crate::Process; + +std::thread_local! { + static MEMORY: RefCell)>> = const { RefCell::new(Vec::new()) }; +} + +/// Runs a test against a process whose memory holds the given regions, each an +/// address and the bytes starting there. Reads outside every region fail. +pub fn with_process(regions: &[(u64, &[u8])], test: impl FnOnce(&Process) -> R) -> R { + MEMORY.with(|memory| { + *memory.borrow_mut() = regions + .iter() + .map(|&(address, bytes)| (address, bytes.to_vec())) + .collect(); + }); + let process = Process::attach("mock").expect("the mock always attaches"); + test(&process) +} + +#[no_mangle] +extern "C" fn process_attach(_name_ptr: *const u8, _name_len: usize) -> Option { + NonZeroU64::new(1) +} + +#[no_mangle] +extern "C" fn process_detach(_process: u64) {} + +#[no_mangle] +extern "C" fn process_read(_process: u64, address: u64, buf_ptr: *mut u8, buf_len: usize) -> bool { + MEMORY.with(|memory| { + memory.borrow().iter().any(|(start, bytes)| { + let Some(offset) = address.checked_sub(*start) else { + return false; + }; + let Ok(offset) = usize::try_from(offset) else { + return false; + }; + if !offset + .checked_add(buf_len) + .is_some_and(|end| end <= bytes.len()) + { + return false; + } + // SAFETY: The runtime layer passes a buffer valid for buf_len + // bytes, and the range is checked to lie inside the region. + unsafe { + core::ptr::copy_nonoverlapping(bytes.as_ptr().add(offset), buf_ptr, buf_len); + } + true + }) + }) +} + +#[cfg(test)] +mod tests { + use super::with_process; + + #[test] + fn reads_come_from_the_regions() { + with_process(&[(0x1000, &[1, 2, 3, 4])], |process| { + assert_eq!(process.read::(0x1000_u64).unwrap(), 0x04030201); + assert_eq!(process.read::<[u8; 2]>(0x1002_u64).unwrap(), [3, 4]); + assert!(process.read::(0x0FFF_u64).is_err()); + assert!(process.read::(0x1001_u64).is_err()); + }); + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 21164a14..742dbec2 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -5,6 +5,8 @@ mod memory_range; mod process; mod sys; +#[cfg(all(test, not(target_family = "wasm")))] +pub(crate) mod mock; pub mod settings; pub mod timer; From 12e5541715523f941be5d0b316c8c299f163b510 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 07:59:17 +0200 Subject: [PATCH 2/8] add pe debug id read --- src/file_format/pe.rs | 292 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/src/file_format/pe.rs b/src/file_format/pe.rs index 46f63cb1..b5bda8ca 100644 --- a/src/file_format/pe.rs +++ b/src/file_format/pe.rs @@ -95,6 +95,94 @@ struct OptionalCOFFHeader { // There's more but those vary depending on whether it's PE or PE+. } +// The magic at the head of the optional header decides between the PE32 and +// PE32+ layouts. +const OPTIONAL_HEADER_MAGIC_PE32: u16 = 0x10B; +const OPTIONAL_HEADER_MAGIC_PE32_PLUS: u16 = 0x20B; + +/// An entry of the data directory array at the end of the optional header, +/// naming where one of the image's tables lives. +#[derive(Debug, Copy, Clone, Zeroable, Pod)] +#[repr(C)] +struct DataDirectory { + virtual_address: u32, + size: u32, +} + +/// The full PE32 optional header. +#[derive(Debug, Copy, Clone, Zeroable, Pod)] +#[repr(C)] +struct OptionalHeader32 { + magic: u16, + major_linker_version: u8, + minor_linker_version: u8, + size_of_code: u32, + size_of_initialized_data: u32, + size_of_uninitialized_data: u32, + address_of_entry_point: u32, + base_of_code: u32, + base_of_data: u32, + image_base: u32, + section_alignment: u32, + file_alignment: u32, + major_operating_system_version: u16, + minor_operating_system_version: u16, + major_image_version: u16, + minor_image_version: u16, + major_subsystem_version: u16, + minor_subsystem_version: u16, + win32_version_value: u32, + size_of_image: u32, + size_of_headers: u32, + checksum: u32, + subsystem: u16, + dll_characteristics: u16, + size_of_stack_reserve: u32, + size_of_stack_commit: u32, + size_of_heap_reserve: u32, + size_of_heap_commit: u32, + loader_flags: u32, + number_of_rva_and_sizes: u32, + data_directories: [DataDirectory; 16], +} + +/// The full PE32+ optional header, which drops `base_of_data` and widens the +/// image base and the stack and heap sizes. +#[derive(Debug, Copy, Clone, Zeroable, Pod)] +#[repr(C)] +struct OptionalHeader64 { + magic: u16, + major_linker_version: u8, + minor_linker_version: u8, + size_of_code: u32, + size_of_initialized_data: u32, + size_of_uninitialized_data: u32, + address_of_entry_point: u32, + base_of_code: u32, + image_base: u64, + section_alignment: u32, + file_alignment: u32, + major_operating_system_version: u16, + minor_operating_system_version: u16, + major_image_version: u16, + minor_image_version: u16, + major_subsystem_version: u16, + minor_subsystem_version: u16, + win32_version_value: u32, + size_of_image: u32, + size_of_headers: u32, + checksum: u32, + subsystem: u16, + dll_characteristics: u16, + size_of_stack_reserve: u64, + size_of_stack_commit: u64, + size_of_heap_reserve: u64, + size_of_heap_commit: u64, + loader_flags: u32, + number_of_rva_and_sizes: u32, + data_directories: [DataDirectory; 16], +} + #[derive(Debug, Copy, Clone, Zeroable, Pod, Default)] #[repr(C)] struct ExportedSymbolsTableDef { @@ -484,3 +572,207 @@ impl FileVersion { .map(|val| val.file_version) } } + +/// The identity of the debug information of a PE module, as recorded in the +/// module's CodeView debug directory entry. Every build of a module gets a +/// fresh identity, so it names one exact binary: symbol servers key their +/// downloads on the GUID and age pair. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub struct DebugId { + /// The GUID of the debug information, in the byte order it is stored in. + pub guid: [u8; 16], + /// The number of times the debug information was written out. + pub age: u32, +} + +impl DebugId { + /// Reads the debug identity from the CodeView entry of the debug directory + /// of the PE module starting at the specified memory address. Returns + /// `None` if the module has no debug directory, no CodeView entry, or + /// debug information in a format older than PDB 7.0. + pub fn read(process: &Process, module_address: impl Into
) -> Option { + #[repr(C)] + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + struct DebugDirectoryEntry { + characteristics: u32, + time_date_stamp: u32, + major_version: u16, + minor_version: u16, + debug_type: u32, + size_of_data: u32, + address_of_raw_data: u32, + pointer_to_raw_data: u32, + } + + #[repr(C)] + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + struct CodeView70 { + signature: [u8; 4], + guid: [u8; 16], + age: u32, + } + + const IMAGE_DIRECTORY_ENTRY_DEBUG: usize = 6; + const IMAGE_DEBUG_TYPE_CODEVIEW: u32 = 2; + + let address: Address = module_address.into(); + + let (coff_header, coff_header_address) = read_coff_header(process, address)?; + + let optional_header_address = coff_header_address + mem::size_of::() as u64; + + let (optional_header_size, directory) = + match process.read::(optional_header_address).ok()? { + OPTIONAL_HEADER_MAGIC_PE32 => ( + mem::size_of::(), + process + .read::(optional_header_address) + .ok()? + .data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], + ), + OPTIONAL_HEADER_MAGIC_PE32_PLUS => ( + mem::size_of::(), + process + .read::(optional_header_address) + .ok()? + .data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], + ), + _ => return None, + }; + + if (coff_header.size_of_optional_header as usize) < optional_header_size { + return None; + } + + let directory = Some(directory) + .filter(|directory| directory.virtual_address != 0 && directory.size != 0)?; + + let entries = directory.size as usize / mem::size_of::(); + + // The walk is bounded so a corrupt entry count can't turn it into a scan. + (0..entries.min(0x10)).find_map(|i| { + let entry = process + .read::( + address + + directory.virtual_address + + (i * mem::size_of::()) as u64, + ) + .ok() + .filter(|entry| { + entry.debug_type == IMAGE_DEBUG_TYPE_CODEVIEW && entry.address_of_raw_data != 0 + })?; + + process + .read::(address + entry.address_of_raw_data) + .ok() + .filter(|codeview| codeview.signature == *b"RSDS") + .map(|codeview| Self { + guid: codeview.guid, + age: codeview.age, + }) + }) + } +} + +impl fmt::Debug for DebugId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // The first three fields of the GUID are stored little-endian and + // render big-endian in the canonical form. + let [a0, a1, a2, a3, b0, b1, c0, c1, d0, d1, d2, d3, d4, d5, d6, d7] = self.guid; + write!( + f, + "{:08x}-{:04x}-{:04x}-{d0:02x}{d1:02x}-{d2:02x}{d3:02x}{d4:02x}{d5:02x}{d6:02x}{d7:02x} (age {})", + u32::from_le_bytes([a0, a1, a2, a3]), + u16::from_le_bytes([b0, b1]), + u16::from_le_bytes([c0, c1]), + self.age, + ) + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::DebugId; + use crate::runtime::mock::with_process; + + use std::{vec, vec::Vec}; + + const BASE: u64 = 0x7FF6_1000_0000; + + // The 2019.4 mono runtime's GUID, stored as CodeView stores it: the first + // three fields little-endian, the rest in order. + const GUID: [u8; 16] = [ + 0xC7, 0xAA, 0x10, 0x77, 0x5A, 0x31, 0x30, 0x4D, 0xA7, 0x7A, 0x08, 0x07, 0x29, 0x69, 0x66, + 0xF6, + ]; + + fn put(image: &mut [u8], at: usize, bytes: &[u8]) { + image[at..at + bytes.len()].copy_from_slice(bytes); + } + + // Builds a minimal mapped PE image by hand from the spec, so the walk is + // checked against the format rather than against itself: headers at the + // base, a two-entry debug directory with the CodeView entry second, and + // the PDB 7.0 record it points at. + fn image(wide: bool) -> Vec { + let mut image = vec![0; 0x400]; + put(&mut image, 0x00, b"MZ"); + put(&mut image, 0x3C, &0x80_u32.to_le_bytes()); + put(&mut image, 0x80, b"PE\0\0"); + let size_of_optional_header: u16 = if wide { 0xF0 } else { 0xE0 }; + put(&mut image, 0x94, &size_of_optional_header.to_le_bytes()); + let magic: u16 = if wide { 0x20B } else { 0x10B }; + put(&mut image, 0x98, &magic.to_le_bytes()); + let debug_dd_at = 0x98 + if wide { 0xA0 } else { 0x90 }; + put(&mut image, debug_dd_at, &0x200_u32.to_le_bytes()); + put(&mut image, debug_dd_at + 4, &(2 * 28_u32).to_le_bytes()); + // Entry 0 is POGO data, entry 1 the CodeView record. + put(&mut image, 0x200 + 0xC, &13_u32.to_le_bytes()); + put(&mut image, 0x21C + 0xC, &2_u32.to_le_bytes()); + put(&mut image, 0x21C + 0x10, &0x30_u32.to_le_bytes()); + put(&mut image, 0x21C + 0x14, &0x300_u32.to_le_bytes()); + put(&mut image, 0x300, b"RSDS"); + put(&mut image, 0x304, &GUID); + put(&mut image, 0x314, &1_u32.to_le_bytes()); + put(&mut image, 0x318, b"mono-2.0-bdwgc.pdb\0"); + image + } + + #[test] + fn reads_the_debug_id_from_a_mapped_image() { + for wide in [true, false] { + with_process(&[(BASE, &image(wide))], |process| { + let debug_id = DebugId::read(process, BASE).unwrap(); + assert_eq!(debug_id.guid, GUID); + assert_eq!(debug_id.age, 1); + }); + } + } + + #[test] + fn renders_the_guid_canonically() { + let debug_id = DebugId { guid: GUID, age: 1 }; + assert_eq!( + std::format!("{debug_id:?}"), + "7710aac7-315a-4d30-a77a-0807296966f6 (age 1)", + ); + } + + #[test] + fn answers_nothing_without_a_debug_directory() { + let mut image = image(true); + put(&mut image, 0x98 + 0xA0, &[0; 8]); + with_process(&[(BASE, &image)], |process| { + assert!(DebugId::read(process, BASE).is_none()); + }); + } + + #[test] + fn answers_nothing_for_debug_information_older_than_pdb_70() { + let mut image = image(true); + put(&mut image, 0x300, b"NB10"); + with_process(&[(BASE, &image)], |process| { + assert!(DebugId::read(process, BASE).is_none()); + }); + } +} From bd273c4a46b76912000dec46a07c6c5237ddbce1 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 08:05:00 +0200 Subject: [PATCH 3/8] add elf build id read --- src/file_format/elf.rs | 209 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/src/file_format/elf.rs b/src/file_format/elf.rs index 179bfede..917e9256 100644 --- a/src/file_format/elf.rs +++ b/src/file_format/elf.rs @@ -1074,7 +1074,7 @@ struct SymTab64 { /// /// By using this function, the user must be aware of the following limitations: /// - Only allocatable symbols and symbols used by the dynamic linker are exported -/// (.symtab is not loaded in memory at runtime) +/// (.symtab is not loaded in memory at runtime) /// - Only 64-bit ELFs are supported (an empty iterator will be returned for 32-bit ELFs) pub fn symbols( process: &Process, @@ -1157,3 +1157,210 @@ pub fn symbols( }) .fuse() } + +/// The GNU build ID of an ELF module, read from its `NT_GNU_BUILD_ID` note. +/// The linker derives it from the built binary, so it names one exact build. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub struct BuildId { + bytes: [u8; 32], + len: u8, +} + +impl BuildId { + /// The bytes of the build ID. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes[..self.len as usize] + } +} + +impl fmt::Debug for BuildId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.as_bytes() { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +/// Reads the GNU build ID from the notes of the ELF module starting at the +/// given address. Returns [`None`] if the module carries no such note, which +/// not every module does. Only little-endian ELFs are supported. +pub fn build_id(process: &Process, module_address: Address) -> Option { + #[derive(Debug, Copy, Clone, Pod, Zeroable)] + #[repr(C)] + struct NoteHeader { + n_namesz: u32, + n_descsz: u32, + n_type: u32, + } + + const NT_GNU_BUILD_ID: u32 = 3; + + let header = process.read::
(module_address).ok()?; + let info = Info::parse(bytemuck::bytes_of(&header))?; + + if info.endian != Endian::Little { + return None; + } + + let (e_phoff, e_phentsize, e_phnum) = if info.bitness.is_64() { + let header = process.read::(module_address).ok()?; + (header.e_phoff, header.e_phentsize as u64, header.e_phnum) + } else { + let header = process.read::(module_address).ok()?; + ( + header.e_phoff as u64, + header.e_phentsize as u64, + header.e_phnum, + ) + }; + + (0..e_phnum).find_map(|index| { + let at = module_address + e_phoff + e_phentsize.wrapping_mul(index as u64); + + let (p_type, p_vaddr, p_filesz) = if info.bitness.is_64() { + let program_header = process.read::(at).ok()?; + ( + program_header.p_type, + program_header.p_vaddr, + program_header.p_filesz, + ) + } else { + let program_header = process.read::(at).ok()?; + ( + program_header.p_type, + program_header.p_vaddr as u64, + program_header.p_filesz as u64, + ) + }; + + if SegmentType(p_type) != SegmentType::PT_NOTE { + return None; + } + + // A note is its header, the name, then the data, the latter two padded + // to four bytes. + let segment = module_address + p_vaddr; + let mut offset = 0; + // The walk is bounded so corrupt sizes can't turn it into a scan. + for _ in 0..0x10 { + if offset + size_of::() as u64 > p_filesz { + return None; + } + + let note = process.read::(segment + offset).ok()?; + let name = offset + size_of::() as u64; + let desc = name + (note.n_namesz as u64).next_multiple_of(4); + + if note.n_type == NT_GNU_BUILD_ID + && note.n_namesz == 4 + && (1..=32).contains(¬e.n_descsz) + && desc + note.n_descsz as u64 <= p_filesz + && process.read::<[u8; 4]>(segment + name).ok()? == *b"GNU\0" + { + let mut bytes = [0; 32]; + process + .read_into_buf(segment + desc, &mut bytes[..note.n_descsz as usize]) + .ok()?; + return Some(BuildId { + bytes, + len: note.n_descsz as u8, + }); + } + + offset = desc + (note.n_descsz as u64).next_multiple_of(4); + } + + None + }) +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::build_id; + use crate::runtime::mock::with_process; + + use std::{format, vec, vec::Vec}; + + const BASE: u64 = 0x7F12_3450_0000; + + // The build ID of the 2019.1 mono runtime, a sha1 the linker computed. + const BUILD_ID: [u8; 20] = [ + 0xE6, 0xAA, 0x00, 0x0A, 0x9A, 0x52, 0x01, 0x63, 0x57, 0x43, 0xC6, 0xA1, 0xB2, 0x78, 0x1E, + 0x05, 0x7D, 0x65, 0x1D, 0xAD, + ]; + + fn put(image: &mut [u8], at: usize, bytes: &[u8]) { + image[at..at + bytes.len()].copy_from_slice(bytes); + } + + // Builds a minimal mapped ELF by hand from the spec, so the walk is + // checked against the format rather than against itself: the header, a + // load and a note program header, and a note segment holding an ABI tag + // note followed by the build ID note. + fn image(wide: bool) -> Vec { + let mut image = vec![0; 0x400]; + put(&mut image, 0x00, b"\x7fELF"); + image[0x04] = if wide { 2 } else { 1 }; + image[0x05] = 1; + image[0x06] = 1; + put(&mut image, 0x10, &3_u16.to_le_bytes()); + if wide { + put(&mut image, 0x20, &0x40_u64.to_le_bytes()); + put(&mut image, 0x36, &56_u16.to_le_bytes()); + put(&mut image, 0x38, &2_u16.to_le_bytes()); + put(&mut image, 0x40, &1_u32.to_le_bytes()); + put(&mut image, 0x78, &4_u32.to_le_bytes()); + put(&mut image, 0x88, &0x200_u64.to_le_bytes()); + put(&mut image, 0x98, &0x44_u64.to_le_bytes()); + } else { + put(&mut image, 0x1C, &0x34_u32.to_le_bytes()); + put(&mut image, 0x2A, &32_u16.to_le_bytes()); + put(&mut image, 0x2C, &2_u16.to_le_bytes()); + put(&mut image, 0x34, &1_u32.to_le_bytes()); + put(&mut image, 0x54, &4_u32.to_le_bytes()); + put(&mut image, 0x5C, &0x200_u32.to_le_bytes()); + put(&mut image, 0x64, &0x44_u32.to_le_bytes()); + } + put(&mut image, 0x200, &4_u32.to_le_bytes()); + put(&mut image, 0x204, &16_u32.to_le_bytes()); + put(&mut image, 0x208, &1_u32.to_le_bytes()); + put(&mut image, 0x20C, b"GNU\0"); + put(&mut image, 0x220, &4_u32.to_le_bytes()); + put(&mut image, 0x224, &20_u32.to_le_bytes()); + put(&mut image, 0x228, &3_u32.to_le_bytes()); + put(&mut image, 0x22C, b"GNU\0"); + put(&mut image, 0x230, &BUILD_ID); + image + } + + #[test] + fn reads_the_build_id_from_a_mapped_image() { + for wide in [true, false] { + with_process(&[(BASE, &image(wide))], |process| { + let build_id = build_id(process, BASE.into()).unwrap(); + assert_eq!(build_id.as_bytes(), BUILD_ID); + }); + } + } + + #[test] + fn renders_the_id_as_hex() { + with_process(&[(BASE, &image(true))], |process| { + let build_id = build_id(process, BASE.into()).unwrap(); + assert_eq!( + format!("{build_id:?}"), + "e6aa000a9a5201635743c6a1b2781e057d651dad", + ); + }); + } + + #[test] + fn answers_nothing_without_a_note_segment() { + let mut image = image(true); + put(&mut image, 0x78, &0_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(build_id(process, BASE.into()).is_none()); + }); + } +} From f279e81130dd2aba88786a572a5a19fa66cffd54 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 08:06:05 +0200 Subject: [PATCH 4/8] add mach-o uuid read --- src/file_format/macho.rs | 148 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 5 deletions(-) diff --git a/src/file_format/macho.rs b/src/file_format/macho.rs index 147a24c6..c19abad2 100644 --- a/src/file_format/macho.rs +++ b/src/file_format/macho.rs @@ -1,11 +1,15 @@ //! Support for parsing Mach-O format +use core::{fmt, mem}; + #[cfg(feature = "alloc")] use core::iter::FusedIterator; #[cfg(feature = "alloc")] use alloc::collections::BTreeMap; +use bytemuck::{Pod, Zeroable}; + #[cfg(feature = "alloc")] use crate::{string::ArrayCString, Error}; use crate::{Address, PointerSize, Process}; @@ -36,11 +40,8 @@ fn scan_macho_page(process: &Process, range: (Address, u64)) -> Option
let first_page = addr + distance_to_page; for i in 0..((len - distance_to_page) / PAGE_SIZE) { let a = first_page + (i * PAGE_SIZE); - match process.read::(a) { - Ok(MH_MAGIC_64 | MH_CIGAM_64 | MH_MAGIC_32 | MH_CIGAM_32) => { - return Some(a); - } - _ => (), + if let Ok(MH_MAGIC_64 | MH_CIGAM_64 | MH_MAGIC_32 | MH_CIGAM_32) = process.read::(a) { + return Some(a); } } None @@ -71,6 +72,8 @@ fn scan_macho_pages( // Constants for the cmd field of load commands, the type // https://opensource.apple.com/source/xnu/xnu-4570.71.2/EXTERNAL_HEADERS/mach-o/loader.h.auto.html +/// the uuid +const LC_UUID: u32 = 0x1b; /// link-edit stab symbol table info #[cfg(feature = "alloc")] const LC_SYMTAB: u32 = 0x2; @@ -78,6 +81,77 @@ const LC_SYMTAB: u32 = 0x2; #[cfg(feature = "alloc")] const LC_SEGMENT_64: u32 = 0x19; +/// The UUID of a Mach-O module, from its `LC_UUID` load command. The linker +/// derives it from the built binary, so it names one exact build. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub struct Uuid { + /// The bytes of the UUID. + pub bytes: [u8; 16], +} + +impl fmt::Debug for Uuid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, byte) in self.bytes.iter().enumerate() { + if let 4 | 6 | 8 | 10 = i { + f.write_str("-")?; + } + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +/// Reads the UUID from the load commands of the Mach-O module in the given +/// range. Returns [`None`] if the module carries no `LC_UUID` command. +pub fn uuid(process: &Process, range: (Address, u64)) -> Option { + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + #[repr(C)] + struct MachHeader { + magic: u32, + cputype: u32, + cpusubtype: u32, + filetype: u32, + ncmds: u32, + sizeofcmds: u32, + flags: u32, + } + + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + #[repr(C)] + struct LoadCommand { + cmd: u32, + cmdsize: u32, + } + + let page = scan_macho_page(process, range)?; + let header = process.read::(page).ok()?; + + // The 64-bit header ends with one reserved field the 32-bit one lacks. + let commands = page + + match header.magic { + MH_MAGIC_64 => mem::size_of::() + mem::size_of::(), + MH_MAGIC_32 => mem::size_of::(), + _ => return None, + } as u64; + + let mut offset = 0; + // The walk is bounded so a corrupt command count can't turn it into a scan. + for _ in 0..header.ncmds.min(0x40) { + let command = process.read::(commands + offset).ok()?; + + if command.cmd == LC_UUID { + return process + .read::<[u8; 16]>(commands + offset + mem::size_of::() as u64) + .ok() + .map(|bytes| Uuid { bytes }); + } + + offset += command.cmdsize as u64; + } + + None +} + #[cfg(feature = "alloc")] struct MachOFormatOffsets { number_of_commands: u32, @@ -205,3 +279,67 @@ fn fileoff_to_vmaddr(map: &BTreeMap, fileoff: u64) -> u64 { .map(|(&k, &v)| v + fileoff - k) .unwrap_or(fileoff) } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::uuid; + use crate::runtime::mock::with_process; + + use std::{format, vec, vec::Vec}; + + const BASE: u64 = 0x1_0000_0000; + + // The UUID of the 2019.4 mono runtime shipped with a real mac player. + const UUID: [u8; 16] = [ + 0xE7, 0x42, 0x0B, 0xC7, 0xA2, 0x6B, 0x33, 0xFA, 0xB5, 0xCD, 0x41, 0xCA, 0xD7, 0xD4, 0x61, + 0x4C, + ]; + + fn put(image: &mut [u8], at: usize, bytes: &[u8]) { + image[at..at + bytes.len()].copy_from_slice(bytes); + } + + // Builds a minimal mapped Mach-O by hand from the loader header, so the + // walk is checked against the format rather than against itself: the + // header, a segment command, and the uuid command. + fn image(wide: bool) -> Vec { + let mut image = vec![0; 0x1000]; + let magic: u32 = if wide { 0xFEEDFACF } else { 0xFEEDFACE }; + put(&mut image, 0x00, &magic.to_le_bytes()); + put(&mut image, 0x10, &2_u32.to_le_bytes()); + let commands = if wide { 0x20 } else { 0x1C }; + put(&mut image, commands, &0x19_u32.to_le_bytes()); + put(&mut image, commands + 0x4, &0x48_u32.to_le_bytes()); + put(&mut image, commands + 0x48, &0x1B_u32.to_le_bytes()); + put(&mut image, commands + 0x4C, &24_u32.to_le_bytes()); + put(&mut image, commands + 0x50, &UUID); + image + } + + #[test] + fn reads_the_uuid_from_a_mapped_image() { + for wide in [true, false] { + with_process(&[(BASE, &image(wide))], |process| { + let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); + assert_eq!(uuid.bytes, UUID); + }); + } + } + + #[test] + fn renders_the_uuid_canonically() { + with_process(&[(BASE, &image(true))], |process| { + let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); + assert_eq!(format!("{uuid:?}"), "e7420bc7-a26b-33fa-b5cd-41cad7d4614c"); + }); + } + + #[test] + fn answers_nothing_without_a_uuid_command() { + let mut image = image(true); + put(&mut image, 0x20 + 0x48, &0_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); + }); + } +} From 901f5c5e052168913c3a89ee27e5a42023267239 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 5 Sep 2026 18:39:28 +0200 Subject: [PATCH 5/8] limit pe identity read to declared sizes --- src/file_format/pe.rs | 172 +++++++++++++++++++++++++++++++----------- 1 file changed, 130 insertions(+), 42 deletions(-) diff --git a/src/file_format/pe.rs b/src/file_format/pe.rs index b5bda8ca..6d5f862f 100644 --- a/src/file_format/pe.rs +++ b/src/file_format/pe.rs @@ -621,56 +621,83 @@ impl DebugId { let optional_header_address = coff_header_address + mem::size_of::() as u64; - let (optional_header_size, directory) = + let (directories_at, directory_count, size_of_image, directory) = match process.read::(optional_header_address).ok()? { - OPTIONAL_HEADER_MAGIC_PE32 => ( - mem::size_of::(), - process + OPTIONAL_HEADER_MAGIC_PE32 => { + let header = process .read::(optional_header_address) - .ok()? - .data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], - ), - OPTIONAL_HEADER_MAGIC_PE32_PLUS => ( - mem::size_of::(), - process + .ok()?; + ( + mem::offset_of!(OptionalHeader32, data_directories), + header.number_of_rva_and_sizes, + header.size_of_image, + header.data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], + ) + } + OPTIONAL_HEADER_MAGIC_PE32_PLUS => { + let header = process .read::(optional_header_address) - .ok()? - .data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], - ), + .ok()?; + ( + mem::offset_of!(OptionalHeader64, data_directories), + header.number_of_rva_and_sizes, + header.size_of_image, + header.data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], + ) + } _ => return None, }; - if (coff_header.size_of_optional_header as usize) < optional_header_size { + // The directories close the optional header, and an image declares how + // many it has, so the debug slot exists only when both that count and + // the header's declared size reach it. + let debug_slot_end = + directories_at + (IMAGE_DIRECTORY_ENTRY_DEBUG + 1) * mem::size_of::(); + if directory_count as usize <= IMAGE_DIRECTORY_ENTRY_DEBUG + || (coff_header.size_of_optional_header as usize) < debug_slot_end + { return None; } - let directory = Some(directory) - .filter(|directory| directory.virtual_address != 0 && directory.size != 0)?; - - let entries = directory.size as usize / mem::size_of::(); + if directory.virtual_address == 0 || directory.size == 0 { + return None; + } - // The walk is bounded so a corrupt entry count can't turn it into a scan. - (0..entries.min(0x10)).find_map(|i| { - let entry = process - .read::( - address - + directory.virtual_address - + (i * mem::size_of::()) as u64, - ) - .ok() - .filter(|entry| { - entry.debug_type == IMAGE_DEBUG_TYPE_CODEVIEW && entry.address_of_raw_data != 0 - })?; + // Everything the walk touches has to lie inside the image, which is + // what bounds it. + let inside = |offset: u32, size: u32| offset as u64 + size as u64 <= size_of_image as u64; + if !inside(directory.virtual_address, directory.size) { + return None; + } - process - .read::(address + entry.address_of_raw_data) - .ok() - .filter(|codeview| codeview.signature == *b"RSDS") - .map(|codeview| Self { - guid: codeview.guid, - age: codeview.age, - }) - }) + let entries = directory.size as usize / mem::size_of::(); + (0..entries) + .map_while(|i| { + // The table is contiguous, so an entry that can't be read ends it. + process + .read::( + address + + directory.virtual_address + + (i * mem::size_of::()) as u64, + ) + .ok() + }) + .filter(|entry| { + entry.debug_type == IMAGE_DEBUG_TYPE_CODEVIEW + && entry.address_of_raw_data != 0 + && entry.size_of_data as usize >= mem::size_of::() + && inside(entry.address_of_raw_data, entry.size_of_data) + }) + .find_map(|entry| { + process + .read::(address + entry.address_of_raw_data) + .ok() + .filter(|codeview| codeview.signature == *b"RSDS") + .map(|codeview| Self { + guid: codeview.guid, + age: codeview.age, + }) + }) } } @@ -712,8 +739,9 @@ mod tests { // Builds a minimal mapped PE image by hand from the spec, so the walk is // checked against the format rather than against itself: headers at the - // base, a two-entry debug directory with the CodeView entry second, and - // the PDB 7.0 record it points at. + // base declaring the image size and all sixteen directories, a two-entry + // debug directory with the CodeView entry second, and the PDB 7.0 record + // it points at. fn image(wide: bool) -> Vec { let mut image = vec![0; 0x400]; put(&mut image, 0x00, b"MZ"); @@ -723,7 +751,10 @@ mod tests { put(&mut image, 0x94, &size_of_optional_header.to_le_bytes()); let magic: u16 = if wide { 0x20B } else { 0x10B }; put(&mut image, 0x98, &magic.to_le_bytes()); - let debug_dd_at = 0x98 + if wide { 0xA0 } else { 0x90 }; + put(&mut image, 0x98 + 0x38, &0x400_u32.to_le_bytes()); + let directories_at = 0x98 + if wide { 0x70 } else { 0x60 }; + put(&mut image, directories_at - 4, &16_u32.to_le_bytes()); + let debug_dd_at = directories_at + 6 * 8; put(&mut image, debug_dd_at, &0x200_u32.to_le_bytes()); put(&mut image, debug_dd_at + 4, &(2 * 28_u32).to_le_bytes()); // Entry 0 is POGO data, entry 1 the CodeView record. @@ -775,4 +806,61 @@ mod tests { assert!(DebugId::read(process, BASE).is_none()); }); } + + #[test] + fn answers_nothing_when_the_directories_end_before_debug() { + let mut image = image(true); + put(&mut image, 0x98 + 0x6C, &6_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(DebugId::read(process, BASE).is_none()); + }); + } + + #[test] + fn reads_through_an_optional_header_that_ends_at_the_debug_slot() { + let mut image = image(true); + put(&mut image, 0x98 + 0x6C, &7_u32.to_le_bytes()); + put(&mut image, 0x94, &(0x70 + 7 * 8_u16).to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert_eq!(DebugId::read(process, BASE).unwrap().guid, GUID); + }); + } + + #[test] + fn answers_nothing_for_a_codeview_entry_too_short_for_its_record() { + let mut image = image(true); + put(&mut image, 0x21C + 0x10, &0x10_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(DebugId::read(process, BASE).is_none()); + }); + } + + #[test] + fn answers_nothing_for_a_debug_directory_outside_the_image() { + let mut image = image(true); + put(&mut image, 0x98 + 0x38, &0x200_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(DebugId::read(process, BASE).is_none()); + }); + } + + #[test] + fn reads_a_codeview_entry_past_the_sixteenth() { + let mut image = image(true); + image.resize(0x800, 0); + put(&mut image, 0x98 + 0x38, &0x800_u32.to_le_bytes()); + // Eighteen entries, the CodeView one last, its record moved clear of + // the table. + put(&mut image, 0x138 + 4, &(18 * 28_u32).to_le_bytes()); + put(&mut image, 0x21C, &[0; 28]); + put(&mut image, 0x3DC + 0xC, &2_u32.to_le_bytes()); + put(&mut image, 0x3DC + 0x10, &0x30_u32.to_le_bytes()); + put(&mut image, 0x3DC + 0x14, &0x600_u32.to_le_bytes()); + put(&mut image, 0x600, b"RSDS"); + put(&mut image, 0x604, &GUID); + put(&mut image, 0x614, &1_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert_eq!(DebugId::read(process, BASE).unwrap().guid, GUID); + }); + } } From 11e52db82280a866bd6f31c15e0fd2fc799089c5 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 5 Sep 2026 18:39:47 +0200 Subject: [PATCH 6/8] limit mach-o identity read to declared sizes --- src/file_format/macho.rs | 78 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/src/file_format/macho.rs b/src/file_format/macho.rs index c19abad2..49cc0c68 100644 --- a/src/file_format/macho.rs +++ b/src/file_format/macho.rs @@ -102,7 +102,8 @@ impl fmt::Debug for Uuid { } /// Reads the UUID from the load commands of the Mach-O module in the given -/// range. Returns [`None`] if the module carries no `LC_UUID` command. +/// range. Returns [`None`] if the module carries no `LC_UUID` command, or is +/// big-endian, which only PowerPC images ever were. pub fn uuid(process: &Process, range: (Address, u64)) -> Option { #[derive(Debug, Copy, Clone, Zeroable, Pod)] #[repr(C)] @@ -126,7 +127,9 @@ pub fn uuid(process: &Process, range: (Address, u64)) -> Option { let page = scan_macho_page(process, range)?; let header = process.read::(page).ok()?; - // The 64-bit header ends with one reserved field the 32-bit one lacks. + // The 64-bit header ends with one reserved field the 32-bit one lacks. A + // swapped magic is a big-endian image, and nothing here decodes swapped + // fields. let commands = page + match header.magic { MH_MAGIC_64 => mem::size_of::() + mem::size_of::(), @@ -134,19 +137,41 @@ pub fn uuid(process: &Process, range: (Address, u64)) -> Option { _ => return None, } as u64; + // The command table has to lie inside the module, which is what bounds + // the walk. + let (module_address, module_size) = range; + let table_size = header.sizeofcmds as u64; + let module_end = module_address.value().saturating_add(module_size); + if commands + .value() + .checked_add(table_size) + .is_none_or(|end| end > module_end) + { + return None; + } + let mut offset = 0; - // The walk is bounded so a corrupt command count can't turn it into a scan. - for _ in 0..header.ncmds.min(0x40) { + for _ in 0..header.ncmds { + if offset + mem::size_of::() as u64 > table_size { + return None; + } let command = process.read::(commands + offset).ok()?; + let size = command.cmdsize as u64; + if size < mem::size_of::() as u64 || offset + size > table_size { + return None; + } if command.cmd == LC_UUID { + if size < (mem::size_of::() + mem::size_of::()) as u64 { + return None; + } return process .read::<[u8; 16]>(commands + offset + mem::size_of::() as u64) .ok() .map(|bytes| Uuid { bytes }); } - offset += command.cmdsize as u64; + offset += size; } None @@ -301,12 +326,14 @@ mod tests { // Builds a minimal mapped Mach-O by hand from the loader header, so the // walk is checked against the format rather than against itself: the - // header, a segment command, and the uuid command. + // header declaring its command table, a segment command, and the uuid + // command. fn image(wide: bool) -> Vec { let mut image = vec![0; 0x1000]; let magic: u32 = if wide { 0xFEEDFACF } else { 0xFEEDFACE }; put(&mut image, 0x00, &magic.to_le_bytes()); put(&mut image, 0x10, &2_u32.to_le_bytes()); + put(&mut image, 0x14, &(0x48 + 24_u32).to_le_bytes()); let commands = if wide { 0x20 } else { 0x1C }; put(&mut image, commands, &0x19_u32.to_le_bytes()); put(&mut image, commands + 0x4, &0x48_u32.to_le_bytes()); @@ -342,4 +369,43 @@ mod tests { assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); }); } + + #[test] + fn answers_nothing_when_the_commands_overrun_the_module() { + let mut image = image(true); + put(&mut image, 0x14, &0x2000_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); + }); + } + + #[test] + fn reads_the_uuid_past_the_sixty_fourth_command() { + let mut image = vec![0; 0x1000]; + put(&mut image, 0x00, &0xFEEDFACF_u32.to_le_bytes()); + put(&mut image, 0x10, &66_u32.to_le_bytes()); + put(&mut image, 0x14, &(65 * 8 + 24_u32).to_le_bytes()); + // Sixty-five empty commands ahead of the uuid one. + let mut at = 0x20; + for _ in 0..65 { + put(&mut image, at + 4, &8_u32.to_le_bytes()); + at += 8; + } + put(&mut image, at, &0x1B_u32.to_le_bytes()); + put(&mut image, at + 4, &24_u32.to_le_bytes()); + put(&mut image, at + 8, &UUID); + with_process(&[(BASE, &image)], |process| { + let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); + assert_eq!(uuid.bytes, UUID); + }); + } + + #[test] + fn answers_nothing_for_a_big_endian_image() { + let mut image = image(true); + put(&mut image, 0x00, &0xFEEDFACF_u32.to_be_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); + }); + } } From dd7bde16ef82bec42c2fef860782432a4bd15ee1 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 5 Sep 2026 18:40:32 +0200 Subject: [PATCH 7/8] add load bias and module range to elf build id read --- src/file_format/elf.rs | 176 ++++++++++++++++++++++++++++------------- 1 file changed, 120 insertions(+), 56 deletions(-) diff --git a/src/file_format/elf.rs b/src/file_format/elf.rs index 917e9256..56c3efad 100644 --- a/src/file_format/elf.rs +++ b/src/file_format/elf.rs @@ -1182,10 +1182,10 @@ impl fmt::Debug for BuildId { } } -/// Reads the GNU build ID from the notes of the ELF module starting at the -/// given address. Returns [`None`] if the module carries no such note, which -/// not every module does. Only little-endian ELFs are supported. -pub fn build_id(process: &Process, module_address: Address) -> Option { +/// Reads the GNU build ID from the notes of the ELF module in the given +/// range. Returns [`None`] if the module carries no such note, which not +/// every module does. Only little-endian ELFs are supported. +pub fn build_id(process: &Process, range: (Address, u64)) -> Option { #[derive(Debug, Copy, Clone, Pod, Zeroable)] #[repr(C)] struct NoteHeader { @@ -1196,6 +1196,7 @@ pub fn build_id(process: &Process, module_address: Address) -> Option { const NT_GNU_BUILD_ID: u32 = 3; + let (module_address, module_size) = range; let header = process.read::
(module_address).ok()?; let info = Info::parse(bytemuck::bytes_of(&header))?; @@ -1215,64 +1216,85 @@ pub fn build_id(process: &Process, module_address: Address) -> Option { ) }; - (0..e_phnum).find_map(|index| { + let program_header = |index: u16| { let at = module_address + e_phoff + e_phentsize.wrapping_mul(index as u64); - - let (p_type, p_vaddr, p_filesz) = if info.bitness.is_64() { - let program_header = process.read::(at).ok()?; - ( - program_header.p_type, - program_header.p_vaddr, - program_header.p_filesz, - ) + if info.bitness.is_64() { + let header = process.read::(at).ok()?; + Some(( + header.p_type, + header.p_offset, + header.p_vaddr, + header.p_filesz, + )) } else { - let program_header = process.read::(at).ok()?; - ( - program_header.p_type, - program_header.p_vaddr as u64, - program_header.p_filesz as u64, - ) - }; - - if SegmentType(p_type) != SegmentType::PT_NOTE { - return None; + let header = process.read::(at).ok()?; + Some(( + header.p_type, + header.p_offset as u64, + header.p_vaddr as u64, + header.p_filesz as u64, + )) } + }; - // A note is its header, the name, then the data, the latter two padded - // to four bytes. - let segment = module_address + p_vaddr; - let mut offset = 0; - // The walk is bounded so corrupt sizes can't turn it into a scan. - for _ in 0..0x10 { - if offset + size_of::() as u64 > p_filesz { + // A shared object's addresses count from wherever it landed, and an + // executable's are absolute. Either way the load segment at file offset + // zero holds this header, and the module address is where that segment + // landed, so the difference between the two is what every other address + // needs added. + let header_vaddr = (0..e_phnum) + .filter_map(program_header) + .find(|&(p_type, p_offset, ..)| { + SegmentType(p_type) == SegmentType::PT_LOAD && p_offset == 0 + }) + .map(|(_, _, p_vaddr, _)| p_vaddr)?; + let bias = module_address.value().wrapping_sub(header_vaddr); + let module_end = module_address.value().saturating_add(module_size); + + (0..e_phnum) + .filter_map(program_header) + .find_map(|(p_type, _, p_vaddr, p_filesz)| { + if SegmentType(p_type) != SegmentType::PT_NOTE { return None; } - let note = process.read::(segment + offset).ok()?; - let name = offset + size_of::() as u64; - let desc = name + (note.n_namesz as u64).next_multiple_of(4); - - if note.n_type == NT_GNU_BUILD_ID - && note.n_namesz == 4 - && (1..=32).contains(¬e.n_descsz) - && desc + note.n_descsz as u64 <= p_filesz - && process.read::<[u8; 4]>(segment + name).ok()? == *b"GNU\0" - { - let mut bytes = [0; 32]; - process - .read_into_buf(segment + desc, &mut bytes[..note.n_descsz as usize]) - .ok()?; - return Some(BuildId { - bytes, - len: note.n_descsz as u8, - }); + // The segment has to lie inside the module, which is what bounds + // the walk. + let segment = bias.wrapping_add(p_vaddr); + if segment < module_address.value() || segment.checked_add(p_filesz)? > module_end { + return None; + } + let segment = Address::new(segment); + + // A note is its header, the name, then the data, the latter two + // padded to four bytes. + let mut offset = 0; + while offset + size_of::() as u64 <= p_filesz { + let note = process.read::(segment + offset).ok()?; + let name = offset + size_of::() as u64; + let desc = name + (note.n_namesz as u64).next_multiple_of(4); + + if note.n_type == NT_GNU_BUILD_ID + && note.n_namesz == 4 + && (1..=32).contains(¬e.n_descsz) + && desc + note.n_descsz as u64 <= p_filesz + && process.read::<[u8; 4]>(segment + name).ok()? == *b"GNU\0" + { + let mut bytes = [0; 32]; + process + .read_into_buf(segment + desc, &mut bytes[..note.n_descsz as usize]) + .ok()?; + return Some(BuildId { + bytes, + len: note.n_descsz as u8, + }); + } + + offset = desc + (note.n_descsz as u64).next_multiple_of(4); } - offset = desc + (note.n_descsz as u64).next_multiple_of(4); - } - - None - }) + None + }) } #[cfg(all(test, not(target_family = "wasm")))] @@ -1338,7 +1360,7 @@ mod tests { fn reads_the_build_id_from_a_mapped_image() { for wide in [true, false] { with_process(&[(BASE, &image(wide))], |process| { - let build_id = build_id(process, BASE.into()).unwrap(); + let build_id = build_id(process, (BASE.into(), 0x400)).unwrap(); assert_eq!(build_id.as_bytes(), BUILD_ID); }); } @@ -1347,7 +1369,7 @@ mod tests { #[test] fn renders_the_id_as_hex() { with_process(&[(BASE, &image(true))], |process| { - let build_id = build_id(process, BASE.into()).unwrap(); + let build_id = build_id(process, (BASE.into(), 0x400)).unwrap(); assert_eq!( format!("{build_id:?}"), "e6aa000a9a5201635743c6a1b2781e057d651dad", @@ -1360,7 +1382,49 @@ mod tests { let mut image = image(true); put(&mut image, 0x78, &0_u32.to_le_bytes()); with_process(&[(BASE, &image)], |process| { - assert!(build_id(process, BASE.into()).is_none()); + assert!(build_id(process, (BASE.into(), 0x400)).is_none()); + }); + } + + // An executable's segments carry absolute addresses, and one built without + // position independence is mapped exactly where they say. + #[test] + fn reads_the_build_id_from_an_executable_mapped_at_its_own_address() { + const EXECUTABLE_BASE: u64 = 0x40_0000; + let mut image = image(true); + put(&mut image, 0x10, &2_u16.to_le_bytes()); + put(&mut image, 0x50, &EXECUTABLE_BASE.to_le_bytes()); + put(&mut image, 0x88, &(EXECUTABLE_BASE + 0x200).to_le_bytes()); + with_process(&[(EXECUTABLE_BASE, &image)], |process| { + let build_id = build_id(process, (EXECUTABLE_BASE.into(), 0x400)).unwrap(); + assert_eq!(build_id.as_bytes(), BUILD_ID); + }); + } + + #[test] + fn answers_nothing_when_the_note_segment_overruns_the_module() { + let mut image = image(true); + put(&mut image, 0x98, &0x1000_u64.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(build_id(process, (BASE.into(), 0x400)).is_none()); + }); + } + + #[test] + fn reads_a_build_id_past_the_sixteenth_note() { + let mut image = image(true); + // Seventeen empty notes ahead of the build ID, all in one segment. + put(&mut image, 0x200, &[0; 0x44]); + let at = 0x200 + 17 * 12; + put(&mut image, at, &4_u32.to_le_bytes()); + put(&mut image, at + 4, &20_u32.to_le_bytes()); + put(&mut image, at + 8, &3_u32.to_le_bytes()); + put(&mut image, at + 12, b"GNU\0"); + put(&mut image, at + 16, &BUILD_ID); + put(&mut image, 0x98, &((at + 36 - 0x200) as u64).to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + let build_id = build_id(process, (BASE.into(), 0x400)).unwrap(); + assert_eq!(build_id.as_bytes(), BUILD_ID); }); } } From 58386cf1806ba3d6a25a5b734316a84c570adfd2 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 5 Sep 2026 19:36:53 +0200 Subject: [PATCH 8/8] drop 32-bit mach-o from uuid read --- src/file_format/macho.rs | 53 ++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/file_format/macho.rs b/src/file_format/macho.rs index 49cc0c68..a34bed86 100644 --- a/src/file_format/macho.rs +++ b/src/file_format/macho.rs @@ -103,7 +103,8 @@ impl fmt::Debug for Uuid { /// Reads the UUID from the load commands of the Mach-O module in the given /// range. Returns [`None`] if the module carries no `LC_UUID` command, or is -/// big-endian, which only PowerPC images ever were. +/// 32-bit or big-endian. macOS has not run 32-bit software since 2019, and +/// Unity dropped PowerPC, the only big-endian Mac, with Unity 3. pub fn uuid(process: &Process, range: (Address, u64)) -> Option { #[derive(Debug, Copy, Clone, Zeroable, Pod)] #[repr(C)] @@ -127,15 +128,13 @@ pub fn uuid(process: &Process, range: (Address, u64)) -> Option { let page = scan_macho_page(process, range)?; let header = process.read::(page).ok()?; - // The 64-bit header ends with one reserved field the 32-bit one lacks. A - // swapped magic is a big-endian image, and nothing here decodes swapped - // fields. - let commands = page - + match header.magic { - MH_MAGIC_64 => mem::size_of::() + mem::size_of::(), - MH_MAGIC_32 => mem::size_of::(), - _ => return None, - } as u64; + // Anything but the 64-bit little-endian magic is 32-bit or big-endian, + // and nothing here decodes either. The 64-bit header ends with one + // reserved field. + if header.magic != MH_MAGIC_64 { + return None; + } + let commands = page + (mem::size_of::() + mem::size_of::()) as u64; // The command table has to lie inside the module, which is what bounds // the walk. @@ -328,13 +327,12 @@ mod tests { // walk is checked against the format rather than against itself: the // header declaring its command table, a segment command, and the uuid // command. - fn image(wide: bool) -> Vec { + fn image() -> Vec { let mut image = vec![0; 0x1000]; - let magic: u32 = if wide { 0xFEEDFACF } else { 0xFEEDFACE }; - put(&mut image, 0x00, &magic.to_le_bytes()); + put(&mut image, 0x00, &0xFEEDFACF_u32.to_le_bytes()); put(&mut image, 0x10, &2_u32.to_le_bytes()); put(&mut image, 0x14, &(0x48 + 24_u32).to_le_bytes()); - let commands = if wide { 0x20 } else { 0x1C }; + let commands = 0x20; put(&mut image, commands, &0x19_u32.to_le_bytes()); put(&mut image, commands + 0x4, &0x48_u32.to_le_bytes()); put(&mut image, commands + 0x48, &0x1B_u32.to_le_bytes()); @@ -345,17 +343,24 @@ mod tests { #[test] fn reads_the_uuid_from_a_mapped_image() { - for wide in [true, false] { - with_process(&[(BASE, &image(wide))], |process| { - let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); - assert_eq!(uuid.bytes, UUID); - }); - } + with_process(&[(BASE, &image())], |process| { + let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); + assert_eq!(uuid.bytes, UUID); + }); + } + + #[test] + fn answers_nothing_for_a_32_bit_image() { + let mut image = image(); + put(&mut image, 0x00, &0xFEEDFACE_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); + }); } #[test] fn renders_the_uuid_canonically() { - with_process(&[(BASE, &image(true))], |process| { + with_process(&[(BASE, &image())], |process| { let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); assert_eq!(format!("{uuid:?}"), "e7420bc7-a26b-33fa-b5cd-41cad7d4614c"); }); @@ -363,7 +368,7 @@ mod tests { #[test] fn answers_nothing_without_a_uuid_command() { - let mut image = image(true); + let mut image = image(); put(&mut image, 0x20 + 0x48, &0_u32.to_le_bytes()); with_process(&[(BASE, &image)], |process| { assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); @@ -372,7 +377,7 @@ mod tests { #[test] fn answers_nothing_when_the_commands_overrun_the_module() { - let mut image = image(true); + let mut image = image(); put(&mut image, 0x14, &0x2000_u32.to_le_bytes()); with_process(&[(BASE, &image)], |process| { assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); @@ -402,7 +407,7 @@ mod tests { #[test] fn answers_nothing_for_a_big_endian_image() { - let mut image = image(true); + let mut image = image(); put(&mut image, 0x00, &0xFEEDFACF_u32.to_be_bytes()); with_process(&[(BASE, &image)], |process| { assert!(uuid(process, (BASE.into(), 0x1000)).is_none());