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

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 839, in _load_lc_unixthread
      | raise CLECompatibilityError()

The raise sits inside load-command parsing, so the binary's five segments, its sections and its symbol table are lost with the entry point.

terramate is not one fixture. Of 13,431 Mach-O objects in our corpus that a sweep has actually covered, 708 carry an LC_UNIXTHREAD and 699 carry a (cputype, flavor) pair cle master refuses, every one of them cputype=0x1000007 with flavor=4. No object goes the other way.

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 all 699 take the else; and the same predicate reads flavor 1 on 32-bit x86 as an arm state.

Fix

The layouts are keyed on (cputype, flavor), with new enums in macho_enums.py, and the state is bounded by the declared word count and the end of the file. An unknown layout, too small a count, or a truncated file logs a warning and leaves unixthread_pc unset rather than aborting; _resolve_entry already reports a binary with no entry point.

It also reads linked_base from __TEXT's vmaddr 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. On 64 bit that reads back the constant it replaces, for all 5,616 swept MH_EXECUTE images that do not carry the command; on 32 bit it was already wrong for 9 of the 10 images on a cputype cle supports. A binary that declares no usable __TEXT address still gets the old constants.

Measured on the corpus

One environment per side, cle master 997d4322 against this branch, with angr, pyvex, archinfo and pypcode byte-identical across both:

$ nix/run.sh --feature pr-cle-727-base -- python -P -c "import cle; o=cle.Loader('OBJECT', main_opts={'backend':'mach-o'}, auto_load_libs=False).main_object; print(hex(o.entry), hex(o.linked_base), o.unixthread_pc)"
$ nix/run.sh --feature pr-cle-727 -- python -P -c "import cle; o=cle.Loader('OBJECT', main_opts={'backend':'mach-o'}, auto_load_libs=False).main_object; print(hex(o.entry), hex(o.linked_base), hex(o.unixthread_pc))"

All 699 were measured. On master 699 of 699 are refused, every one at macho.py:839 in _load_lc_unixthread with an empty message; on this branch 699 of 699 load. The head's answers were checked against each file's own bytes, not against each other: linked_base equals the object's own __TEXT vmaddr and unixthread_pc the __rip word of its own thread state, for 699 of 699. 695 of the 699 carry MH_PIE in their own header. The other 4 reach that branch only through MachO.pic, independently wrong at macho.py:164; with that corrected they are refused on both sides, so 695 depends on nothing but this change.

The two objects cited below are ones the sweep recorded itself: both ok in a 2026-09-21 epoch whose rollup carried this branch, and load-error in the 2026-10-04 epoch, which does not. Both load here, at 0x107bfc0 and 0x107c380, each the file's own __rip. Three more went through a CFG and one decompiled function each, recovering 2,284, 3,288 and 2,746 functions.

$ nix/run.sh --feature pr-cle-727 -- python -P -c "import angr, logging; logging.disable(logging.ERROR); p=angr.Project('OBJECT', auto_load_libs=False); cfg=p.analyses.CFGFast(normalize=True); f=p.kb.functions['gogo']; print(p.analyses.Decompiler(f, cfg=cfg.model).codegen.text)"

The samples are unpublished, so all five are identified by SHA-256: eb9b8808c21a0b12139afc3eba6d503212daeecad7bf40604b9e05afa6665f20, eba86c6c5bb7fb64965bef2d8dcd824c6511a85b45755418ca76c942cf7a84b8, 06f50408ac2c9d7c0d54c5f851102cb5dfb5624116a784811fe96c9f3ef3064b, 6475878f31ba6fd2a17f0c543d2b6ccf52f664e3228b0b7358a2f1d28485a87f and 93e46f3819df001496cba9f3f839114f13b1fa8abae7e38b834dabecb0155768.

