Skip to content

Mach-O: Register the LC_FUNCTION_STARTS entries as function hints - #754

Closed
zardus wants to merge 1 commit into
masterfrom
feature/macho-funcstarts
Closed

Mach-O: Register the LC_FUNCTION_STARTS entries as function hints#754
zardus wants to merge 1 commit into
masterfrom
feature/macho-funcstarts

Conversation

@zardus

@zardus zardus commented Aug 17, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

A Mach-O object hands its consumer no function hints at all, while an ELF one gets a hint for every FDE in .eh_frame. On tests/aarch64/dyld_ios15.macho the backend decodes the whole LC_FUNCTION_STARTS table and then reports nothing:

lc_function_starts count: 36
function_hints count: 0
hints with source FUNCTION_STARTS: 0

On a stripped image that table is the only record of where the functions begin, so a consumer seeding on Backend.function_hints starts from nothing on every Mach-O.

Root cause

MachO._load_lc_function_starts decodes the ULEB deltas into self.lc_function_starts and nothing reads that list afterwards. Backend.__init__ sets self.function_hints: list[FunctionHint] = [] and the Mach-O constructor never appends to it, so Backend.__getstate__ and every consumer see the empty list. FunctionHintSource also had no member such a hint could carry:

class FunctionHintSource:
    EH_FRAME = 0
    EXTERNAL_EH_FRAME = 1
    EXPORT_TABLE = 2

Fix

_register_function_start_hints turns each entry into a FunctionHint under a new FunctionHintSource.FUNCTION_STARTS, called from the constructor beside _parse_mod_funcs. It is a source of its own rather than an export or an unwind record because ld64 records every atom it placed in an executable section, data atoms included, so a consumer has to be able to tell this table from one that names only functions. No size is recorded — the distance to the next entry is not a function length — so each hint is built as FunctionHint(address, 0, FunctionHintSource.FUNCTION_STARTS).

All 36 entries then arrive as hints, and every one lands inside __TEXT,__text at 0x100006ebc, size 0x998:

FunctionHintSource.FUNCTION_STARTS: 3
function_hints count: 36
hint addrs == lc_function_starts: True
all hints inside __text: True

Testing

test_function_starts_hints asserts [h.addr for h in hints] == macho.lc_function_starts on tests/aarch64/dyld_ios15.macho, that there are 36 of them, and that each falls inside the bounds of __TEXT,__text. It fails on the merge base, where the list is empty.

Land this before angr/angr#6861, which decides which entries to use and reads the hint source added here. Landing this half on its own is not neutral either: angr master's _load_func_addr_and_names_from_hints admits every hint source except EH_FRAME, so the new FUNCTION_STARTS entries become definite function starts the day this merges, with none of the validation the angr half adds — the defect that pull request's Problem section measures on tests/armhf/FileProtection-05.armv7.macho. Land the two together, or this one first and the angr one straight after.

Fixes #750. Validation: #754 (comment)

sync: angr/angr#6861

session: sharpen

@zardus

zardus commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 4be4ea50ee6851d30385c6391f798718c5621051 against baseline 929991dbb31fb5eae7767d674c82c683ba6ea3b8.

Re-keyed from e374a46a14c7f4fecd3999d61d6bfdfce4bbeeb5 on baseline c7e0d4db664a45b59c84967ac42d04f82a97b087, a rebase onto master with no other change. git range-diff marks the commit =: the three files it touches come out as the same blobs, the patch text is byte-identical, and the difference between the two heads is byte-identical to the difference between the two baselines, so the branch's contribution and the base move commute exactly. The three commits it picked up are eac0e554, c18e8442 and 929991db, none of which touches cle/backends/backend.py, cle/backends/macho/macho.py or tests/test_macho.py. The regression, suite and lint figures below were re-measured at this head and this baseline rather than carried over. Two later paragraphs are different and say so themselves: the GHC counts are carried over from an earlier head of this branch, and a set of angr-side figures is named as removed rather than re-keyed.

