Mach-O: Read the LC_UNIXTHREAD entry point per cputype - #727
Conversation
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.
|
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS Validation record for head
The regression test loads
An earlier version of this record covered head Caveats:
|
|
Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_727 |
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS
_load_lc_unixthreadchose the thread state layout from the flavor field alone, but Mach-O flavor numbers are only unique within a cputype. Flavor 4 isx86_THREAD_STATE64, so every x86_64 executable carrying its entry point inLC_UNIXTHREADwas rejected with an emptyCLECompatibilityError; on 32-bit x86, flavor 1 was read asARM_THREAD_STATE, which yields__gsas the entry point instead of__eip.This keys the layouts by (cputype, flavor), and checks the thread state against the length the command declares and against the end of the file, so a state that cannot be read leaves the entry point unset instead of aborting the load.
It also stops pinning
linked_baseto the ld64 default for a position independentMH_EXECUTEand reads the vmaddr of__TEXTinstead. Go's internal linker links darwin/amd64 executables at0x1000000, and Go is the toolchain still emittingLC_UNIXTHREADrather thanLC_MAIN, so without that the mapped base lands four gigabytes above every segment and the load aborts onassert obj.min_addr <= obj.max_addrinLoader._map_objectas soon as the entry point is read correctly. The old constants remain the fallback for a binary that declares no__TEXT, and ld64 puts__TEXTexactly where they said.The regression test loads a Go-linked x86_64 executable added by angr/binaries#176, so the checks here stay red until that merges.
Validation: #727 (comment)