Mach-O: LC_FUNCTION_STARTS is parsed and then discarded, and the backend registers no function hints
- Dominant language
- Python
- Stars
- 485
- Forks
- 135
- Avg merge
- 2d 2h
- Merged PRs (30d)
- 15
Description
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS
### Description
The Mach-O backend registers no function hints. `Backend.function_hints` stays
empty for every Mach-O object, while the ELF backend fills it from `.eh_frame`
FDEs (`cle/backends/elf/elf.py:225,602-620`) and the PE backend from both
`.eh_frame` and the export table (`cle/backends/pe/pe.py:448-452,596-600`).
`grep -rn FunctionHintSource cle/backends/macho` finds nothing. (On ELF that
call site is inside the `self.loader._load_debug_info` branch, which is #744 —
so on ELF the hints exist but are usually not collected, while on Mach-O there
is nothing to collect in the first place. The two are independent.)
The table it would come from is already parsed and already discarded.
`MachO._load_lc_function_starts` (`cle/backends/macho/macho.py:753-785`)
ULEB-decodes `LC_FUNCTION_STARTS` into `self.lc_function_starts`, and
`grep -rn lc_function_starts` across cle and angr finds no reader outside the
function that writes it. The same is true of `self.lc_data_in_code`.
The consumer side needs no change: `CFGBase._load_func_addr_and_names_from_hints`
(`angr/analyses/cfg/cfg_base.py:1069-1080`) takes every hint whose source is not
`EH_FRAME`, with no backend type test, so a Mach-O backend appending
`FunctionHint(addr, size, , name)` would reach CFGFast
as it stands.
### How much the table knows
Parsing the container directly — not through CLE — over a stratified sample of
220 Mach-O objects that CLE loads today (aarch64 and x86_64, `MH_EXECUTE` and
`MH_DYLIB`, from Homebrew macOS builds):
| evidence in the file | objects | entries |
| --- | --- | --- |
| `LC_FUNCTION_STARTS` | 217 (98.6%) | 477,636 function entries, median 529 per object |
| `__eh_frame` FDEs | 39 (17.7%) | 19,090 ranges |
| `__debug_line` | 0 | — |
`LC_FUNCTION_STARTS` is by far the richest source, and it is the one nothing
reads. `__eh_frame` — the source the ELF path uses — is present on under a fifth
of linked Mach-O images, because Apple's toolchain emits `__unwind_info` compact
unwind instead. The three objects with no `LC_FUNCTION_STARTS` at all are
Go-linked (`__gopclntab`, `__noptrbss`); Go's internal linker emits neither
function starts nor `N_SECT` symbols in `__text`, only 72 undefined imports.
### What CFGFast currently does with those entries
Scoring `CFGFast(normalize=True, data_references=False,
resolve_indirect_jumps=True)` against the entries, over the 148 objects of that
sample measured so far — 1,249,133 blocks, 79,059 function entries:
| | objects | instances |
| --- | --- | --- |
| entry no block covers at all | 47 of 148 | 7,332 of 79,059 |
| entry covered by a block that starts earlier | 40 of 148 | 5,544 of 79,059 |
The second row is mostly alignment padding: on an aarch64 `MH_EXECUTE` the flagged
entries sit immediately after four `nop`s and angr's block starts on the first
`nop`, so a hint would move the block boundary onto the function.
The first row needs a caveat before anyone treats it as 7,332 lost functions.
"No block covers the recorded entry" is not "angr did not find the function":
some producers put a metadata header at the entry and start instructions after
it. On one x86_64 dylib with 4,884 entries, each beginning with two eight-byte
words, 962 entries are uncovered and 477 of those have covered code sixteen
bytes later — so roughly half of that object's contribution is an entry-address
disagreement rather than undiscovered code. The rest are real: on
`969b5fc564da775ef1bea8d7f9a010cadb9b810d329f95e1099d131f898fadf5` (aarch64,
`MH_EXECUTE`), 93 of 1,718 entries are uncovered and the ones checked by hand are
ordinary prologues — `sub sp, sp, #0x40 ; stp x22, x21, [sp, #0x10] ; …` at
`0x100008a7c`, `0x10000a42c` — that nothing reaches because they are referenced
only from data.
### Two things to decide
1. **Whether hints from `LC_FUNCTION_STARTS` are net positive.** They would
recover functions reached only through data, and would fix the padding-start
boundaries. They would also create a function at every entry that is a
metadata header rather than code, and at entries like the two `nop` bytes at
the end of `__text` on
`c98705d1ecd0e8aacb1dd4e2443c43c6ecf1e157e6c01098188bd7659f082f01`. I have not
measured the second effect, because measuring it means making the change.
2. **Whether `__unwind_info` is worth parsing too.** It is on 158 of the 220
sampled objects (71.8%) where `__eh_frame` is on 39 (17.7%), and every object
with an `__eh_frame` also has an `__unwind_info`, so compact unwind strictly
dominates the source the ELF path uses. Its second-level pages carry function
offsets, and unlike `LC_FUNCTION_STARTS` it would give ranges rather than
bare starts. 62 objects have neither section.
### Adjacent, and much smaller
`_load_lc_data_in_code` (`cle/backends/macho/macho.py:743-751`) cannot ever
append anything:
```python
_, _, dataoff, datasize = self._unpack("4I", f, off, 16)
for i in range(dataoff, datasize, 8):
```
`datasize` is a length being used as the stop bound, so with the usual
`dataoff > datasize` the loop body never runs and `lc_data_in_code` stays empty;
it should be `range(dataoff, dataoff + datasize, 8)`. The entries are also file
offsets and would need converting to addresses before they were useful. Since
nothing reads the list, this changes no result today — but `LC_DATA_IN_CODE` is
exactly the table that says which parts of `__text` are jump tables and literal
pools, so it is worth having correct if the hint work above happens.
### Environment
cle at `master` b58ea02a, angr 0c293dc0, as of 2026-08-16.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.