Measured configuration: a detached worktree of cle at the revision named, archinfo at master, this workspace's pinned Python 3.12 environment, nice -n 19, no xdist, -p no:randomly. The baseline arm is 929991dbb with only this branch's tests/test_macho.py laid over it, so the two arms differ by the production hunks alone. pylint is run with the CI configuration from angr/ci-settings, ci-image/conf/pylintrc, because cle declares no [tool.pylint] table of its own and the bare defaults score 1.5 to 2 points lower on each of these three files. Wall-clock timings are omitted: the host these ran on was carrying unrelated concurrent work and the durations say nothing a reader could reproduce.

  • Regression: python -m pytest tests/test_macho.py -k function_starts1 passed at this head; on the baseline it fails at tests/test_macho.py:285 with assert [] == [4294995644, ...][h.addr for h in hints] empty against the 36 entries tests/aarch64/dyld_ios15.macho records
  • Focused: python -m pytest tests at this head — 255 passed, 9 skipped. At the previous head on the previous baseline it was 243 passed, 9 skipped; the 12 new tests are master's growth, not this branch's, and they are accounted for exactly — 7 in tests/test_mips_n32.py, 2 in tests/test_ppc64_initial_rtoc.py, and one each in tests/test_extern.py, tests/test_pe.py and tests/test_te.py, with nothing collected at the old head that is missing at this one
  • Lint: pylint per changed file, this head against this baseline — cle/backends/backend.py 9.97 -> 9.97, cle/backends/macho/macho.py 10.00 -> 10.00, and tests/test_macho.py 9.94 -> 9.95. No file regresses

What the entries are, and why this is a hint source of its own rather than a symbol table. Measured on a GHC-produced x86-64 MH_DYLIB, sha256 1a0b6c5ea58c30af..., at cle b987439b3f3fdd10e54790958e8b58d1895fc5fc: the object records 8,968 LC_FUNCTION_STARTS entries, of which 4,255 name GHC closure entry code (*_info) and 129 name a GHC info table (_dsp_*_dsp), which is a data atom rather than a function. Those two counts are the argument for a separate FunctionHintSource, and they are properties of the file and of the code that reads it. The labels come from the producer's own naming rather than from anything angr or this branch computes. These three counts were not re-measured for this record: the sample is not an angr/binaries fixture, and they are supporting argument for the shape of the change rather than a claim about its correctness. They stand as measured at cle b987439b, an earlier head of this branch. Everything that changed under cle/backends/macho/ between that revision and this head is master's — two commits, a4fb8003 (#798) and f101f3c4 (#762), the only ones touching that directory between the branch's old base 45c6509c and its new one 929991dbb. a4fb8003 is the one that touches macho.py, in four hunks, each replacing a bare raise with one that carries a message. One of them is on _load_lc_function_starts's if address is None branch, which raised at b987439b as well, so a sample that produced counts never took it. read_uleb, MachO._unpack, MachO._read, MachO._load_segment, MachO._load_symtab and macho/symbol.py are byte-identical across the two revisions, so re-running the count here would give the same three numbers.

Removed rather than re-keyed. The CFGFast scoring beside that table — 17,024 blocks and 4,147 functions stock against 23,777 and 8,201 with the entries fed in unfiltered — was measured with angr pinned at 503b1be0663e65de225194eeb8e2cee6fa20d8d3, and both angr master and cle's own macho/binding.py have moved since, so it no longer describes any pair of revisions a reviewer would check out. The merge-order counts measured on the angr side (21 functions against 19 on x86_64/fauxware.macho, 28 against 27 on multi_arch/fauxware_macho_multiarch, and 48 passed 2 skipped over angr's two Mach-O test files) were pinned to the same angr revision and are removed with it. The pyright comparison and the local pre-commit run are removed in favour of the ci / Typecheck and pre-commit.ci - pr checks at this exact head, below.

The merge order itself is unchanged and does not rest on those numbers. angr/angr#6861 reads FunctionHintSource.FUNCTION_STARTS, which this pull request adds, so with the angr half first a Mach-O load raises AttributeError. This one lands first, or the two land together.

Caveats: the address is recorded as the linked virtual address and rebased with the object, matching the ELF FDE hints; a 32-bit ARM Mach-O records the Thumb bit in the entry, which this branch passes through untouched.

Hosted CI at head 4be4ea50ee6851d30385c6391f798718c5621051, read live 2026-08-30T18:03Z: 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. They are ci / Build, ci / Lint, ci / Typecheck, the ten ci / Test (0..9) shards, ci / Decompiler Snapshot Testing (0), ci / Publish Unit Tests Results, Test (Pyodide), Test windows-2022 and Test macos-15. All 18 come from one check suite of the single workflow run at this head, https://github.com/angr/cle/actions/runs/33312931719, which concluded success. The last three check angr/binaries out themselves and took it at master, the description naming no angr/binaries pull request.

Re-keyed 2026-09-04. Head is now 1bdd501bf5f549bab628d2a1bd949512b0af5361, on cle master 3812052df2ad284cd16684fb7b7eb66e8d14dc6d. The branch was rebased to clear a merge conflict. The conflict was two import lines, one in cle/backends/macho/macho.py and one in tests/test_macho.py, against imports #787 and #808 added; both sides are kept whole. The lines this branch adds and removes against its merge base are byte-identical before and after the rebase, so every figure above describes the same patch on a new base.

