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

Problem

An x86_64 executable that carries its entry point in LC_UNIXTHREAD is refused outright, with an exception carrying no message. tests/x86_64/terramate.macho is a Go binary, and Go's internal linker still emits LC_UNIXTHREAD rather than LC_MAIN:

stock LC_UNIXTHREAD at 0x650: cmdsize=184 flavor=4 count=42
--- stock fixture (x86_THREAD_STATE64, flavor 4)
    RAISED CLECompatibilityError: ''
      | File "cle/backends/macho/macho.py", line 813, in _load_lc_unixthread
      | raise CLECompatibilityError()

The raise sits inside load-command parsing, so nothing else about the binary is parsed either — its five segments, its sections and its symbol table are all lost with the entry point.

Root cause

MachO._load_lc_unixthread chose the thread state layout from the flavor field alone:

if flavor == 1 and self.arch.bits != 64:  # ARM_THREAD_STATE or ARM_UNIFIED_THREAD_STATE or ARM_THREAD_STATE32
    ...
elif flavor == 1 and self.arch.bits == 64 or flavor == 6:
    ...
else:
    log.error("Unknown thread flavor: %d", flavor)
    raise CLECompatibilityError()

Mach-O flavor numbers are only unique within a cputype. Flavor 4 is x86_THREAD_STATE64, which matches neither arm, so terramate takes the else. The same predicate reads flavor 1 on 32-bit x86 — x86_THREAD_STATE32 — as ARM_THREAD_STATE, which would report __gs rather than __eip; no fixture here exercises that path.

Fix

The layouts are keyed on (cputype, flavor), with CPUType, X86ThreadFlavor and ARMThreadFlavor enums in macho_enums.py, and the state is bounded by the declared word count and the end of the file. A layout cle does not know, a count too small for its flavor, or a truncated file logs a warning and leaves unixthread_pc unset rather than aborting: the entry point is all this command contributes, and _resolve_entry already reports a binary that has none.

It also reads linked_base from the vmaddr of __TEXT for a position-independent MH_EXECUTE rather than the ld64 default, because Go links darwin/amd64 at 0x1000000 and the load would otherwise abort in Loader._map_object once the entry point is read correctly.

--- stock fixture:            LOADED MachO arch=AMD64 cputype=0x1000007
    unixthread_pc=0x1081180 entry=0x1081180 linked_base=0x1000000 mapped_base=0x1000000
    segments=['__PAGEZERO', '__TEXT', '__DATA_CONST', '__DATA', '__LINKEDIT']
--- flavor patched to 5:      LOADED  unixthread_pc=None entry=0x0   (same segments, same span)
--- count patched to 2:       LOADED  unixthread_pc=None entry=0x0   (same segments, same span)

Testing

tests/test_macho_unixthread.py::test_entry_point_comes_from_the_thread_state asserts obj.entry == 0x1081180 on the stock fixture; ::test_flavor_without_a_known_layout_still_loads and ::test_thread_state_shorter_than_its_flavor_still_loads patch one 32-bit field of the command in a temporary copy and pin that the rest of the binary still loads. All three fail on the merge base, where even the stock fixture is refused. The fixture is on angr/binaries master.

Validation: #727 (comment)

session: sharpen

@zardus

zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 6da024934c007ef689c59b4749db8b47900d0c93 against baseline c7e0d4db664a45b59c84967ac42d04f82a97b087. Python 3.12.13, pytest run from the cle checkout.

Re-keyed from aba66255fe436a5acfc8648d0111d6e7c91d9d01 on baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b. git range-diff marks the first commit ! rather than =, but the branch's own added and removed lines are byte-identical across the move: the difference is index lines, hunk offsets and hunk context, because master's a4fb8003 ("Mach-O: say what was rejected instead of raising an empty error") rewrote the error text immediately beside these hunks. Three commits separate the two baselines, and two of them touch cle/backends/macho/, so every figure below was re-measured at this head rather than carried over.

