Skip to content

Mach-O: Read the LC_UNIXTHREAD entry point per cputype - #727

Open
zardus wants to merge 3 commits into
masterfrom
feature/fix-cle-macho-unixthread
Open

Mach-O: Read the LC_UNIXTHREAD entry point per cputype#727
zardus wants to merge 3 commits into
masterfrom
feature/fix-cle-macho-unixthread

Conversation

@zardus

@zardus zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

_load_lc_unixthread chose the thread state layout from the flavor field alone, but Mach-O flavor numbers are only unique within a cputype. Flavor 4 is x86_THREAD_STATE64, so every x86_64 executable carrying its entry point in LC_UNIXTHREAD was rejected with an empty CLECompatibilityError; on 32-bit x86, flavor 1 was read as ARM_THREAD_STATE, which yields __gs as 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_base to the ld64 default for a position independent MH_EXECUTE and reads the vmaddr of __TEXT instead. Go's internal linker links darwin/amd64 executables at 0x1000000, and Go is the toolchain still emitting LC_UNIXTHREAD rather than LC_MAIN, so without that the mapped base lands four gigabytes above every segment and the load aborts on assert obj.min_addr <= obj.max_addr in Loader._map_object as soon as the entry point is read correctly. The old constants remain the fallback for a binary that declares no __TEXT, and ld64 puts __TEXT exactly 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)

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.
@zardus

zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head ca4a86742497c48e249bd6005abedd4c5aa2808c against baseline b58ea02a446106647cdaae32bdf91b7062404cc1. Python 3.12.13, pytest run from the cle checkout.

  • Focused: python -m pytest tests/test_macho_unixthread.py — 3 passed
  • Mach-O modules: python -m pytest tests/test_macho*.py — 39 passed, 9 skipped, the same skip set as the baseline
  • Fails without the fix: with the branch's production diff reverted and the test left in place, all 3 fail with cle.errors.CLECompatibilityError out of _load_lc_unixthread, logged as Unknown thread flavor: 4. Reverting only the LC_UNIXTHREAD change gives those same three errors; reverting only the __TEXT base address change gives all 3 failing on assert obj.min_addr <= obj.max_addr in cle/loader.py. Both production changes are load-bearing for this test
  • Pre-commit: pre-commit run --all-files — every hook passes and leaves the tree unchanged
  • Test inputs: the workspace check passes for cle; the branch adds no binary and assembles no container
  • Workspace: cle only, plus the fixture in Add fixtures for cle loader cases that had no real binary binaries#176; no other repository is touched
  • Hosted CI: red until Add fixtures for cle loader cases that had no real binary binaries#176 merges, because cle CI checks out binaries master unconditionally and the fixture only exists on that branch

The regression test loads tests/x86_64/terramate.macho from angr/binaries#176, sha256 020c5d7df5621bef908294f59cc2da732bfa8360fe179114525f6bd51fd294ae: a Go-linked MH_EXECUTE, cputype=0x1000007, MH_PIE, __TEXT at 0x1000000, one LC_UNIXTHREAD at file offset 0x650 with flavor=4, count=42 whose __rip word holds 0x1081180. That is the same cputype, flavor, count and entry point as the sample the report behind this PR was written from. The two malformed cases patch one 32-bit field of a copy of that file rather than inventing a container: the flavor overwritten with x86_FLOAT_STATE64, and the count cut to two words.

Input Baseline Head
the fixture as shipped empty CLECompatibilityError entry 0x1081180, from __rip
flavor patched to 5 (x86_FLOAT_STATE64, which carries no program counter) empty CLECompatibilityError loads, no entry point, segments intact
count patched to 2, far short of an x86_thread_state64_t empty CLECompatibilityError loads, no entry point, segments intact

An earlier version of this record covered head 4c34ee62f8dbf6d68548171cc57c16a213ff69c6, whose test module assembled its executables with struct.pack instead of loading a fixture. Results from it that this head does not repeat: python -m pytest tests gave 209 passed, 9 skipped against 202 passed, 9 skipped on the baseline; pylint 10.00 on both edited files and on the test; pyright badness on cle/backends/macho/macho.py down from 0.1723 to 0.1695; the angr/cle_727 branch of dec-snapshots identical to its master, which rules out a corpus regression rather than showing a benefit, since the corpus holds no Mach-O LC_UNIXTHREAD executable. The report behind the PR came from a sweep in which 76 units failed on the x86_64 row, all MH_EXECUTE. The full suite has not been re-run since; what was added on top is the __TEXT base address fix and the test rewrite, and every Mach-O module passes above.

Caveats:

  • ARM_UNIFIED_THREAD_STATE is still not decoded; it nests a second flavor/count header, and no sample of it was available.
  • LC_UNIXTHREAD may carry a sequence of flavor/count/state triples. Only the first is read, as before.
  • The fixture is not MH_TWOLEVEL, so loading it logs the backend's existing warning about flat namespacing. That is unrelated to this change and does not affect the entry point.
  • The nine skips are the pre-existing TODO markers in tests/test_macho_bindinghelper.py, identical on both revisions.

@angr-bot

Copy link
Copy Markdown
Member

Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_727

zardus and others added 2 commits August 10, 2026 20:35
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants