From fb5d38a8f8bae9a4c3d77be246e59f04def541ec Mon Sep 17 00:00:00 2001 From: Yan Date: Sun, 9 Aug 2026 23:06:30 +0000 Subject: [PATCH 1/3] Read the LC_UNIXTHREAD entry point per cputype Thread state flavor numbers are only unique within a cputype, but _load_lc_unixthread dispatched on the flavor alone. Flavor 1 and 6 were read as ARM_THREAD_STATE and ARM_THREAD_STATE64 whatever the cputype was, and everything else aborted the load with an empty CLECompatibilityError. An x86_64 executable stores x86_THREAD_STATE64, flavor 4, so it never loaded at all. A 32-bit x86 executable stores x86_THREAD_STATE32, flavor 1, which is the same 16 words as ARM_THREAD_STATE but keeps __eip at index 10 rather than a trailing __pc, so it loaded with __gs as its entry point. Key the thread state layouts by (cputype, flavor) and cover both x86 states. Check the state against the length the command declares and against the end of the file before unpacking it; a binary truncated inside the thread state used to come back as a bare struct.error. An LC_UNIXTHREAD that cannot be read now leaves unixthread_pc unset and lets _resolve_entry report the missing entry point, because the entry point is the only thing the command contributes and the rest of the binary is still loadable. --- cle/backends/macho/macho.py | 59 +++++++---- cle/backends/macho/macho_enums.py | 37 +++++++ tests/test_macho_unixthread.py | 157 ++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 tests/test_macho_unixthread.py diff --git a/cle/backends/macho/macho.py b/cle/backends/macho/macho.py index eb590ed3..0fd56135 100644 --- a/cle/backends/macho/macho.py +++ b/cle/backends/macho/macho.py @@ -22,8 +22,8 @@ from cle.errors import CLECompatibilityError, CLEInvalidBinaryError, CLEOperationError from .encrypted_sentinel_backer import CryptSentinel +from .macho_enums import ARMThreadFlavor, CPUType, MachoFiletype, MH_flags, X86ThreadFlavor from .macho_enums import LoadCommands as LC -from .macho_enums import MachoFiletype, MH_flags from .section import TYPE_MASK, ZEROFILL_SECTION_TYPES, MachOSection from .segment import MachOSegment from .structs import ( @@ -43,6 +43,16 @@ __all__ = ("MachO", "MachOSection", "MachOSegment", "SymbolList") +# Layout of the thread states LC_UNIXTHREAD can carry, keyed by (cputype, flavor) because a flavor number +# only identifies a thread state together with the cputype it belongs to. Every format string stops at the +# program counter, so the last field it unpacks is the entry point. +UNIXTHREAD_PC_FORMATS = { + (CPUType.CPU_TYPE_X86, X86ThreadFlavor.x86_THREAD_STATE32): "11I", # __eip + (CPUType.CPU_TYPE_X86_64, X86ThreadFlavor.x86_THREAD_STATE64): "17Q", # __rip + (CPUType.CPU_TYPE_ARM, ARMThreadFlavor.ARM_THREAD_STATE): "16I", # __pc + (CPUType.CPU_TYPE_ARM64, ARMThreadFlavor.ARM_THREAD_STATE64): "33Q", # __pc +} + # pylint: disable=abstract-method class SymbolList(SortedKeyList): @@ -750,10 +760,10 @@ def _detect_arch_ident(self): try: arch_lookup = { # contains all supported architectures. Note that apple deviates from standard ABI, see Apple docs - 0x100000C: "aarch64", - 0xC: "arm", - 0x7: "x86", - 0x1000007: "x64", + CPUType.CPU_TYPE_ARM64: "aarch64", + CPUType.CPU_TYPE_ARM: "arm", + CPUType.CPU_TYPE_X86: "x86", + CPUType.CPU_TYPE_X86_64: "x64", } return arch_lookup[self.cputype] # subtype currently not needed except KeyError: @@ -824,21 +834,32 @@ def _load_lc_unixthread(self, f, offset): ) # parse basic structure - # _, cmdsize, flavor, long_count - _, _, flavor, _ = self._unpack("4I", f, offset, 16) - - # we only support 4 different types of thread state atm - # TODO: This is the place to add x86 and x86_64 thread states - if flavor == 1 and self.arch.bits != 64: # ARM_THREAD_STATE or ARM_UNIFIED_THREAD_STATE or ARM_THREAD_STATE32 - blob = self._unpack("16I", f, offset + 16, 64) # parses only until __pc - elif flavor == 1 and self.arch.bits == 64 or flavor == 6: - # ARM_THREAD_STATE or ARM_UNIFIED_THREAD_STATE or ARM_THREAD_STATE64 - blob = self._unpack("33Q", f, offset + 16, 264) # parses only until __pc - else: - log.error("Unknown thread flavor: %d", flavor) - raise CLECompatibilityError() + # _, cmdsize, flavor, count + _, _, flavor, count = self._unpack("4I", f, offset, 16) + + # Nothing below can abort the load: the entry point is all LC_UNIXTHREAD contributes, and + # _resolve_entry already reports a binary that has no entry point at all. + fmt = UNIXTHREAD_PC_FORMATS.get((self.cputype, flavor)) + if fmt is None: + # Without a known layout there is no telling where the program counter sits in the thread state. + log.warning("Unsupported thread state flavor %d for cputype %#x", flavor, self.cputype) + return + + # "=" asks for the standard sizes, which is what both byteorder prefixes select, so the size + # of the thread state does not depend on which one this binary needs. + size = struct.calcsize("=" + fmt) + if count * 4 < size: + # count is the length of the thread state in 32 bit words. Unpacking more than the command + # declares would read whatever follows it rather than the program counter. + log.warning("LC_UNIXTHREAD declares %d words of thread state, too few for flavor %d", count, flavor) + return + + state = self._read(f, offset + 16, size) + if len(state) < size: + log.warning("File ends inside the LC_UNIXTHREAD thread state") + return - self.unixthread_pc = blob[-1] + self.unixthread_pc = self._unpack_with_byteorder(fmt, state)[-1] log.debug("LC_UNIXTHREAD: __pc=%#x", self.unixthread_pc) def _load_dylib_info(self, f, offset): diff --git a/cle/backends/macho/macho_enums.py b/cle/backends/macho/macho_enums.py index 3ed4a206..ef2758d5 100644 --- a/cle/backends/macho/macho_enums.py +++ b/cle/backends/macho/macho_enums.py @@ -229,6 +229,43 @@ class MH_flags(IntEnum): MH_DYLIB_IN_CACHE = 0x80000000 +class CPUType(IntEnum): + """ + from mach/machine.h + + Values for the cputype field of the mach_header, limited to the architectures cle supports + """ + + CPU_TYPE_X86 = 0x7 + CPU_TYPE_X86_64 = 0x1000007 + CPU_TYPE_ARM = 0xC + CPU_TYPE_ARM64 = 0x100000C + + +class X86ThreadFlavor(IntEnum): + """ + from mach/i386/thread_status.h + + Flavors of the thread state carried by LC_UNIXTHREAD on CPU_TYPE_X86 and CPU_TYPE_X86_64. + A flavor number only identifies a thread state together with the cputype it belongs to. + """ + + x86_THREAD_STATE32 = 1 + x86_THREAD_STATE64 = 4 + + +class ARMThreadFlavor(IntEnum): + """ + from mach/arm/thread_status.h + + Flavors of the thread state carried by LC_UNIXTHREAD on CPU_TYPE_ARM and CPU_TYPE_ARM64. + A flavor number only identifies a thread state together with the cputype it belongs to. + """ + + ARM_THREAD_STATE = 1 + ARM_THREAD_STATE64 = 6 + + class RebaseType(IntEnum): """ from mach-o/loader.h diff --git a/tests/test_macho_unixthread.py b/tests/test_macho_unixthread.py new file mode 100644 index 00000000..4a008460 --- /dev/null +++ b/tests/test_macho_unixthread.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +from __future__ import annotations + +import io +import logging +import struct + +import cle + +# Constants are spelled out here rather than imported from cle so that the expected layouts come +# from the Apple headers and not from the table under test. +MH_MAGIC = 0xFEEDFACE +MH_MAGIC_64 = 0xFEEDFACF +CPU_ARCH_ABI64 = 0x1000000 +CPU_TYPE_X86 = 0x7 +CPU_TYPE_X86_64 = CPU_TYPE_X86 | CPU_ARCH_ABI64 +CPU_TYPE_ARM = 0xC +CPU_TYPE_ARM64 = CPU_TYPE_ARM | CPU_ARCH_ABI64 +MH_EXECUTE = 2 +MH_TWOLEVEL = 0x80 +MH_PIE = 0x200000 +LC_SEGMENT = 0x1 +LC_SEGMENT_64 = 0x19 +LC_SYMTAB = 0x2 +LC_UNIXTHREAD = 0x5 + +# thread state flavors, from mach/i386/thread_status.h and mach/arm/thread_status.h +x86_THREAD_STATE32 = 1 +x86_THREAD_STATE64 = 4 +x86_FLOAT_STATE64 = 5 +ARM_THREAD_STATE = 1 +ARM_THREAD_STATE64 = 6 + +PAGE_SIZE = 0x1000 + + +def build_unixthread_executable(cputype: int, flavor: int, thread_state: bytes, text_vaddr: int) -> bytes: + """ + Assemble a minimal Mach-O executable that takes its entry point from an LC_UNIXTHREAD command. + + :param cputype: Value for the cputype field of the mach header. + :param flavor: Thread state flavor the LC_UNIXTHREAD command announces. + :param thread_state: The register block the command carries, laid out as ``cputype`` defines it. + :param text_vaddr: Address the __TEXT segment is linked at, __PAGEZERO covers everything below it. + """ + is64 = bool(cputype & CPU_ARCH_ABI64) + + def segment(segname: bytes, vmaddr: int, vmsize: int, fileoff: int, filesize: int) -> bytes: + if is64: + return struct.pack("<2I16s4Q4I", LC_SEGMENT_64, 72, segname, vmaddr, vmsize, fileoff, filesize, 7, 5, 0, 0) + return struct.pack("<2I16s8I", LC_SEGMENT, 56, segname, vmaddr, vmsize, fileoff, filesize, 7, 5, 0, 0) + + commands = b"".join( + [ + segment(b"__PAGEZERO", 0, text_vaddr, 0, 0), + segment(b"__TEXT", text_vaddr, PAGE_SIZE, 0, PAGE_SIZE), + struct.pack("<6I", LC_SYMTAB, 24, 0, 0, 0, 0), + unixthread_command(flavor, thread_state), + thread_state, + ] + ) + header = struct.pack( + "<8I" if is64 else "<7I", + MH_MAGIC_64 if is64 else MH_MAGIC, + cputype, + 3, + MH_EXECUTE, + 4, + len(commands), + MH_TWOLEVEL | MH_PIE, + *([0] if is64 else []), + ) + return (header + commands).ljust(PAGE_SIZE, b"\0") + + +def unixthread_command(flavor: int, thread_state: bytes) -> bytes: + """The 16 byte LC_UNIXTHREAD header, whose count field is the length of the thread state in 32 bit words.""" + return struct.pack("<4I", LC_UNIXTHREAD, 16 + len(thread_state), flavor, len(thread_state) // 4) + + +def load(cputype: int, flavor: int, thread_state: bytes, text_vaddr: int) -> cle.MachO: + return load_blob(build_unixthread_executable(cputype, flavor, thread_state, text_vaddr)) + + +def load_blob(blob: bytes) -> cle.MachO: + ld = cle.Loader(io.BytesIO(blob), main_opts={"backend": "mach-o"}) + assert isinstance(ld.main_object, cle.MachO) + return ld.main_object + + +def test_x86_64_thread_state(): + # _STRUCT_X86_THREAD_STATE64 keeps __rip at index 16 of its 21 64 bit fields + state = [0] * 21 + state[16] = 0x100000F00 + obj = load(CPU_TYPE_X86_64, x86_THREAD_STATE64, struct.pack("<21Q", *state), 0x100000000) + assert obj.entry == 0x100000F00 + + +def test_x86_thread_state(): + # _STRUCT_X86_THREAD_STATE32 keeps __eip at index 10 of its 16 32 bit fields and __gs last, so a + # reader that takes the last field it unpacks as the program counter comes back with __gs + state = [0] * 16 + state[10] = 0x4F00 + state[15] = 0xDEADBEEF + obj = load(CPU_TYPE_X86, x86_THREAD_STATE32, struct.pack("<16I", *state), 0x4000) + assert obj.entry == 0x4F00 + + +def test_arm64_thread_state(): + # _STRUCT_ARM_THREAD_STATE64 keeps __pc at index 32 of its 33 64 bit fields, __cpsr and __pad follow + state = [0] * 33 + state[32] = 0x100000F00 + obj = load(CPU_TYPE_ARM64, ARM_THREAD_STATE64, struct.pack("<33Q2I", *state, 0, 0), 0x100000000) + assert obj.entry == 0x100000F00 + + +def test_arm_thread_state(): + # _STRUCT_ARM_THREAD_STATE keeps __pc at index 15 of its 17 32 bit fields, __cpsr follows + state = [0] * 17 + state[15] = 0x4F00 + obj = load(CPU_TYPE_ARM, ARM_THREAD_STATE, struct.pack("<17I", *state), 0x4000) + assert obj.entry == 0x4F00 + + +def test_flavor_without_a_known_layout_still_loads(): + # x86_FLOAT_STATE64 carries no program counter, so the binary loads without an entry point instead of failing + obj = load(CPU_TYPE_X86_64, x86_FLOAT_STATE64, b"\0" * 168, 0x100000000) + assert obj.entry == 0 + + +def test_thread_state_shorter_than_its_flavor_still_loads(): + # Two words is nowhere near an x86_thread_state64_t, so there is no __rip to read behind the command + obj = load(CPU_TYPE_X86_64, x86_THREAD_STATE64, struct.pack("<2I", 0, 0), 0x100000000) + assert obj.entry == 0 + assert obj.unixthread_pc is None + + +def test_thread_state_running_past_the_end_of_the_file_still_loads(): + state = [0] * 21 + state[16] = 0x100000F00 + thread_state = struct.pack("<21Q", *state) + blob = build_unixthread_executable(CPU_TYPE_X86_64, x86_THREAD_STATE64, thread_state, 0x100000000) + command = unixthread_command(x86_THREAD_STATE64, thread_state) + obj = load_blob(blob[: blob.index(command) + len(command) + 8]) + assert obj.entry == 0 + assert obj.unixthread_pc is None + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + test_x86_64_thread_state() + test_x86_thread_state() + test_arm64_thread_state() + test_arm_thread_state() + test_flavor_without_a_known_layout_still_loads() + test_thread_state_shorter_than_its_flavor_still_loads() + test_thread_state_running_past_the_end_of_the_file_still_loads() From cac93c295f8cf5c1e47c5c300689b7b009234b5e Mon Sep 17 00:00:00 2001 From: Yan Date: Mon, 10 Aug 2026 20:35:34 +0000 Subject: [PATCH 2/3] Base a Mach-O executable at the address its __TEXT segment declares The backend assumed every position independent MH_EXECUTE was linked at 0x100000000 on 64 bit and 0x4000 on 32 bit. Those are ld64's defaults, not properties of the format. Go's internal linker links darwin/amd64 executables at 0x1000000, and for one of those the mapped base ended up four gigabytes above every segment, so the load aborted in Loader._map_object on `assert obj.min_addr <= obj.max_addr` before any analysis could start. Read the vmaddr of __TEXT out of the load commands instead. That is the address the mach header itself lands at and what __mh_execute_header resolves to, so it is the linked base by definition. The old constants stay as the fallback for a binary that declares no __TEXT, and every ld64-linked executable already puts __TEXT exactly where they said, so nothing changes for those. This is also what makes the LC_UNIXTHREAD change observable. Go's linker is the toolchain still emitting LC_UNIXTHREAD instead of LC_MAIN, so every binary that exercises that path is one this assumption rejected. Co-Authored-By: Claude Opus 5 --- cle/backends/macho/macho.py | 43 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/cle/backends/macho/macho.py b/cle/backends/macho/macho.py index 0fd56135..b8a1261e 100644 --- a/cle/backends/macho/macho.py +++ b/cle/backends/macho/macho.py @@ -189,15 +189,25 @@ def __init__(self, *args, **kwargs): # Note that this should be customized for Apple ABI (TODO) self.set_arch(archinfo.arch_from_id(arch_ident, endness="lsb" if self.struct_byteorder == "<" else "msb")) + # Start reading load commands + lc_offset = (7 if self.arch.bits == 32 else 8) * 4 + # Determine the base address the binary was linked against # and set the values for the Backend and Loader accordingly if self.pic and self.filetype == MachoFiletype.MH_EXECUTE: assert self.is_main_bin, "An file of type MH_EXECUTE should be the main bin, this should not happen" # a Position Independent Main binary would later be loaded at 0x400000, which isn't legal for Mach-O - # Also, its segment vaddrs are relative to 0x100000000, so we set this as the linked base - # and the MachO Backend code uses the AdressTranslator to translate linked addresses to relative ones + # An executable is linked at the address of its __TEXT segment: that is where the mach header + # itself ends up, and what __mh_execute_header resolves to. ld64 puts it at 0x100000000 on 64 bit + # and 0x4000 on 32 bit, but that is a default and not a property of the format -- Go's linker + # links darwin/amd64 executables at 0x1000000 -- so read it instead of assuming it, and fall back + # to the ld64 default for a binary that declares no __TEXT. + # The MachO Backend code uses the AddressTranslator to translate linked addresses to relative ones. # In theory this is the place where the slide for rebasing should be added, but this isn't supported yet - if self.arch.bits == 64: + text_vmaddr = self._text_segment_vmaddr(lc_offset) + if text_vmaddr: + self.linked_base = self.mapped_base = text_vmaddr + elif self.arch.bits == 64: self.linked_base = self.mapped_base = 2**32 elif self.arch.bits == 32: self.linked_base = self.mapped_base = 0x4000 @@ -233,9 +243,6 @@ def __init__(self, *args, **kwargs): "Please open an issue if you need support for this" ) - # Start reading load commands - lc_offset = (7 if self.arch.bits == 32 else 8) * 4 - self._parse_load_commands(lc_offset) except OSError as e: @@ -307,6 +314,30 @@ def check_compatibility(cls, spec, obj): # TODO: Check properly, but for now libs are just used via force load libs anyway return True + def _text_segment_vmaddr(self, lc_offset: int) -> int | None: + """ + The address __TEXT is linked at, read straight from the load commands before they are parsed + properly, because the base address the rest of the parse works against depends on it. + + :return: the vmaddr of the __TEXT segment, or None if the binary declares no __TEXT segment. + """ + binary_file = self._binary_stream + count = 0 + offset = lc_offset + # Bounded the same way _parse_load_commands bounds itself, so a header that lies about either + # ncmds or sizeofcmds cannot walk this loop off the end of the commands + while count < self.ncmds and (offset - lc_offset) < self.sizeofcmds: + count += 1 + cmd, size = self._unpack("2I", binary_file, offset, 8) + if cmd in (LC.LC_SEGMENT, LC.LC_SEGMENT_64): + segname = self._read(binary_file, offset + 8, 16).rstrip(b"\0") + if segname == b"__TEXT": + if cmd == LC.LC_SEGMENT_64: + return self._unpack("Q", binary_file, offset + 24, 8)[0] + return self._unpack("I", binary_file, offset + 24, 4)[0] + offset += size + return None + def _parse_load_commands(self, lc_offset): # Possible optimization: Remove all unnecessary calls to seek() # Load commands have a common structure: First 4 bytes identify the command by a magic number From 8b59ffb581ee7c291bd889168c415a690f84dc43 Mon Sep 17 00:00:00 2001 From: Yan Date: Mon, 10 Aug 2026 20:35:48 +0000 Subject: [PATCH 3/3] Load a real LC_UNIXTHREAD binary in the thread state test The test assembled its own Mach-O executables with struct.pack. Test inputs belong in angr/binaries, and a hand-built container is worse than a stray binary file in the wrong repository, because it is shaped to make the test pass: this one linked __TEXT at 0x100000000, where ld64 puts it and where nothing carrying an LC_UNIXTHREAD is actually linked. The suite went green while every real binary that uses the command still failed to load. Load tests/x86_64/terramate.macho instead, the terramate executable out of the official Homebrew bottle for tenv 4.15.1. It is a Go-linked x86_64 macOS executable, so it takes its entry point from an x86_THREAD_STATE64 carried by LC_UNIXTHREAD, the flavor that used to abort the load with an empty CLECompatibilityError. The two malformed cases overwrite a single 32 bit field of that same fixture in a temp copy, which is how a bad input is made from a known good object. Two groups of cases went with the assembler: - The arm, arm64 and 32 bit x86 thread states. ld64 stopped emitting LC_UNIXTHREAD long ago and Go's linker only reaches for it on darwin/amd64, so there is no real object left that carries those states to test against. The layouts stay in the table; they are simply not covered. - "Thread state running past the end of the file", which needs LC_UNIXTHREAD to be the last thing in the file. It is the sixth of fourteen commands in a real binary, so a file truncated inside its thread state has lost every segment too and the load fails on an empty backer well before the check matters. The check stays in the parser, where it keeps a short read from surfacing as a bare struct.error, but no real container reaches it. Co-Authored-By: Claude Opus 5 --- tests/test_macho_unixthread.py | 182 +++++++++++---------------------- 1 file changed, 57 insertions(+), 125 deletions(-) diff --git a/tests/test_macho_unixthread.py b/tests/test_macho_unixthread.py index 4a008460..82aadefa 100644 --- a/tests/test_macho_unixthread.py +++ b/tests/test_macho_unixthread.py @@ -1,157 +1,89 @@ #!/usr/bin/env python from __future__ import annotations -import io import logging import struct +import tempfile +from pathlib import Path import cle +from cle.backends.macho.macho_enums import LoadCommands as LC -# Constants are spelled out here rather than imported from cle so that the expected layouts come -# from the Apple headers and not from the table under test. -MH_MAGIC = 0xFEEDFACE -MH_MAGIC_64 = 0xFEEDFACF -CPU_ARCH_ABI64 = 0x1000000 -CPU_TYPE_X86 = 0x7 -CPU_TYPE_X86_64 = CPU_TYPE_X86 | CPU_ARCH_ABI64 -CPU_TYPE_ARM = 0xC -CPU_TYPE_ARM64 = CPU_TYPE_ARM | CPU_ARCH_ABI64 -MH_EXECUTE = 2 -MH_TWOLEVEL = 0x80 -MH_PIE = 0x200000 -LC_SEGMENT = 0x1 -LC_SEGMENT_64 = 0x19 -LC_SYMTAB = 0x2 -LC_UNIXTHREAD = 0x5 - -# thread state flavors, from mach/i386/thread_status.h and mach/arm/thread_status.h -x86_THREAD_STATE32 = 1 -x86_THREAD_STATE64 = 4 -x86_FLOAT_STATE64 = 5 -ARM_THREAD_STATE = 1 -ARM_THREAD_STATE64 = 6 - -PAGE_SIZE = 0x1000 - - -def build_unixthread_executable(cputype: int, flavor: int, thread_state: bytes, text_vaddr: int) -> bytes: - """ - Assemble a minimal Mach-O executable that takes its entry point from an LC_UNIXTHREAD command. +TEST_BASE = Path(__file__).resolve().parent.parent.parent / "binaries" - :param cputype: Value for the cputype field of the mach header. - :param flavor: Thread state flavor the LC_UNIXTHREAD command announces. - :param thread_state: The register block the command carries, laid out as ``cputype`` defines it. - :param text_vaddr: Address the __TEXT segment is linked at, __PAGEZERO covers everything below it. - """ - is64 = bool(cputype & CPU_ARCH_ABI64) - - def segment(segname: bytes, vmaddr: int, vmsize: int, fileoff: int, filesize: int) -> bytes: - if is64: - return struct.pack("<2I16s4Q4I", LC_SEGMENT_64, 72, segname, vmaddr, vmsize, fileoff, filesize, 7, 5, 0, 0) - return struct.pack("<2I16s8I", LC_SEGMENT, 56, segname, vmaddr, vmsize, fileoff, filesize, 7, 5, 0, 0) - - commands = b"".join( - [ - segment(b"__PAGEZERO", 0, text_vaddr, 0, 0), - segment(b"__TEXT", text_vaddr, PAGE_SIZE, 0, PAGE_SIZE), - struct.pack("<6I", LC_SYMTAB, 24, 0, 0, 0, 0), - unixthread_command(flavor, thread_state), - thread_state, - ] - ) - header = struct.pack( - "<8I" if is64 else "<7I", - MH_MAGIC_64 if is64 else MH_MAGIC, - cputype, - 3, - MH_EXECUTE, - 4, - len(commands), - MH_TWOLEVEL | MH_PIE, - *([0] if is64 else []), - ) - return (header + commands).ljust(PAGE_SIZE, b"\0") - - -def unixthread_command(flavor: int, thread_state: bytes) -> bytes: - """The 16 byte LC_UNIXTHREAD header, whose count field is the length of the thread state in 32 bit words.""" - return struct.pack("<4I", LC_UNIXTHREAD, 16 + len(thread_state), flavor, len(thread_state) // 4) - - -def load(cputype: int, flavor: int, thread_state: bytes, text_vaddr: int) -> cle.MachO: - return load_blob(build_unixthread_executable(cputype, flavor, thread_state, text_vaddr)) - - -def load_blob(blob: bytes) -> cle.MachO: - ld = cle.Loader(io.BytesIO(blob), main_opts={"backend": "mach-o"}) - assert isinstance(ld.main_object, cle.MachO) - return ld.main_object +# `terramate` out of the official Homebrew bottle for tenv 4.15.1, x86_64 macOS. Go's internal linker is +# the toolchain still shipping LC_UNIXTHREAD instead of LC_MAIN, and it stores an x86_THREAD_STATE64 +# whose __rip is the entry point. `otool -l` puts the command at file offset 0x650, command 6 of 14. +FIXTURE = TEST_BASE / "tests" / "x86_64" / "terramate.macho" +UNIXTHREAD_OFFSET = 0x650 +FLAVOR_FIELD = 8 +COUNT_FIELD = 12 +ENTRY = 0x1081180 +SEGMENTS = ["__PAGEZERO", "__TEXT", "__DATA_CONST", "__DATA", "__LINKEDIT"] +# x86_FLOAT_STATE64 from mach/i386/thread_status.h. A thread state LC_UNIXTHREAD is allowed to carry, +# but one with no program counter in it, so there is nothing for the loader to take an entry point from. +X86_FLOAT_STATE64 = 5 -def test_x86_64_thread_state(): - # _STRUCT_X86_THREAD_STATE64 keeps __rip at index 16 of its 21 64 bit fields - state = [0] * 21 - state[16] = 0x100000F00 - obj = load(CPU_TYPE_X86_64, x86_THREAD_STATE64, struct.pack("<21Q", *state), 0x100000000) - assert obj.entry == 0x100000F00 +def load(path: Path | str) -> cle.MachO: + ld = cle.Loader(str(path), main_opts={"backend": "mach-o"}, auto_load_libs=False) + assert isinstance(ld.main_object, cle.MachO) + return ld.main_object -def test_x86_thread_state(): - # _STRUCT_X86_THREAD_STATE32 keeps __eip at index 10 of its 16 32 bit fields and __gs last, so a - # reader that takes the last field it unpacks as the program counter comes back with __gs - state = [0] * 16 - state[10] = 0x4F00 - state[15] = 0xDEADBEEF - obj = load(CPU_TYPE_X86, x86_THREAD_STATE32, struct.pack("<16I", *state), 0x4000) - assert obj.entry == 0x4F00 +def patch_unixthread(field: int, value: int) -> Path: + """ + A copy of the fixture with one 32 bit field of its LC_UNIXTHREAD command overwritten. -def test_arm64_thread_state(): - # _STRUCT_ARM_THREAD_STATE64 keeps __pc at index 32 of its 33 64 bit fields, __cpsr and __pad follow - state = [0] * 33 - state[32] = 0x100000F00 - obj = load(CPU_TYPE_ARM64, ARM_THREAD_STATE64, struct.pack("<33Q2I", *state, 0, 0), 0x100000000) - assert obj.entry == 0x100000F00 + :param field: Offset of the field within the command. + :param value: Value to write there. + """ + data = bytearray(FIXTURE.read_bytes()) + command, _cmdsize = struct.unpack_from("<2I", data, UNIXTHREAD_OFFSET) + assert command == LC.LC_UNIXTHREAD, "the fixture's thread command moved, update UNIXTHREAD_OFFSET" + struct.pack_into("