cfg_fast: add treat_functions_as_complete for incremental partial runs

An incremental CFGFast run that extends a partial model (e.g. resuming
from a single address after a graceful abort) completes normally, so
its post-analysis treated the recovered functions as complete and
concluded returning=False for functions that are merely truncated -
poisoning a later resume_state-driven full resume the same way an
un-reset abort would (122 bogus functions on dir_gcc_-O0). Callers can
now declare such runs with treat_functions_as_complete=False: bad
functions are not dropped, the batch function-feature pass is skipped,
returning=None stays undetermined, and transient returning=False
conclusions are reset, mirroring the abort-time behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XCpegthEVAhKNYJ3rfLd3
This commit is contained in:
Fish Wang 2026-07-24 04:22:10 +00:00 committed by Fish
parent 56b469d37a
commit 33aad6a116
2 changed files with 80 additions and 14 deletions

View file

@ -700,6 +700,7 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
jumptable_resolver_resolves_calls: bool | None = None,
retedges: bool = False,
drop_bad_funcs: bool = True,
treat_functions_as_complete: bool = True,
start=None, # deprecated
end=None, # deprecated
collect_data_references=None, # deprecated
@ -778,6 +779,10 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
aborted CFG recovery is passed in, recovery resumes on top of it: bytes covered
by existing nodes and memory data are treated as already scanned and will not
be re-lifted.
:param treat_functions_as_complete: Whether post-analysis may treat the recovered functions as complete
(drop bad functions, conclude returning=False for functions without
return paths). Set to False for incremental runs that extend a partial
model without completing it (see the resume recipes below).
:param resume_state: The resume_state captured by a previously aborted CFGFast instance. Its
unprocessed jobs and unresolved indirect jumps are re-created (with their
original function context) so that the recovery continues where it left off.
@ -810,7 +815,12 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
proj.analyses.CFGFast(model=partial_cfg.model, function_starts=[addr], start_at_entry=False,
symbols=False, function_prologues=False, eh_frame=False,
force_smart_scan=False, force_complete_scan=False)
force_smart_scan=False, force_complete_scan=False,
treat_functions_as_complete=False)
treat_functions_as_complete=False tells post-analysis that the resulting model is still partial: functions
must not be dropped as bad, and returning=False must not be concluded for (possibly truncated) functions -
such conclusions would poison a later full resume the same way an abort would without them being reset.
:return: None
"""
@ -993,6 +1003,7 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
self._transitory_unresolved_indirect_jumps = 0
self._drop_bad_funcs = drop_bad_funcs
self._treat_functions_as_complete = treat_functions_as_complete
self.stage: str = ""
@ -2725,6 +2736,10 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
def _post_analysis(self):
self.stage = "Analysis (Stage 2)"
# is the resulting model known to be partial? this is the case when the analysis was aborted, and when the
# caller declared upfront that this run only extends a partial model (treat_functions_as_complete=False)
model_is_partial = self.should_abort or not self._treat_functions_as_complete
if self.should_abort:
# the analysis was aborted before completion; capture the unprocessed jobs (preserving their function
# context) and the unresolved indirect jumps so that the caller may resume the analysis later by passing
@ -2736,11 +2751,13 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
)
self.unprocessed_job_addrs = {j.addr for j in unprocessed_jobs}
# returning=False conclusions reached mid-analysis may be transient: e.g., a PLT stub is concluded as
# non-returning while the job that would process its jump target (an always-returning extern function)
# is still sitting in the queue. An uninterrupted analysis corrects such conclusions once the callee is
# processed; since the correction will never come, reset them to undetermined so that neither this run's
# make_functions() nor a resumed analysis (which would suppress all return sites of calls to these
if model_is_partial:
# returning=False conclusions reached over a partial model may be transient: e.g., a PLT stub is
# concluded as non-returning while the job that would process its jump target (an always-returning extern
# function) is still sitting in the queue, or the callee simply lies in a not-yet-recovered part of the
# binary. An analysis over the complete binary corrects such conclusions once the callee is processed;
# since the correction will not come in this run, reset them to undetermined so that neither this run's
# make_functions() nor a future resumed analysis (which would suppress all return sites of calls to these
# functions) treats them as ground truth. Provably non-returning SimProcedures and syscalls keep their
# status.
for func_addr in list(self.functions.nonreturning_func_addrs()):
@ -2751,8 +2768,8 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
func.returning = None
self._calculate_progress_and_notify(skip_percentage=True)
if (self._force_complete_scan or self._force_smart_scan) and self._drop_bad_funcs and not self.should_abort:
# note: when the analysis is aborted, functions may be truncated (their outgoing edges were never
if (self._force_complete_scan or self._force_smart_scan) and self._drop_bad_funcs and not model_is_partial:
# note: when the model is partial, functions may be truncated (their outgoing edges were never
# processed), which makes them look like bad functions; do not drop them
self.drop_bad_functions()
self._calculate_progress_and_notify(skip_percentage=True)
@ -2781,12 +2798,12 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
self.make_functions()
self._calculate_progress_and_notify(skip_percentage=True)
# when the analysis is aborted, functions may be truncated and callees may be missing; the batch function-
# when the model is partial, functions may be truncated and callees may be missing; the batch function-
# feature analysis would incorrectly conclude returning=False for functions whose return sites or callees
# were simply never processed, and a future resumed analysis would then suppress their fall-through edges.
# skip it entirely on abort - the incrementally determined features stay in place, and a resumed analysis
# re-runs this pass with complete knowledge.
if not self.should_abort:
# skip it entirely for partial models - the incrementally determined features stay in place, and a full
# analysis re-runs this pass with complete knowledge.
if not model_is_partial:
self._analyze_all_function_features(all_funcs_completed=True)
# Scan all functions, and make sure all fake ret edges are either confirmed or removed
@ -2833,8 +2850,8 @@ class CFGFast(ForwardAnalysis[CFGNode, CFGNode, CFGJob, int, object], CFGBase):
# Finally, mark endpoints of every single function
f.mark_nonreturning_calls_endpoints()
if not self.should_abort:
# keep returning=None (undetermined) for truncated functions when the analysis is aborted
if not model_is_partial:
# keep returning=None (undetermined) for truncated functions when the model is partial
for func_addr in sorted(self.functions.unknown_returning_func_addrs()):
f = self.functions.get_by_addr(func_addr)
# Scan all functions, and make sure .returning for all functions are either True or False

View file

@ -192,6 +192,55 @@ class TestCfgfastAbortResume(unittest.TestCase):
# unresolved indirect jumps captured at abort time must be re-processed on resume
self._assert_resume_state_exact("cfg_switches", (5, 50), allow_unreachable_orphan_extras=True)
def test_full_resume_exact_after_strict_resume(self):
# a strict resume-from-address over a partial model must declare treat_functions_as_complete=False;
# otherwise its post-analysis concludes returning=False for truncated functions, which poisons a later
# full resume (return sites of calls to those functions get suppressed and gap scanning promotes the
# orphaned bytes to bogus functions)
path = os.path.join(test_location, "x86_64", "dir_gcc_-O0")
ref = angr.Project(path, auto_load_libs=False)
ref.analyses.CFGFast(normalize=True)
ref_funcs = set(ref.kb.functions)
orig_interval = cfg_fast_mod.PROGRESS_NOTIFY_INTERVAL
cfg_fast_mod.PROGRESS_NOTIFY_INTERVAL = 0
try:
proj = angr.Project(path, auto_load_libs=False)
count = [0]
def cb(percentage, text=None, cfg=None, **kwargs): # pylint:disable=unused-argument
if cfg is not None:
count[0] += 1
if count[0] >= 20:
cfg.abort()
partial = proj.analyses.CFGFast(normalize=True, progress_callback=cb)
assert partial.should_abort
missing = sorted(ref_funcs - set(proj.kb.functions))
assert missing
proj.analyses.CFGFast(
model=partial.model,
function_starts=[missing[0]],
normalize=True,
treat_functions_as_complete=False,
**RESUME_ONLY_KWARGS,
)
proj.analyses.CFGFast(
model=partial.model,
start_at_entry=False,
resume_state=partial.resume_state,
normalize=True,
)
got = set(proj.kb.functions)
assert got == ref_funcs, (
f"{len(got)} funcs vs reference {len(ref_funcs)}; "
f"extra={sorted(hex(a) for a in got - ref_funcs)[:10]}, "
f"missing={sorted(hex(a) for a in ref_funcs - got)[:10]}"
)
finally:
cfg_fast_mod.PROGRESS_NOTIFY_INTERVAL = orig_interval
def test_second_pass_idempotent(self):
path = os.path.join(test_location, "x86_64", "fauxware")