CFGFast: Use a Mach-O function-start table for what it does not otherwise find

A linker's function-start table is the only record of where a stripped image's
functions begin, and CFGFast had no use for one. Fed in unfiltered it is worse
than nothing: ld64 records the address of every atom it placed in an executable
section, so a Haskell closure's info table and a Swift offset table are entries
beside the functions, and seeding those puts function heads on data.

Consume FunctionHintSource.FUNCTION_STARTS hints the way the .eh_frame ones are
consumed -- after the worklist drains, skipping an address already decoded --
with one added test: the bytes at the address have to be a range something enters
as code. The decode runs over the distance to the next recorded address, which is
not a size and is never used as one, and stops where a real decoder would: at a
byte capstone refuses, at a word it names undefined, and at an instruction
encoded entirely in zero bytes, which is what a table is padded and filled with.
The range is code if the decode reaches an instruction after which control does
not fall through, or consumes the extent without one of those stops. Neither half
of that is sufficient alone -- an info table decodes as x86 arithmetic to the end
of a range it fits exactly, and an AArch64 function ending in a call that does
not return reaches no terminator.

An architecture whose terminators are not established here gets no hints at all,
rather than a decision made with another architecture's set.
This commit is contained in:
Yan 2026-08-17 06:40:41 +00:00
parent 503b1be066
commit e272212d6f
3 changed files with 142 additions and 1 deletions

View file

@ -232,6 +232,7 @@ class CFGBase(Analysis):
self._function_addresses_from_symbols = self._load_func_addrs_from_symbols()
self._function_addresses_from_eh_frame = self._load_func_addrs_from_eh_frame()
self._function_addr_and_names_from_hints = self._load_func_addr_and_names_from_hints()
self._function_addresses_from_function_starts = self._load_func_addrs_from_function_starts()
# Cache if an object has executable sections or not
self._object_to_executable_sections = {}
@ -1096,10 +1097,27 @@ class CFGBase(Analysis):
addrs_and_names = set()
for function_hint in self._binary.function_hints:
if function_hint.source != FunctionHintSource.EH_FRAME:
if function_hint.source not in (FunctionHintSource.EH_FRAME, FunctionHintSource.FUNCTION_STARTS):
addrs_and_names.add((function_hint.addr, function_hint.name))
return addrs_and_names
def _load_func_addrs_from_function_starts(self) -> set[int]:
"""
Get the addresses that a linker's function-start table records.
Mach-O's LC_FUNCTION_STARTS names every atom ld64 placed in an executable section. That
includes the data atoms a producer emits among its functions - a Haskell closure's info
table, a Swift offset table - so these addresses are candidates and not function starts.
:return: A set of addresses that may be functions.
"""
return {
function_hint.addr
for function_hint in self._binary.function_hints
if function_hint.source == FunctionHintSource.FUNCTION_STARTS
}
#
# Analyze function features
#

View file

@ -85,6 +85,22 @@ VEX_IRSB_MAX_SIZE = 400
# the minimum interval (in seconds) between two consecutive progress notifications
PROGRESS_NOTIFY_INTERVAL = 0.05
# Instructions after which control does not fall through, used to tell the code a linker's
# function-start table records from the data atoms it records beside it. Keyed by architecture: one
# whose terminators are not listed here gets no function-start hints, rather than a decision made
# with another architecture's set. Privileged and far-transfer instructions are deliberately absent,
# because no compiler emits them into a user-space function, so finding one is evidence that the
# bytes are a table and not evidence of the end of a function.
FALLTHROUGH_TERMINATORS = {
"X86": frozenset({"ret", "jmp", "ud2"}),
"AMD64": frozenset({"ret", "jmp", "ud2"}),
"AARCH64": frozenset({"ret", "retaa", "retab", "b", "br", "braa", "brab", "braaz", "brabz", "brk"}),
}
# What capstone emits where it has no instruction for the bytes. On a variable-width instruction set
# it stops; on a fixed-width one it does not, and every undecodable AArch64 word comes back as udf.
UNDEFINED_MNEMONICS = frozenset({"udf", "invalid", "undefined", ".byte"})
l = logging.getLogger(name=__name__)
@ -464,6 +480,7 @@ class CFGJobType(Enum):
IFUNC_HINTS = 3
DATAREF_HINTS = 4
EH_FRAME_HINTS = 5
FUNCTION_START_HINTS = 6
class CFGJob:
@ -884,6 +901,8 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
self._write_addr_to_run = defaultdict(list)
self._remaining_eh_frame_addrs: list[int] | None = None
self._remaining_function_start_addrs: list[int] | None = None
self._function_start_extents: dict[int, int] = {}
self._remaining_function_prologue_addrs: list[int] | None = None
self._used_function_prologue_addrs: set[int] | None = None
self._ptr_hints: SortedDict | None = None
@ -1716,6 +1735,9 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
if self._use_eh_frame:
self._remaining_eh_frame_addrs = sorted(self._function_addresses_from_eh_frame, reverse=True)
self._function_start_extents = self._measure_function_start_extents()
self._remaining_function_start_addrs = sorted(self._function_start_extents, reverse=True)
if self._use_function_prologues and self.project.concrete_target is None:
func_addrs_from_prologs = self._func_addrs_from_prologues()
if self._ptr_hints:
@ -2338,6 +2360,19 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
self._register_analysis_job(eh_addr, job)
return
if self._remaining_function_start_addrs:
while self._remaining_function_start_addrs:
start_addr = self._remaining_function_start_addrs.pop()
if self._seg_list.is_occupied(start_addr):
continue
if not self._function_start_holds_code(start_addr):
continue
job = CFGJob(start_addr, start_addr, "Ijk_Boring", job_type=CFGJobType.FUNCTION_START_HINTS)
self._insert_job(job)
self._register_analysis_job(start_addr, job)
return
if self._use_function_prologues and self._remaining_function_prologue_addrs:
while self._remaining_function_prologue_addrs:
prolog_addr = self._remaining_function_prologue_addrs.pop()
@ -2875,6 +2910,71 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
# Methods to get start points for scanning
def _measure_function_start_extents(self) -> dict[int, int]:
"""
How far each address a linker's function-start table records reaches before the next one.
The distance to the next recorded address is not a function size: the table records atoms,
and one function can hold several. It is how many bytes there are to read when asking whether
anything ever entered the address as code, where reading too far costs an answer and can
never place a boundary. Addresses outside an executable section are dropped, along with every
address on an architecture whose terminators are not established.
:return: A map from address to the number of bytes to decode there.
"""
if not self._function_addresses_from_function_starts:
return {}
if self.project.arch.name not in FALLTHROUGH_TERMINATORS or not self.project.arch.capstone_support:
return {}
ordered = sorted(self._function_addresses_from_function_starts)
extents = {}
for index, addr in enumerate(ordered):
if not self._inside_regions(addr):
continue
section = self.project.loader.find_section_containing(addr)
if section is None or not section.is_executable or section.only_contains_uninitialized_data:
continue
end = section.vaddr + section.memsize
if index + 1 < len(ordered) and ordered[index + 1] < end:
end = ordered[index + 1]
if end > addr:
extents[addr] = end - addr
return extents
def _function_start_holds_code(self, addr: int) -> bool:
"""
Whether the bytes a linker recorded a function start at are a range something enters as code.
The decode runs forward from the address and stops where a real decoder would: at a byte
capstone refuses, at a word it names undefined, and at an instruction encoded entirely in
zero bytes, which is what a table is padded and filled with. The range is code if the decode
reaches an instruction after which control does not fall through, or consumes the whole
extent without one of those stops. Neither half is sufficient alone: a Haskell info table
decodes as x86 arithmetic to the end of a range it fits exactly, and an AArch64 function that
ends in a call which does not return reaches no terminator.
"""
extent = self._function_start_extents.get(addr)
if not extent:
return False
try:
data = self.project.loader.memory.load(addr, extent)
except KeyError:
return False
terminators = FALLTHROUGH_TERMINATORS[self.project.arch.name]
consumed = 0
for insn_addr, size, mnemonic, _operands in self.project.arch.capstone.disasm_lite(data, addr):
offset = insn_addr - addr
if size <= 0 or mnemonic in UNDEFINED_MNEMONICS or not any(data[offset : offset + size]):
return False
if mnemonic in terminators:
return True
consumed = offset + size
return consumed == len(data)
def _func_addrs_from_prologues(self):
"""
Scan the entire program image for function prologues, and start code scanning at those positions

View file

@ -11,6 +11,7 @@ import random
import unittest
import archinfo
from cle.backends.backend import FunctionHintSource
import angr
from angr.analyses.cfg.indirect_jump_resolvers import mips_elf_fast
@ -968,6 +969,28 @@ class TestCfgfast(unittest.TestCase):
assert "_accepted" in func_names
assert "_authenticate" in func_names
def test_macho_function_starts_seed_functions(self):
# LC_FUNCTION_STARTS records the address of every atom ld64 placed in an executable section.
# With every other source of starting points turned off, the table is all CFGFast has.
path = os.path.join(test_location, "aarch64", "dyld_ios15.macho")
proj = angr.Project(path, auto_load_libs=False)
recorded = {
hint.addr
for hint in proj.loader.main_object.function_hints
if hint.source == FunctionHintSource.FUNCTION_STARTS
}
assert len(recorded) == 36
cfg = proj.analyses.CFGFast(
symbols=False,
start_at_entry=False,
function_prologues=False,
force_smart_scan=False,
force_complete_scan=False,
data_references=False,
)
assert recorded <= set(cfg.kb.functions)
def test_syscalls_resolved_with_constant_propagation(self):
for arch in ["x86", "x86_64"]:
with self.subTest(arch=arch):