CFGFast seeds every Mach-O LC_FUNCTION_STARTS entry as a function start without checking the address
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS
Description
CFGBase._load_func_addr_and_names_from_hints (cfg_base.py:1097) admits every CLE
function-hint source except EH_FRAME:
addrs_and_names = set()
for function_hint in self._binary.function_hints:
if function_hint.source != FunctionHintSource.EH_FRAME:
addrs_and_names.add((function_hint.addr, function_hint.name))
return addrs_and_namesIts only consumer is cfg_fast.py:1750, in _pre_analysis, which puts each of those
addresses straight into starting_points. Nothing in between looks at what is at the
address.
angr/cle#810 (merged 2026-09-04, cle master 0e77ade3) made LC_FUNCTION_STARTS one of
those sources: macho/macho.py:804 appends a
FunctionHint(address, 0, FunctionHintSource.MACHO_FUNCTION_STARTS) for every entry in
the table. So every entry the linker wrote is now a definite function start.
That table is not a list of function starts. ld64 records atoms, data atoms among the
code included, and it records entries whose bytes angr cannot lift. Over the Mach-O
fixtures in angr/binaries at 003e82a2b that carry a table — 22 objects, 3,109
entries — master turns 3,104 of them into functions, with nothing checked on any of them.
What it costs on the public fixtures today
One spurious function, and the shape is not the obvious one. On
tests/armhf/FileProtection-05.armv7.macho the table records 0xa20d, which is
__ZL30arclite_uninitialized_functionv — a real, symbol-named entry whose whole body is
fe de, Thumb udf #0xfe:
0xa208 fa e7 b ...
0xa20a 00 bf nop
0xa20c fe de udf #0xfe <- table entry, __ZL30arclite_uninitialized_functionv
0xa20e 00 bf nop <- padding to the next entry
0xa210 90 b5 push {r4, r7, lr} <- table entry, __ZL12cxxConstructP11objc_objectVEX lifts nothing at 0xa20d, so the seeded function has size 0 and occupies no bytes.
The linear scan then finds 0xa20e unoccupied and opens a second function at 0xa20f,
two bytes long, whose body is the nop.
import angr
p = angr.Project("binaries/tests/armhf/FileProtection-05.armv7.macho", auto_load_libs=False)
cfg = p.analyses.CFGFast()
print(cfg.kb.functions[0xa20d].size) # 0
print(0xa20f in cfg.kb.functions) # True, and its body is one nop0xa20d is in main_object.lc_function_starts; 0xa20f is not. With
force_smart_scan=False, force_complete_scan=False the function at 0xa20f disappears
and the one at 0xa20d stays, so it takes both the unchecked seed and the linear scan to
produce it.
Only 1 of the 3,104 seeded entries ends up a size-0 function, so this is one bad function
on the public corpus and not a large loss. The exposure is the policy — nothing is
checked — and #6921 is the other half of it, where the same unchecked hints are then
deleted by drop_bad_functions with nothing protecting them.
Two repairs that were measured and are worse than doing nothing
Both look right. Measured on angr master 87411a719 with cle master 0e77ade3, one
default CFGFast() per object over every Mach-O file in angr/binaries at 003e82a2b
(27 found by magic number, 25 of which load).
1. Validate the bytes, and consume the hints after the worklist drains. This is what
#6861 did, and why it was closed. It gives up 211 function starts and gains 6; 207 of the
211 are table entries and 166 carry a symbol. The byte check is not what loses them:
instrumented over the same 25 objects it was called 2,451 times, refused once, and was
never called on any of the 211. 204 of them were skipped one line earlier, at
self._seg_list.is_occupied(start_addr) — that change moved the hints out of
_pre_analysis's starting points into a pop in _job_queue_empty, and by the time that
pop runs the linear sweep has already covered the address. 0x10000584c on
armhf/FileProtection-05.arm64.macho is an ordinary stp prologue named
__ZL32__arclite_objc_allocateClassPairP10objc_classPKcm, and under that change it ends
up a block inside the function at 0x100005528. A validated hint has to be consumed
while the address can still open a function, not after the worklist drains.
2. Drop MACHO_FUNCTION_STARTS from the admitted sources. This is the option #6948's
DEFINITE_FUNCTION_HINT_SOURCES allowlist took until 2026-09-06, when it was
changed to name the source. Simulated on master over the
same 25 objects, it gives up 278 function starts and gains 55; 269 of the 278 are table
entries and 221 carry a symbol. It does remove the 0xa20f nop, by throwing the whole
table away.
A filter on hints cannot reach the nop on its own in any case, because 0xa20f is not
a table entry. It exists because the entry at 0xa20d is seeded and then occupies
nothing.
Environment
angr 87411a719, cle 0e77ade3, binaries 003e82a2b, measured 2026-09-06.
Source: angr/angr