#7105·angr

CFGFast seeds every Mach-O LC_FUNCTION_STARTS entry as a function start without checking the address

Author: zardusCreated Sep 6, 2026Updated Sep 6, 2026

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:

python
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_names

Its 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_object

VEX 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.

python
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 nop

0xa20d 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.