Regression set: 487 other swept Mach-O objects, none affected, across 19 header strata — arm64, x86_64, 32-bit x86 and arm thin images, fat and Universal2 containers, every file type the corpus has, PIE and not. None got worse. 400 load on both sides and 87 are refused on both with the same message; of the 400, 391 are identical in every loader fact and 9 differ. Those 9 are four thin 32-bit x86 MH_EXECUTE and five fat containers carrying such a slice; on each, __eip is read where master read __gs and got 0. None of the 9 is claimed here: each is a non-PIE MH_EXECUTE reaching this code only through the pic defect above, and with that corrected both sides refuse all 9. Function recovery is identical on all 25 objects that load on both sides, agree on the base address and produced a count. Four more were excluded for disagreeing on it, and are four of those 9: each recovers 201 functions on master and 197 here, which nothing here settles.

Testing

tests/test_macho_unixthread.py loads three committed fixtures: terramate.macho for the entry point, and the two variants for the cases that must load without one. angr/binaries#245 adds the last two, so this cannot merge before it. Four checks are not green; the validation comment accounts for each. The durable reason: cle resolves a single angr/binaries pull request from this description, so with #245 named, master's test_relocatable_object_no_symtab cannot reach its own fixture, unmerged in another. All three tests here fail on the merge base.

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 3da169096c3d8612b8587b15270be9597d18fdc6 against baseline 997d4322678fe5876d9f83e0dd34e1052813b9db. 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 in this section was measured at head 6da02493. Read them as that head's; the final section of this record carries what was re-measured since.

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.py — 3 passed in 2.8 s
  • Mach-O modules: python -m pytest tests/test_macho*.py — 44 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 head 6da02493 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 out of _load_lc_unixthread — at macho.py:827 in that baseline's file, and at line 839 in the current one; the frame is the same and the line number moved with master
    • 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, head 6da02493 against baseline c7e0d4db — 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 at that head: cle only, plus the fixture from Add fixtures for cle loader cases that had no real binary binaries#176, which had merged. That is no longer the position: this branch now needs Add two LC_UNIXTHREAD variants of terramate.macho binaries#245, which is open. See the final section

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 read at head 6da02493 and at baseline c7e0d4db:

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 usable __TEXT address. How close they were to the truth is measured in the final section's table.

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 green — pre-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.py — bytearray( 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.


Re-keyed 2026-10-03 to head 3da169096c3d8612b8587b15270be9597d18fdc6, on baseline 997d4322678fe5876d9f83e0dd34e1052813b9db. The figures below were taken at 2cf929d2c764843c98eabd187f00a85b0f9506b4, which carries the same tree, ac44cb60a0b62eeef11ca717168e05b6cbd77525; this head is that commit with one commit message corrected. Figures in this section are at that head and that baseline unless the sentence names another revision. The opening statement of this record had been left on 6da02493 by the 2026-09-05 re-key; it is now correct, and the sentences above that said "this head" now name the head they were measured at, because re-keying the opening moved what they pointed to.

This is a new candidate, not a formality rebase. The branch was rebased off 0e77ade3 onto this baseline, and separately the two malformed test inputs were replaced with committed fixtures. The rebase itself replayed with no conflict and moved no line: master's only change to cle/backends/macho/macho.py since 0e77ade3 is b0203a53 ("Mach-O: Support loading files with no LC_SYMTAB"), one line at self.symtab_nsyms, nowhere near these hunks. The test change did move lines, deliberately: git range-diff 0e77ade3..8b59ffb5 997d4322..2cf929d2c764843c98eabd187f00a85b0f9506b4 marks commit 1 = and commits 2 and 3 !, and comparing the two diffs' added and removed lines gives 226 on the published side and 201 here: 40 of those lines are dropped and 15 are new, and 226 - 40 + 15 = 201.

What the rebase fixes beyond staleness. At 0e77ade3 this branch's pyproject.toml pinned archinfo==9.3.5.dev0 and pyvex==9.3.5.dev0; master pins 10.0.2.dev0. The branch never touched that file, so the skew was the base's — but a consumer that declares this pull request as a cross-repository dependency builds cle from this branch and then cannot resolve those pins. At this baseline the pins are master's.

The two malformed cases load committed fixtures. check-test-inputs.py grew a run-time-manufacture rule on 2026-08-30 in 727360f75, and the previous head tripped it twice:

  • at 8b59ffb581ee7c291bd889168c415a690f84dc43 it exits 1 with two findings, both in patch_unixthread in tests/test_macho_unixthread.py: bytearray( at line 42 and struct.pack_into( at line 45;
  • at this head it exits 0: "1 checkout(s) add no binary or manufactured input outside angr/binaries". The gate's test-inputs suite passes.

The fixtures are tests/x86_64/terramate_float_thread_state.macho (sha256 37b6b65a44ae01a7ee223ebae6586270ab876cbf236fe830371c86b441e4076b, flavor 4 -> 5 at file offset 0x658) and tests/x86_64/terramate_short_thread_state.macho (sha256 e86a49d222fc710df53487fcc6ea13e073ae7ac9194c2c5e4556c2d34218543e, count 42 -> 2 at 0x65c). Each differs from terramate.macho in exactly one byte and all three are 1,891,168 bytes. They come from angr/binaries#245, which is open and has to merge first.

Where executables actually declare __TEXT. The old constants are not a safe assumption, and how unsafe differs by width. Measured over every MH_EXECUTE image in the swept Mach-O corpus, fat slices walked as well as thin files:

images __TEXT vmaddr count
64-bit, not carrying an LC_UNIXTHREAD 0x100000000 5,616 of 5,616
64-bit, carrying one (the affected set) 0x1000000 695
64-bit, carrying one (the affected set) 0x100000000 4
32-bit (9 x86, 5 ppc, 1 arm) 0x1000 14 of 15
32-bit 0x4000 1 of 15

So on 64 bit the new code reads back exactly the constant it replaces for every unaffected executable, and on 32 bit the constant named the wrong address for 14 of the 15 images the corpus has. An earlier version of this record said "ld64 puts __TEXT exactly where they said, so nothing linked by ld64 moves"; that is withdrawn. The table above is the measurement it should have been, and nothing here establishes who linked any of these objects.

The message-less raise, re-counted at this baseline. cle/backends/macho/macho.py at 997d4322 has five raise CLECompatibilityError, exactly one without an argument, at line 839 in _load_lc_unixthread. At this head there are four and none without an argument.

The corpus measurement, and a figure withdrawn. An earlier version of the description said "The corpus holds 1,620 objects of this shape". That number is not reproducible and is withdrawn. Three complete populations, every sha256 verified against the extracted bytes:

population Mach-O objects carry LC_UNIXTHREAD master refuses
covered by a sweep (an object_status or sighting row) 13,431 708 699
every Mach-O object in the catalogs, swept or not 66,068 1,669 1,657
one dataset of Go and Rust builds, swept or not 5,632 1,556 1,556

The description quotes the first row, because those are the objects a sweep actually covered. 1,620 is none of them. No population holds an object whose (cputype, flavor) master accepts and this branch refuses.

Both sides, one environment each, cle master 997d4322 and this branch, each built through its own feature collection. angr, pyvex, archinfo and pypcode come from the same checkouts on both sides and a digest over every .py and .so agrees, with the same digest over cle differing as the control. All 699 affected objects were measured on both sides, not a sample: 699 of 699 refused on master, every one at macho.py:839 with an empty message; 699 of 699 loading here. linked_base equals each object's own __TEXT vmaddr for 699 of 699 and unixthread_pc the __rip word of its own thread state for 699 of 699, each checked against the file's bytes rather than against the other side, with a control that deliberately compares against the wrong value and mismatches all 699.

One thing to know about the environment the corpus run used. It ran against cle 2585639e, which differs from this head only in tests/test_macho_unixthread.py and in two comment lines of macho.py — the docstring and the call-site comment of _text_segment_vmaddr. The executable syntax tree of macho.py is identical across the two (compared with docstrings stripped, against a control that distinguishes two different files), so the library measured is the library published; only the store path differs.

Why the description gives 695 as well as 699. MachO.pic is independently wrong at macho.py:164: bool(self.filetype & MachoFiletype.MH_DYLIB) uses & where == was meant, and MH_DYLIB is 6, so filetype & 6 is truthy for MH_EXECUTE too. 695 of the 699 carry MH_PIE in their own header and are entitled to that branch whatever pic does; the other 4 reach it only through that defect, and with the expression corrected at run time they are refused on both sides. Both numbers are measured and they are not the same 695 objects. Cross-tabulating the 699 on (MH_PIE, own __TEXT vmaddr) gives (set, 0x1000000) 692, (set, 0x100000000) 3, (clear, 0x1000000) 3 and (clear, 0x100000000) 1: both margins are 695 and 4, and the two 695s overlap in 692. So the 4 that depend on the pic defect are not the 4 whose __TEXT sits where the old constant said — three of those 4 do move. Read each 695 with the criterion its own sentence names. The defect is master's, not this branch's, and is reported separately.

Regression set: 487 other swept Mach-O objects, none affected, across 19 header strata. 0 worse. 400 load on both sides and 87 are refused on both sides with the same message; of the 400, 391 are identical in every loader fact and 9 differ, which with the 87 refusals makes the 478 unchanged rows. The 9 are four thin 32-bit x86 MH_EXECUTE, whose own linked_base, entry and unixthread_pc move, and five fat containers whose own facts are identical on both sides and whose X86 slice moves. None of the 9 is claimed as an improvement: each is a non-PIE MH_EXECUTE reaching that code only through the pic defect, and with it corrected both sides refuse all 9 at the filetype check — base at macho.py:221, head at macho.py:241.

Function recovery was compared on the 26 objects that load on both sides and agree on the base address: of the 25 that produced a CFGFast count, all 25 are identical, none fewer and none more; the 26th, an 18.3 MB arm64 MH_DYLIB, finished on neither side inside the cap. Four further objects were excluded for disagreeing on the base address, and they are four of the 9 above: each recovers 201 functions on master and 197 here. The two sides map the same file bytes at the same addresses for those objects — checked by reading cle's own mapping on each side — so the comparison is meaningful, and the four functions the head does not report are unnamed sub_* in a region both sides map. This run did not establish whether they are real code or artifacts of master's wrong base address, and does not claim either.

Local gate, ./feature.sh test pr-cle-727. It ran at 2cf929d2, whose tree ac44cb60a0b62eeef11ca717168e05b6cbd77525 is the tree of this head: the two commits differ only in the message of the third, and the build takes the tracked content, so these are this head's figures.

  • cle: 285 passed, 1 failed, 9 skipped. The failure is tests/test_macho.py::test_relocatable_object_no_symtab, a CLEFileNotFoundError: tests/x86_64/relocatable_object_no_symtab.macho is not tracked in angr/binaries at all (git ls-files --error-unmatch fails on it). The control is the same suite on the baseline collection — 282 passed, 1 failed, 9 skipped, the same single failure — so it is master's, and the three extra passes here are this branch's three tests.
  • Mach-O modules: pytest tests/test_macho*.py — 1 failed, 46 passed, 9 skipped here against 1 failed, 43 passed, 9 skipped on the baseline, the same single failure. An earlier row in this record gives 44 passed and 9 skipped with no failure; that was head 6da02493 against a different baseline and is superseded by this line.
  • test-inputs, test-packages, pre-commit, pysoot and worktree-cleanliness: passed.
  • Three suites failed for reasons outside this change, named rather than left as a red gate. workspace fails four assertions of row["schema"] == 7 in .agents/skills/angr-sweep-corpus/tests/ against an uncommitted SCHEMA = 8 in the same development tree, which that suite reads live rather than from a build; this branch touches none of those files. mono fails test_the_gate_components_are_the_ones_nix_builds, which compares a rollup driver's component list against the workspace's nix definitions and has nothing to do with cle. feature-build fails on the same missing relocatable_object_no_symtab.macho fixture as above.
  • Not re-measured at this head: the pylint and pyright figures in the earlier section, which were taken at 6da02493 on a test module since rewritten, and the three "fails without the fix" configurations. Hosted ci / Lint and ci / Typecheck cover the first at this head.

Caveats, extending the list above:

  • The new table decodes fewer (cputype, flavor) pairs than master's predicate accepted, which is deliberate: master applied an arm layout to any flavor 1 or flavor 6 state whatever the cputype, and every such reading outside the new table was wrong. Measured over the swept scan, the only images affected are five ppc slices carrying a flavor 1 state, one inside each of the five fat containers in the regression set. cle refuses cputype 0x12 at _detect_arch_ident (macho.py:186, "Unsupported architecture") before a single load command is parsed — all five objects are on record in our own ledger against that refusal — so none of them reaches the narrowed code on either side and nothing measured changes.
  • The existing before/after output comment on this pull request is keyed to cle at the merge base, 46a3733 and describes the two malformed inputs as copies patched at run time. Its outputs are still what the two committed fixtures produce, but its keying and its description of the test module predate this round. The equivalent evidence at the current heads is the before/after comment on Add two LC_UNIXTHREAD variants of terramate.macho binaries#245.
  • _text_segment_vmaddr returns None when there is no __TEXT, and the call site treats a returned 0 the same way, so an executable declaring __TEXT at vmaddr 0 takes the ld64 fallback. No such object exists in either scan; the docstring and the comment now say so rather than leaving the reader to infer it.

The two objects cited below are ones the corpus sweep recorded itself, and they are why this is cited rather than argued. Both are Go builds from a controlled generator corpus, MH_PIE MH_EXECUTE, cputype 0x1000007, one LC_UNIXTHREAD with flavor 4 and count 42, __TEXT at 0x1000000. They are not the only two: at least 18 objects had made the same transition, read at 2026-10-04T23:29:53Z, with both 2026-10-04 epochs still running, so that is a floor and not a count. What is stable is the transition itself, per object:

object the 2026-09-21 epoch the 2026-10-04 epoch this branch
eb9b8808c21a0b12139afc3eba6d503212daeecad7bf40604b9e05afa6665f20 ok, backend MachO load-error loads, entry 0x107bfc0
eba86c6c5bb7fb64965bef2d8dcd824c6511a85b45755418ca76c942cf7a84b8 ok, backend MachO load-error loads, entry 0x107c380

The first epoch ran rollup/2026-09-16-032232, which applied 61 cle pull requests including this one at head 8b59ffb581ee7c291bd889168c415a690f84dc43; the second ran rollup/2026-10-04-204523, which applied 56 and not this one, because the rollup takes only green pull requests and recorded its own reason — checks not green: Test (Pyodide) concluded FAILURE, ci / Test (2) concluded FAILURE.

The two epochs are not otherwise alike, so the isolation is worth stating as a measurement rather than as a claim about the ledger. Six of their seven components moved between them, cle's own base among them (3e7bce920733fdafa37283fd23dc9d86f1cab88b to 997d4322678fe5876d9f83e0dd34e1052813b9db); only pypcode is identical, and the collection pins and rollup identity differ too. What does not move is the code that refuses these objects. MachO._load_lc_unixthread is byte-identical at both epoch bases and differs only at this head. The only commit in the range touching cle/backends/macho/ at all is b0203a53, and its whole effect on macho.py is one line: self.symtab_nsyms becomes an int initialised to 0 in MachO.__init__. So no master change in the window reaches this path, and the objects lost the fix rather than meeting a new defect.

Re-measured directly at baseline 997d4322678fe5876d9f83e0dd34e1052813b9db and head 3da169096c3d8612b8587b15270be9597d18fdc6, not read off the ledger: on the baseline each object raises a message-less CLECompatibilityError at macho.py:839 in _load_lc_unixthread, logging Unknown thread flavor: 4; on this head each loads with unixthread_pc equal to its own x86_thread_state64.rip and linked_base equal to its own __TEXT vmaddr, mapping 5 and 6 segments.

Both are sighted on a28ef454f9f4d039, the issue this pull request is the fix for, through the sweep's own record of the same frame carrying an empty message.

Hosted CI at this head. Run 37156346842, read after it completed: 18 check runs, 14 success, 3 failure, 1 cancelled. ci / Build, ci / Lint, ci / Typecheck, ci / Decompiler Snapshot Testing (0) and nine of the ten ci / Test shards are green.

One mechanism accounts for all four non-green checks, and it is not this change. angr/ci-settings/actions/binaries-ref@master, which ci.yml runs at lines 28 and 67, greps the pull request description for an angr/binaries#N reference and takes the first match, so a description resolves exactly one angr/binaries pull request. Two are needed here and only one can be named:

  • this branch's two fixtures are on angr/binaries#245, which the description names;
  • tests/x86_64/relocatable_object_no_symtab.macho, which master's own tests/test_macho.py::test_relocatable_object_no_symtab loads, is on angr/binaries#235 (head 2cf95445) and not on binaries master. git ls-tree -r over refs/pull/235/head finds it; over origin/master it does not, with relocatable_object.macho found on both as the control. A pull request's head is not in refs/remotes/origin, which is why an earlier version of this record said the fixture was on no branch at all. It was wrong.

So no single description satisfies both, and that is a property of the chain rather than a mistake in this candidate. Two sibling pull requests make it checkable without reading the action: cle#855 names angr/binaries#235 and is green on all 18 checks including ci / Test (2); cle#854 names no angr/binaries pull request and carries exactly this tally, 14 success, 3 failure, 1 cancelled.

What each job actually did, measured from its own log rather than from the check name:

check result
ci / Test (2) one failure, test_relocatable_object_no_symtab
Test macos-15 3 failed, 283 passed, 9 skipped — the two new tests and test_relocatable_object_no_symtab
Test (Pyodide) 3 failed, 265 passed, 27 skipped — the same three
Test windows-2022 ran no test: "Sync dependencies with uv" cancelled and "Run tests" skipped, the other half of the macOS matrix

Test macos-15 and Test (Pyodide) failed on this branch's two fixtures as well, which the description does name, for a second and narrower reason: binaries-ref reads github.event.pull_request.body, the description as the run's event payload carried it, and this run was created by the push about a minute before the description was edited. Their logs quote the pre-edit text and print Checking out angr/binaries at master. A rerun reuses the payload, so re-running the failed jobs changed nothing. That part is a sequencing mistake of mine: on a fixture chain the description has to be updated before the push. And nothing undoes it on its own at this head: ci.yml's pull_request: names no types:, so the triggers are the defaults, a push would move the head, and workflow_dispatch carries no pull request payload for the action to read.

@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

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 and others added 2 commits October 3, 2026 20:52
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 usable __TEXT address.

How far off the constants were differs sharply between the two widths. Measured
over every MH_EXECUTE image a corpus sweep here has covered, fat slices
included: all 5,616 of the 64 bit images that do not carry an LC_UNIXTHREAD
declare __TEXT at exactly 0x100000000, so on 64 bit the new code reads back the
constant it replaces. Of the 15 32 bit images, 1 is at 0x4000 and 14 are at
0x1000 -- nine x86 and five ppc -- so on 32 bit the constant names the wrong
address far more often than the right one.

This is also what makes the LC_UNIXTHREAD change observable on darwin/amd64.
Go's linker still emits LC_UNIXTHREAD rather than LC_MAIN, and of the 699 x86_64
objects in that corpus which carry the command, 695 declare __TEXT at 0x1000000
and 4 at 0x100000000: for those 695 the old constant and the file disagree, and
reading the entry point correctly is what exposes it.

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 only 4 of the
699 objects in our corpus whose thread state this change rescues are actually
linked -- the other 695 are at 0x1000000. 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 load committed fixtures too:
tests/x86_64/terramate_float_thread_state.macho and
tests/x86_64/terramate_short_thread_state.macho, each that same binary with one
32 bit field of its LC_UNIXTHREAD command header rewritten, so each differs from
it in a single byte. angr/binaries carries them and
tests_src/macho_unixthread_variants/build.sh, which rebuilds both from the
tracked terramate.macho. Patching a copy at run time would be the same
violation as assembling one: the input the test loads would not be a file
anybody can look at, and nothing outside the test would ever see it.

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.
  The corpus does hold objects carrying a flavor 1 state -- nine x86 and five
  ppc -- but all of them are samples we cannot publish a fixture from, so there
  is nothing to commit here for those layouts. They stay in the table and 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. In a real binary it is not: in terramate it is
  the seventh of fourteen commands, 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 8b59ffb to 3da1690 Compare October 3, 2026 21:48
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