Measured configuration: a detached worktree of cle at the revision named, this workspace's pinned Python 3.12 environment, nice -n 19, no xdist, -p no:randomly. pylint is run with the CI configuration from angr/ci-settings, ci-image/conf/pylintrc, because cle declares no [tool.pylint] table and the bare defaults score about two points lower on every file.

  • Focused: python -m pytest tests/test_macho_unixthread.py3 passed in 2.8 s
  • Mach-O modules: python -m pytest tests/test_macho*.py44 passed, 9 skipped in 1.5 s. The earlier record's 39 passed is master's growth, not this branch's; the nine skips are the pre-existing TODO markers in tests/test_macho_bindinghelper.py
  • Fails without the fix, three configurations, all at this head with only the named production files moved:
    • both cle/backends/macho/macho.py and macho_enums.py at the baseline — 3 failed, each an empty cle.errors.CLECompatibilityError raised at macho.py:827 out of _load_lc_unixthread
    • the __TEXT base-address commit reverted, leaving the LC_UNIXTHREAD one — 3 failed, all on assert obj.min_addr <= obj.max_addr in cle/loader.py
    • the LC_UNIXTHREAD commit reverted, leaving the __TEXT one — 3 failed, each CLECompatibilityError logged as Unknown thread flavor: 4
      So both production commits are load-bearing for this test, and neither alone is enough
  • Lint: pylint per changed file, this head against the baseline — cle/backends/macho/macho.py and cle/backends/macho/macho_enums.py both flat at 10.00, and the new tests/test_macho_unixthread.py at 10.00
  • Test inputs: check-test-inputs.py --repository cle over this head reports "no binaries or assembled containers outside angr/binaries". The branch adds no binary and assembles no container
  • Workspace: cle only, plus the fixture from Add fixtures for cle loader cases that had no real binary binaries#176, which has merged, so nothing here waits on a sibling

The regression test loads tests/x86_64/terramate.macho, 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 pull request 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/count pair sits at file offset 0x658. The table below was re-read at this head and at the baseline for this record:

Input Baseline Head
the fixture as shipped empty CLECompatibilityError loads, entry 0x1081180 from __rip, 5 segments
flavor patched to 5 (x86_FLOAT_STATE64, which carries no program counter) empty CLECompatibilityError loads, no entry point, 5 segments intact
count patched to 2, far short of an x86_thread_state64_t empty CLECompatibilityError loads, no entry point, 5 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 then baseline; 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 pull request came from a sweep in which 76 units failed on the x86_64 row, all MH_EXECUTE. Those figures are not carried forward to this head; the Mach-O modules row above and the hosted checks below cover the same ground at this revision.

The old linked_base constants remain the fallback for a binary that declares no __TEXT, and ld64 puts __TEXT exactly where they said, so nothing linked by ld64 moves.

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.

Hosted CI at head 6da024934c007ef689c59b4749db8b47900d0c93, read live 2026-08-29T20:24Z: 18 check runs, every one success, and both legacy commit statuses greenpre-commit.ci - pr and docs/readthedocs.org:cle — for 20 terminal green checks and nothing outside success. That includes ci / Lint, ci / Typecheck, ci / Build, all eleven ci / Test shards, Test (Pyodide), Test windows-2022 and Test macos-15. The workflow run is https://github.com/angr/cle/actions/runs/33239517429, concluded success at this head. The local pre-commit run --all-files row the earlier record carried is dropped in favour of pre-commit.ci - pr at this exact head.


Re-keyed 2026-09-05 to head 8b59ffb581ee7c291bd889168c415a690f84dc43, on baseline 0e77ade3c39a3cee05f65051e57955675e1ac21b. The branch was CONFLICTING after cle#810 merged; this is a rebase, not a new patch.

The one conflict was the import block of cle/backends/macho/macho.py. This branch consolidates from .macho_enums import ARMThreadFlavor, CPUType, MachoFiletype, MH_flags, X86ThreadFlavor; #810 added TYPE_MASK, ZEROFILL_SECTION_TYPES to the from .section import ... line next to it. Both are kept and nothing else conflicted.

The patch itself did not move. git range-diff c7e0d4db..6da02493 0e77ade3..8b59ffb5 marks commits 2 and 3 =; commit 1 is ! and the whole of that is the import line above. Filtering both diffs to added and removed lines only and comparing them: 226 lines on each side, identical sets.