The sync: angr/angr#6861 sibling moved too, from 82d917f2f20e16c1725aff6b909b791c62c4d365 to e8c222b9466845ab10ad80ebc11dd12c696d7c37, rebased onto angr master. That was needed here: this branch's ci / Build installs angr from refs/pull/6861/head, and that branch still pinned pyvex==9.3.4.dev0 while pyvex master reports 9.3.5.dev0, so the install step would have failed for a reason outside this change.

Hosted CI at this head, read live 2026-09-04: 20 terminal checks, every one success, nothing outside success.

@angr-bot

Copy link
Copy Markdown
Member

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

@zardus

zardus commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Corpus measurement for this change, on AArch64 Mach-O. This is the whole affected population rather than a sample: all 21 objects that show the symptom, out of 686 that carry __eh_frame evidence at all.

Denominators are fixed by the files and were checked per object to be identical across configurations.

                                          cle master     this branch
fde_start_not_block_start                      1,195               3
  (of 7,345 FDE entries, both)
symbol_function_start_not_block_start          1,518              80
  (of 25,035, both)
missed_symbol_function_code_undiscovered         119               0
missed_fde_code                                  154             142
blocks                                       639,045         640,875
functions                                     71,498          74,655
function hints from CLE                            0          25,035
blocks in data / outside executable /
  overlapping / misaligned                    0 0 0 0         0 0 0 0

1,192 of 1,195 removed, 99.7%, and eighteen of the twenty-one objects go to zero.

On the oracle, since it matters here: the fde_* metric is scored against __TEXT,__eh_frame parsed directly from the file, not against LC_FUNCTION_STARTS and not against anything CLE reports, so the truth set cannot grow when this change supplies more hints — and it did not, staying at 7,345 throughout. It is worth stating plainly, though, that all 7,345 FDE starts are also LC_FUNCTION_STARTS entries, so this change hands angr a superset of the oracle's start set. That is the same relationship #777 has with .eh_frame on ELF, so the two results are comparable, but nobody should have to re-derive it.

Three things belong with the headline.

34 functions the file names are lost. Corrected below — it is 6. 2,478 functions disappear overall, and 2,444 of those (98.6%) are corroborated by no file table — angr-invented functions that merge away once the real starts are known, which is the intended effect. 34 are named in LC_FUNCTION_STARTS, nine of them with FDEs.

Correction, after taking those 34 to mechanism. The original text above claimed all 34 as a real regression. That overstates it. On the master side 27 of the 34 were already in kb.functions with zero blocks — hollow placeholders that had never been decoded — and a 28th held one block. Only 6 had substantial recovery before (6, 23, 31, 74, 87 and 140 blocks) and lost it. So this change destroys recovered code at 6 addresses, 0.024% of the 25,035 seeded entries, not 34; the other 28 are placeholders becoming nothing, which costs no analysis.

The 6 are lost in _process_irrational_function_starts; the 28 in drop_bad_functions, whose only escape hatch reads _function_addresses_from_symbols — a set that is empty on every one of these objects, because MachOSymbol.is_function returns False unconditionally (#769). Underneath that, the blocks end at AArch64 LSE atomics and LDAPR, which VEX does not decode. Both of those are angr-side and pyvex-side gaps rather than anything this change introduces; I am filing them separately.

One object regresses, from 1 instance to 3, with missed_fde_code going 15 to 20 on it. Its three residual addresses were all hinted and none is preceded by padding — in one case the swallowing block starts 24 bytes earlier on a real symbol whose last instruction is a branch to the very address that gets swallowed.

This masks angr/angr#6836 rather than fixing it. Under master, 1,031 of the 1,195 instances (86.3%) have a swallowing block that begins in nop alignment padding. Seeding the real start splits those blocks, which is why the metric collapses — but all 1,015 padding-start pseudo-functions that exist under master are still functions afterwards. Worth noting that AArch64 gets neither existing repair: is_arm_arch is name.startswith("ARM"), which AARCH64 does not satisfy, and _remove_redundant_overlapping_blocks is gated on ("X86", "AMD64", "MIPS32").

Of the added functions, 5,330 of 5,635 (94.6%) are in LC_FUNCTION_STARTS and 305 (5.4%) are corroborated by nothing — all in __TEXT,__text, none in stub sections, and reached by following newly seeded starts rather than being hint addresses themselves.

Oracle hazards were cleared rather than assumed: 0 of 1,195 flagged addresses are in an S_SYMBOL_STUBS section (21 such sections exist across these objects), and the 8 that carry N_ALT_ENTRY are also __eh_frame FDE starts, so they are genuine entries; nothing was discarded on either ground.

The pin and current master differ slightly on neighbouring metrics here, unlike the ELF case, but fde_start_not_block_start is 1,195 under both and matches the ledger, so these rows reproduce on today's master.

Objects are from a non-public corpus; counts and shapes only.

@zardus
zardus force-pushed the feature/macho-funcstarts branch from fb669d6 to 46cf6a4 Compare August 26, 2026 22:46
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full function-hint report for tests/aarch64/dyld_ios15.macho, loaded with cle.Loader(path, auto_load_libs=False), before and after this change. The script prints the parsed LC_FUNCTION_STARTS table, the hints the backend registers, and the bounds of __TEXT,__text.

Before — the table is parsed and then discarded, so the object exposes no hints:

cle at the merge base, 929991d
LC_LOAD_DYLINKER is not handled yet
LC_UUID is not handled yet
LC_SOURCE_VERSION is not handled yet
LC_CODE_SIGNATURE is not handled yet
cle: <cle at the merge base>/cle/__init__.py
fixture: tests/aarch64/dyld_ios15.macho
FunctionHintSource.FUNCTION_STARTS: None
backend: MachO
lc_function_starts count: 36
lc_function_starts[:5]: ['0x100006ebc', '0x100006ff8', '0x10000714c', '0x100007150', '0x100007154']
function_hints count: 0
hints with source FUNCTION_STARTS: 0
first 5 such hints: []
hint addrs == lc_function_starts: False
__TEXT,__text: vaddr=0x100006ebc memsize=0x998
all hints inside __text: True

After — all 36 entries are registered under the new source, and each one lands inside __TEXT,__text:

with this change, 4be4ea5
LC_LOAD_DYLINKER is not handled yet
LC_UUID is not handled yet
LC_SOURCE_VERSION is not handled yet
LC_CODE_SIGNATURE is not handled yet
cle: <cle with this change>/cle/__init__.py
fixture: tests/aarch64/dyld_ios15.macho
FunctionHintSource.FUNCTION_STARTS: 3
backend: MachO
lc_function_starts count: 36
lc_function_starts[:5]: ['0x100006ebc', '0x100006ff8', '0x10000714c', '0x100007150', '0x100007154']
function_hints count: 36
hints with source FUNCTION_STARTS: 36
first 5 such hints: [('0x100006ebc', 0, 3), ('0x100006ff8', 0, 3), ('0x10000714c', 0, 3), ('0x100007150', 0, 3), ('0x100007154', 0, 3)]
hint addrs == lc_function_starts: True
__TEXT,__text: vaddr=0x100006ebc memsize=0x998
all hints inside __text: True

The backend parses LC_FUNCTION_STARTS into lc_function_starts and then uses it
for nothing, so a Mach-O object hands its consumer no function hints at all while
an ELF one gets a hint for every FDE in .eh_frame. On a stripped image the table
is the only record of where the functions begin.

Each entry becomes a FunctionHint under a new FunctionHintSource.FUNCTION_STARTS.
It is a source of its own rather than an export or an unwind record because ld64
records the address of every atom it placed in an executable section, and a
producer's data atoms -- a Haskell closure's info table, a Swift offset table --
are atoms like any other, so the consumer has to be able to tell this table apart
from one that names only functions.

No size is recorded. The distance to the next entry is not a function length: one
function can hold several atoms, and a consumer that reads the gap as a size gets
a boundary the file never stated.
@zardus
zardus force-pushed the feature/macho-funcstarts branch from 4be4ea5 to 1bdd501 Compare September 4, 2026 04:06
@zardus

zardus commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Closing this: cle#810 landed the same change on 2026-09-04 as 0e77ade3.

Master now has FunctionHintSource.MACHO_FUNCTION_STARTS = 3, appends FunctionHint(address, 0, MACHO_FUNCTION_STARTS) for every entry in _parse_function_starts, and carries test_function_start_hints on the same fixture this branch used, tests/aarch64/dyld_ios15.macho, asserting the same thing plus a rebased-base check this branch did not make.

Rebasing was how that got established rather than assumed. The only conflict is the enum line, and resolving it leaves this branch's _register_function_start_hints appending a second hint for every address master already appended one for — duplicated hints and a failing test. Nothing here survives the rebase, so there is no residue worth carrying.

The one thing that did carry: cle#789 numbers its new EXCEPTION_DIRECTORY member 4 rather than 3, which was done to avoid colliding with this branch. That is still the right value, since 3 is now taken by merged code.

@zardus zardus closed this Sep 5, 2026
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.

Mach-O: LC_FUNCTION_STARTS is parsed and then discarded, and the backend registers no function hints

2 participants