Two things measured again at this head rather than carried over:

  • Focused and module tests: python -m pytest tests/test_macho_unixthread.py tests/test_macho.py — 17 passed, 1 failed. The failure is test_macho.py::test_relocatable_object, whose fixture tests/x86_64/relocatable_object.macho is not in the local angr/binaries checkout; it fails identically on unmodified 0e77ade3, so it is not this branch's. It is on binaries master, so hosted CI does not see it.
  • All 24 Mach-O fixtures tracked in the local binaries checkout were loaded on 0e77ade3 and on this head and compared on linked_base, mapped_base, min_addr, max_addr, entry, function-hint count and symbol count. One file differs, and it is the intended one: tests/x86_64/terramate.macho raises CLECompatibilityError on the base and loads at 0x1000000 on the head. FileProtection-05.armv7.macho exercises the 32-bit path and still gets 0x4000, the old constant.

Correcting a line above. This record says check-test-inputs.py reported "no binaries or assembled containers outside angr/binaries". That was true when it was written and is not true now: the checker gained a run-time-manufacture rule on 2026-08-30 in 727360f75, and at this head it reports two findings, both in patch_unixthread in tests/test_macho_unixthread.pybytearray( at line 42 and struct.pack_into( at line 45, the copy-and-patch the table above describes. The code is unchanged since 2026-08-10; only the rule is new. It needs a fixture in angr/binaries for each malformed case, or a reviewed allow-list entry, and that is being handled separately rather than by weakening the test.

Hosted CI is re-running at this head; the run at 6da02493 reported above is superseded by whatever this one says.

@angr-bot

Copy link
Copy Markdown
Member

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

@zardus

zardus commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Independent corroboration from a survey of Mach-O binaries, in case it is useful
for review. Nine real-world x86_64 executables produced by Go's internal linker
go from unloadable to loading on this branch.

They are interesting because the failure is only reachable after a different
fix. Go's internal linker emits a __DWARF segment with vmsize == 0 carrying
the whole debug blob as file content, and because that segment occupies no
address space the linker gives __LINKEDIT the same vmaddr:

SEG __DATA       vmaddr=0x1001a0000 vmsize=0x371e8  fileoff=0x1a0000 filesize=0x0c2c0
SEG __DWARF      vmaddr=0x1001d8000 vmsize=0x0      fileoff=0x1b0000 filesize=0xbcbf5
SEG __LINKEDIT   vmaddr=0x1001d8000 vmsize=0x2d2d2  fileoff=0x270000 filesize=0x2d2d2

Before #729 these died in _load_segment with Address ... is already backed!,
because the backer was sized from filesize alone. With #729 merged, the aarch64
ones load — and the x86_64 ones get one step further and then hit the
LC_UNIXTHREAD path this pull request fixes: Go/amd64 emits flavor 4
(x86_THREAD_STATE64, count 42), which fell through to CLECompatibilityError
beside the TODO: This is the place to add x86 and x86_64 thread states.

Measured over the same twenty-one objects, each loaded in an isolated
environment:

pin (pre-#729) current master this branch
aarch64 (12) all already backed! all load all load
x86_64 (9) all already backed! CLECompatibilityError all load

Entry points were checked against the rip field read directly out of the load
command rather than taken from cle — 0x106cde0 and 0x107fc40 both match. The
linked_base correction in this branch also fixes the negative RVAs those
binaries were reporting: min_addr now reads 0x1000000, the real __TEXT
vmaddr.

No action requested; the branch already does the right thing. Recording the
evidence in case a second data point helps.

@zardus

zardus commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

A corpus measurement, in case it is useful for review.

A decompilation sweep over 35,578 objects recorded 48 distinct Mach-O binaries
that fail to load. All 48 attribute to the defect this PR fixes.

Every one has the same shape: MH_MAGIC_64, cputype=0x1000007
(CPU_TYPE_X86_64), MH_EXECUTE, and a single LC_UNIXTHREAD carrying
flavor=4 (x86_THREAD_STATE64) with count=42 — Go's internal linker still
emitting LC_UNIXTHREAD where LC_MAIN would be, which is the case the
current flavor table does not cover.

Method: the sweep's ledger stores full tracebacks, so each failure was
attributed by the macho.py line number in its last frame, and all 48 land on
the raise CLECompatibilityError() in _load_lc_unixthread. I then re-ran the
loads directly to confirm the attribution rather than relying on the ledger
alone.

Two details for anyone reading failure counts off a ledger like this one:

  • The 48 appear as 96 records. Each binary was run under two decompiler
    configurations, so every one is counted twice.
  • The records carry an empty error string, because that raise has no message.
    They are separable from other Mach-O rejections only by the line number in a
    stored traceback. This PR removes the raise rather than messaging it, so the
    question does not arise afterwards.

None of the 48 come from redistribution-restricted material.

session: sharpen

@zardus

zardus commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Correction to my previous comment. The denominator I gave was wrong, and it
understated this defect.

I wrote "a sweep over 35,578 objects recorded 48 distinct Mach-O binaries that
fail to load". 35,578 is the size of the enumerated corpus, not the set that
has actually been swept, so the sentence reads as 48 in 35,578 — about 0.13%.
That is not the rate.

Measured over what has actually been swept:

  • distinct binaries swept: 1,154
  • of those, Mach-O: 163
  • Mach-O failing to load: 49

So roughly 30% of the Mach-O binaries reached so far fail to load (49/163).

Of those 49, 48 are the flavor=4 _load_lc_unixthread population from my
previous comment, which is the set this PR fixes. The remaining one fails a
different check — Unsupported Mach-O file type: 8 (MH_BUNDLE) at
macho.py:213, which #728 covers rather than this PR. So the 48 is precisely
the flavor-4 population, and the gap between 48 and 49 is that one unrelated
rejection.

This agrees with the load-only A/B already recorded against this corpus, which
I re-derived from its raw output rather than quoting: over 5,032 Mach-O
objects, master loads 3,476 and fails 1,556 (31%), and this PR's head loads
5,032 and fails 0. Two instruments, ~30% both times.

session: sharpen

zardus added a commit that referenced this pull request Aug 28, 2026
Thirteen raise sites in the Mach-O backend raised CLECompatibilityError or
CLEInvalidBinaryError with no message. A caller that catches one, and any log
that records one, gets an empty string: every rejection is indistinguishable
from every other, and from the several unrelated conditions that raise the same
type. In a sweep over 35,578 objects, 48 binaries failed this way, and the only
thing separating them from a rejection that did carry a message ("Unsupported
Mach-O file type: 8...", classifiable on sight) was that someone had written
the message.

Most of these sites already computed the diagnosis and then threw it away into
a log.error immediately above the raise, where it is lost as soon as logging is
not configured at that level, and is not attached to the exception in any case.
Move that information into the exception, and add it where it did not exist:
each message now names what was found and what was expected, so the message
alone identifies the check that rejected the file.

_load_lc_unixthread's unknown-thread-flavor raise is deliberately left alone:
open PR #727 deletes that branch rather than messaging it, making an
unrecognized flavor non-fatal, and a message there would only conflict.

The regression test loads an existing ELF fixture through the Mach-O backend,
the one path of the thirteen reachable from a fixture that already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

The two rebase messages name the segment they overran, and `segment`, `address`
and `reloc_type` are all None until a SET_* opcode assigns them, so reading them
for the message is only well typed once that is established. Reject a rebase
opcode that arrives before its state is set, which is a malformed blob and
previously died on an AttributeError several lines later, and hoist the segment
end out of the loop it does not vary in.
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full load report for tests/x86_64/terramate.macho and two copies of it, before and after this change. The copies patch one 32-bit field of the LC_UNIXTHREAD command at file offset 0x650: the flavor, set to 5 (x86_FLOAT_STATE64, a legal thread state with no program counter in it), and the word count, set to 2, far shorter than the flavor's state. Each case prints the entry point the command yields, the base addresses, and the segments recovered.

Before — all three loads are refused with a message-less CLECompatibilityError, the stock fixture included:

cle at the merge base, 46a3733
cle: <cle at the merge base>/cle/__init__.py
fixture: tests/x86_64/terramate.macho
stock LC_UNIXTHREAD at 0x650: cmdsize=184 flavor=4 count=42
--- stock fixture (x86_THREAD_STATE64, flavor 4)
    RAISED CLECompatibilityError: ''
      | File "<cle at the merge base>/cle/backends/macho/macho.py", line 813, in _load_lc_unixthread
      | raise CLECompatibilityError()
      | cle.errors.CLECompatibilityError
--- temp copy with flavor patched to 5 (x86_FLOAT_STATE64, no program counter) [path: <tmp>/terramate.macho]
    RAISED CLECompatibilityError: ''
      | File "<cle at the merge base>/cle/backends/macho/macho.py", line 813, in _load_lc_unixthread
      | raise CLECompatibilityError()
      | cle.errors.CLECompatibilityError
--- temp copy with count patched to 2 (thread state far shorter than the flavor needs) [path: <tmp>/terramate.macho]
    RAISED CLECompatibilityError: ''
      | File "<cle at the merge base>/cle/backends/macho/macho.py", line 813, in _load_lc_unixthread
      | raise CLECompatibilityError()
      | cle.errors.CLECompatibilityError

After — the stock fixture yields its entry point and its five segments, and both patched copies load with no entry point rather than no object:

with this change, aba6625
cle: <cle with this change>/cle/__init__.py
fixture: tests/x86_64/terramate.macho
stock LC_UNIXTHREAD at 0x650: cmdsize=184 flavor=4 count=42
--- stock fixture (x86_THREAD_STATE64, flavor 4)
    LOADED  MachO arch=AMD64 cputype=0x1000007 pic=True
    unixthread_pc=0x1081180 entry=0x1081180 linked_base=0x1000000 mapped_base=0x1000000
    segments=['__PAGEZERO', '__TEXT', '__DATA_CONST', '__DATA', '__LINKEDIT']
    min_addr=0x1000000 max_addr=0x11f1b5f
--- temp copy with flavor patched to 5 (x86_FLOAT_STATE64, no program counter) [path: <tmp>/terramate.macho]
    LOADED  MachO arch=AMD64 cputype=0x1000007 pic=True
    unixthread_pc=None entry=0x0 linked_base=0x1000000 mapped_base=0x1000000
    segments=['__PAGEZERO', '__TEXT', '__DATA_CONST', '__DATA', '__LINKEDIT']
    min_addr=0x1000000 max_addr=0x11f1b5f
--- temp copy with count patched to 2 (thread state far shorter than the flavor needs) [path: <tmp>/terramate.macho]
    LOADED  MachO arch=AMD64 cputype=0x1000007 pic=True
    unixthread_pc=None entry=0x0 linked_base=0x1000000 mapped_base=0x1000000
    segments=['__PAGEZERO', '__TEXT', '__DATA_CONST', '__DATA', '__LINKEDIT']
    min_addr=0x1000000 max_addr=0x11f1b5f

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

A second corpus measurement, on a much broader and less Mach-O-heavy sample than
the one already recorded above, so the two denominators do not get confused.

Sample. 12,000 objects drawn uniformly at random, from a seeded permutation,
out of a 624,920-object internal corpus of compiler- and vendor-produced
binaries; 11,989 were retrievable and probed, of which 1,204 are Mach-O or
Universal 2. Rates carry 95% Wilson intervals.

Method. Each object is loaded with the catalogue's declared load recipe and
CFGFast is taken as far as the failure under test; the same object set is then
probed against master and against this branch's head aba66255 in one
environment, so before and after are the same objects.

Before. 350 of the 1,204 Mach-O objects fail at load — 29.1%, which agrees
with the ~30% recorded in the earlier comment. The split by class is different
here: 3 fail at _load_lc_unixthread, the class this branch fixes, and 347 fail
at MachO.__init__ on an unsupported file type.

After. All 3 load, and all 3 complete a full CFGFast — 28,455, 6,914 and
2,684 functions, all AMD64. That is 3 / 11,989 = 0.03% of the sample
(CI 0.01–0.07) and 0.25% of its Mach-O objects. The 347 are untouched by
this branch, 0 of 347 changing class — they are #728's population, of which that
branch clears 142.

The gap against the earlier 48-object figure is corpus composition, not a
disagreement: this sample's Mach-O population is dominated by vendor and package
manager builds rather than Go-internal-linker output, so LC_UNIXTHREAD with
flavor=4 is rare in it while MH_OBJECT and MH_BUNDLE are common. Both
measurements are of the same defect at different mixes.

A 500-object control set that already reached CFG on master is unchanged — 0 of
500 differ.

The corpus is not redistributable, so the objects are described by architecture,
format and OS rather than named; none of the affected objects is byte-identical
to anything tracked in angr/binaries.

session: sharpen

@zardus
zardus force-pushed the feature/fix-cle-macho-unixthread branch from aba6625 to 6da0249 Compare August 29, 2026 06:52
zardus and others added 3 commits September 5, 2026 21:00
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.
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>
@zardus
zardus force-pushed the feature/fix-cle-macho-unixthread branch from 6da0249 to 8b59ffb Compare September 5, 2026 21:23
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