Skip to content

fix(aarch64): candidate-quality follow-ups to the BTI landing-pad series - #311

Merged
danielplohmann merged 10 commits into
danielplohmann:masterfrom
r0ny123:upstream-aarch64-candidate-quality
Sep 8, 2026
Merged

fix(aarch64): candidate-quality follow-ups to the BTI landing-pad series#311
danielplohmann merged 10 commits into
danielplohmann:masterfrom
r0ny123:upstream-aarch64-candidate-quality

Conversation

@r0ny123

@r0ny123 r0ny123 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Six more changes to AArch64 candidate discovery, all in the same two scans, continuing directly from #310.

This branch contains #310's three commits as well — its work is what the first change here builds on, and the two cannot be separated without rewriting both. Review the seven commits after fix(aarch64): stop the refused-pad walk at a trap, or take #310 first and this rebases to just those.

1. Both interior tests read a stale snapshot

getFunctionStartCandidates() is taken before analysis begins and gap analysis never adds to it, so a function the gap scan discovered is in code_map and absent from that set. Both interior tests in the AArch64 candidate manager read that difference as evidence of an interior:

  • _gapRunFlowsIntoInterior treats a branch to such a function as a branch into somebody's body, so a run of branch veneers whose targets the gap scan found gets suppressed;
  • _isLikelyInteriorBtiCandidate refuses a landing pad at an address the gap scan already recovered as a function.

Both now ask disassembly.functions too. On the bundled osx.frostyferret fixture this alone recovers 8 more functions, every one of them an address LC_FUNCTION_STARTS declares, with nothing lost.

2. The two gap-run walks stop at different words, and that is correct

_gapRunFlowsIntoInterior and _endOfRefusedLandingPadRun share _GAP_RUN_LIMIT, but since #310 the second also stops at brk/hlt/udf. That reads like an omission — AArch64Backend.analyzeInstruction ends a function on all three through END_INS — and it is not.

The two walks answer different questions. _endOfRefusedLandingPadRun decides how far to skip, and one instruction too far steps over a real entry and loses it outright, so it must stop at the earliest boundary it can defend. _gapRunFlowsIntoInterior decides whether to suppress; nothing is skipped either way and reading further only gathers more evidence about the same candidate. A trap is not a boundary the image declares — mid-body it is a bounds check or a __builtin_trap — so the words behind it still belong to the enclosing routine.

Counted rather than argued: adding is_trap to the suppression walk releases 12 candidates on the 11-cell ARM64 Mach-O corpus (two sites, each swept one word at a time), 0 of them true starts, and 0 candidates on the 72-cell built AArch64 ELF corpus. Both released sites sit 0x15c and 0x1d8 inside functions the ground truth declares.

No behaviour change — the comment on each walk now states why its terminator set is what it is and points at the other, plus a new test file that pins the asymmetry so a future reader does not "fix" it.

3. A function may open with a conditional branch

The gap scan refused any candidate whose first word was one:

if is_conditional_branch(word):  # a function never opens with a cond branch

The claim is false. At -O2 a compiler hoists an argument check above the frame setup routinely:

cbz   x0, .Lreturn_null      <- the entry
stp   x29, x30, [sp, #-32]!  <- what got booked instead
mov   x29, sp

On the 72-cell AArch64 ELF corpus this shape occurs at 230 routines whose entry is one word before the start that was booked, 203 of them opening on a conditional branch.

Removing a refusal reduces false positives here, which is worth explaining. The refusal did not merely lose the entry: the frame setup one word in is 4-aligned and nothing calls it, so the alignment floor keeps it out of the first pass, and the start booked in the entry's place came from the same gap scan four bytes past the entry it had just stepped over — with the guard's own branch target often becoming a second one. Recovering the entry deletes both wrong starts.

What the refusal was buying, on those 72 cells: 558 addresses refused, of which 462 are function starts the ground truth names. Four in five of what it held back was real.

corpus cells truth TP FP ΔTP ΔFP
Built C/C++ AArch64 ELF 72 60,326 59,167 → 59,516 3,023 → 2,484 +349 −539
ARM64 Mach-O (LC_FUNCTION_STARTS) 11 2,753 2,532 → 2,538 388 → 382 +6 −6
Built Go, seven platforms 47 166,337 165,627 → 165,627 9,816 → 9,812 0 −4

Not one cell in any of the three gains a false positive. One cell of 83 loses recall and is worth naming rather than averaging away: on lz4_gcc-arm64_O1, recovering 0xcf48 lets its analysis run on through the four truth functions after it — net −2 TP and −2 FP on that cell. That is the merge hazard of recovering an earlier entry, not a property of the guard.

The other call site of is_conditional_branch, in locateAddressRefCandidates, is untouched and correct: it clears register provenance at a control-flow edge, which a conditional branch is whatever may open a function. Its docstring carried the false claim and now states the encoding fact it is actually for.

4. Skip the address scan when metadata already named the functions

locateAddressRefCandidates exists to reach entries no call names — a pointer handed to a callback, a table walked at run time. An image that ships a full function map has none of those left over: the symbol pass consumes the map, every entry is already a candidate, and what the scan then produces is the addresses that are materialized but are not entries. On a Go binary that is thousands of them for no recovered function at all.

The question is asked of the image rather than of its container or its language, both of which have been the wrong variable before: of the addresses this image's own bl instructions call, how many did metadata name before this pass ran? A stripped C or C++ object answers near zero however it was linked; one carrying a complete map answers near one whoever compiled it.

bl targets are the denominator because they are the one population both kinds of image have in proportion to how many functions they contain, and because they are known at exactly this point — the scan that resolves them has just finished. The threshold sits at 0.95, in the upper half of a bimodal distribution with a wide empty interval between the modes, because the two errors are not symmetric: running the scan when it is useless costs precision, skipping it when it is not costs recall.

A rate needs a sample, so an image resolving fewer than 512 bl targets is left to the scan whatever share of those few metadata named — one corpus image resolves four in the whole binary, and metadata naming all four says nothing.

5. Turn the Mach-O address-materialization scan on

USE_MACHO_ADDRESS_REF_CANDIDATES shipped off because the scan was all cost on images with a function map. With the coverage gate above deciding that per image, it can default on: on the bundled osx.frostyferret fixture it reaches 2 more starts the linker declares and removes one address that is not in the table.

What the bundled fixtures do

Both are updated in a single trailing commit so the movement can be read in one place.

  • osx.frostyferret (ARM64 Mach-O): the primary pass recovers 256 functions where it recovered 246, and 128 of the linker's own LC_FUNCTION_STARTS entries where it recovered 117. Eleven table entries gained, one non-table address (0x100004c48) dropped. Every gain is an address the linker itself declares; the single loss is not, so it is a false positive going.
  • aarch64_static (ELF): 278 functions either way, one instruction more, and the routine at 0x40DF34 is reported at 0x40DF30. That word is a hoisted cbz x14. 0x40DF30 is 16-aligned and follows the previous function's ret and its padding nop; 0x40DF34 is 4-aligned and mid-line; neither carries an inbound direct branch. This is the one baseline movement that is a judgement rather than a measurement, and it is called out rather than folded into a count.

Tests

  • tests/testAArch64GapRunTerminators.py — nine tests in two classes pinning the terminator asymmetry, behavioural and walk-level, mutation-checked by adding or is_trap(word) to the suppression walk and watching the behavioural assertion and one walk-level assertion turn red while every control stays green.
  • tests/testAArch64HoistedGuardEntry.py — the hoisted guard recovered as the entry with the whole recovered set compared, not one address; a control that the same word class after ordinary code inside an already-claimed routine is still not booked (the case the guard existed for); and a control that a conditional branch opening an unclaimed gap now books the block's first word rather than the word behind it.
  • tests/testAArch64MetadataCoverageGate.py — the gate over synthetic images at both sides of the threshold and below the sample floor, plus the reset behaviour that matters for a manager driven one pass at a time.

Validation

ruff check / ruff format --check pass, ty check exit 0, full suite green on this branch rebased onto current master, diff coverage 100% on the changed source lines.

Branch shape

Rebased onto current master (395c88d) as the flat ten commits, dropping the three pre-squash copies of #310 and the fix(ci) commit that #302 made redundant. This is the exact set and order you listed.

The rebase is content-neutral: the tree is 38b27ba, byte for byte what the merge-based version resolved to, so the measurements above stand unchanged and nothing needs re-running. ruff check, ruff format --check and ty 0.0.74 are clean on it; the full suite was 1918 passed / 1 skipped / 2593 subtests on this identical tree.

One thing to know if you take #309 first: #312 still carries copies of these ten, so it will hit the same squash artifact once this lands, in the same way and with the same fix. Its content reduces to a single commit either way.

Changelog note

Flagging this for whoever writes the release entry, since it is the one thing here that changes behaviour for every consumer rather than only for the images it measures on:

USE_MACHO_ADDRESS_REF_CANDIDATES now defaults on. The AArch64 adr/adrp address-materialization scan is gated per image by metadata call-target coverage rather than per container, so it no longer runs on images whose metadata already names their functions. Measured on the bundled 11-sample ARM64 Mach-O corpus: +31 true starts, −5 false ones, TPR 86.916% → 88.424%, with no cell losing recall.

I have not touched CHANGELOG.md here — its entries are per released version and there is no unreleased section, so writing one would mean picking a version number, which is yours to decide at release time.

@danielplohmann

Copy link
Copy Markdown
Owner

Reviewed and wanted — see #309 (comment) for the details, since the rebase pass for #309, this and #312 is being tracked in one place.

Short version: #310 landing as a squash means this branch's copies of its three commits (9233624, fa31fe1, e8626b7) no longer read as ancestors of master and collide with the squashed version. Dropping those three leaves your own ten, which cherry-pick onto current master without a conflict. Measured +31 true positives and −5 false ones on the bundled ARM64 Mach-O corpus with no cell losing recall, and the hoisted-guard change is confirmed on aarch64_static_xored at the instruction level. Nothing outstanding but the rebase.

danielplohmann added a commit that referenced this pull request Sep 7, 2026
Five small corrections noticed while reviewing #302 through #311, each one
something a merged PR left slightly short of right and none worth a PR of
its own. Collected in #317 as they were found.

`README`: the address-space section told a consumer the backend is named
"in two places, report.architecture and metadata.language". The second is
wrong in the direction the section exists to prevent. metadata.language is
a source-language score map -- a single decisive entry on the managed
backends ({'.net': 1.0}, {'dalvik': 1.0}) but a distribution on the native
ones that carries a .net score -- so branching on .net appearing in it can
read a native report as managed and then treat virtual addresses as file
offsets. The README already stated the score-map contract 130 lines below,
so it contradicted itself. report.architecture is authoritative and is now
named as the field to branch on.

`smda_instruction_matches_capstone` gets its `-> bool` back. #302 dropped
the annotation to satisfy ty 0.0.74's unsound-return-statement, but the
cause is that capstone_instruction is untyped, so `.size` is Unknown and
the comparison infers Unknown. bool() around the return makes the declared
type true instead of removing the declaration.

`.pre-commit-config.yaml` moves to `id: ruff-check`. At ruff-pre-commit
v0.16.x the hook reports itself as "ruff (legacy alias)"; astral renamed it,
and the alias will presumably go in a future major.

`GAP_SEQUENCES` gains a note that it is read in seven places outside the
alignment cut, so an encoding added there changes all padding-aware
discovery rather than one caller -- which is what makes a measurement over
a change to that table hard to attribute.

`test_late_tailcall_does_not_rebuild_queue` says what it is for now that
#307 removed the scoring path it guarded, so a future reader does not have
to work out whether a trivially-true assertion is load-bearing.

Two items from #317 are deliberately not here, because both need work
upstream of the annotation rather than the annotation itself, and #317 now
records what each one runs into:

  - typing SmdaFunction.blocks as Dict[int, List[SmdaInstruction]] is
    accurate and does restore getInstructionsForBlock's return type, but it
    surfaces two no-matching-overload errors where getOpcHashSequence and
    its sibling join instruction.bytes, which is Optional[str]. In practice
    a block instruction always carries bytes, so this is latent rather than
    live -- but it is a real soundness gap and wants its own change.

  - extract_strings' Tuple[str, Any, Any, str] cannot be narrowed while
    read_string, read_go_string and derefs are untyped: every element of
    the yielded tuple infers Unknown, so even `str` in the first position
    is unprovable. Typing those three comes first.

Validation: ty check exit 0 with the error count unchanged at zero and the
warning count unchanged, ruff check and ruff format --check clean, both
pre-commit ruff hooks pass under the new id, full suite 1882 passed, 2
skipped, 2591 subtests.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…shot

getFunctionStartCandidates() is a snapshot taken before analysis begins and
gap analysis never adds to it, so a function the gap scan discovered is in
code_map and absent from that set. Both interior tests in the AArch64
candidate manager read the difference as evidence of an interior:

- _gapRunFlowsIntoInterior treats a branch to such a function as a branch
  into somebody's body, so a run of branch veneers whose targets the gap
  scan found is suppressed;
- _isLikelyInteriorBtiCandidate refuses a landing pad at an address the gap
  scan already recovered as a function.

Both now ask disassembly.functions as well. An address the analysis calls a
function is an entry whichever pass found it, which is the question these
two tests are actually asking.
…e it

The two straight-line walks over a gap run share _GAP_RUN_LIMIT and stop at
different words: _endOfRefusedLandingPadRun stops at brk/hlt/udf as well as
ret/br/b, and _gapRunFlowsIntoInterior does not. That difference has read as
an accident, and closing it by adding is_trap to the second walk was measured
before it was believed.

It should not be closed. The two walks answer different questions. The first
decides how far to SKIP, where one instruction too far steps over a real entry
and loses it with nothing to recover it; it has to stop at the earliest
boundary it can defend. The second decides whether to SUPPRESS, where nothing
is skipped either way and reading further only gathers more evidence about the
same candidate. And a trap is not a boundary the image declares: brk/hlt/udf
mid-body is a bounds check or a __builtin_trap block, and it ends a function
only by the backend's own END_INS convention. The words behind it still belong
to the enclosing routine, so a branch out of them into that routine's interior
is a true statement about the candidate ahead of it.

Measured by re-running the walk with is_trap in its terminator set and diffing
the candidates that stop being suppressed:

  ARM64 Mach-O, 11 images: 1,106 runs decided, 251 suppressed, 39 passing a
  trap, of which 12 suppressed on evidence read past it. Those 12 are two
  sites swept one word at a time, both strictly interior to a function the
  ground truth declares -- 0x1d8 and 0x15c past its start -- and 0 of them
  are true starts. The change would trade 12 suppressions for 2 false
  positives and recover nothing.

  Built C/C++ AArch64 ELF, 72 cells: 5,353 runs decided, 1,445 suppressed,
  and 0 passing a trap at all. That corpus cannot vouch for the change either
  way, which is worth recording next to the one that can.

So the divergence is the intent, and the comment on each walk now says so and
points at the other. tests/testAArch64GapRunTerminators.py pins it: a gap-only
run whose body passes a trap and then branches into a mapped interior stays
refused, the same run branching to a real entry is still booked, and the two
walks are exercised directly over identical words with only the middle one
varying. Adding is_trap to the suppression walk turns the behavioural
assertion and the walk-level one red; every control stays green.
…he numbers

The measurement block named cell counts, sample names and addresses from one
corpus run. Those go stale the moment either corpus is rebuilt, and they are
already recorded where measurements belong -- the commit that made the call and
the pull request that carried it. What a reader of this function needs is why
the two walks stop in different places, which does not go stale, and where the
behaviour is pinned. Both stay; the arithmetic goes.
… a stray tail

The gap scan refused any candidate whose first word was a conditional branch,
on the claim that a function never opens with one. At -O2 a compiler hoists an
argument check above the frame setup constantly, so the claim is false and the
refusal walked past the entry:

    cbz   x0, .Lreturn_null      <- the entry
    stp   x29, x30, [sp, #-32]!  <- what got booked instead
    mov   x29, sp

The frame setup one word in is 4-aligned and nothing calls it, so the alignment
floor keeps it out of the first pass; the start booked in the entry's place came
from the same gap scan, four bytes past the entry it had just stepped over, and
the guard's own branch target often became a second one. That is why removing
the refusal takes false positives away rather than adding them: recovering the
entry deletes the two wrong starts the refusal produced.

Measured by neutralising the guard at its gap-scan call site and comparing whole
recovered sets against ground truth, then re-measured on the committed change:

  corpus                            cells    dTP     dFP
  Built C/C++ AArch64 ELF              72   +349    -539
  ARM64 Mach-O                         11     +6      -6
  Built Go, all seven platforms        47      0      -4

On the ELF corpus, 60,326 truth functions: TPR 98.0788 -> 98.6573 and PPV
95.1391 -> 95.9935. On Mach-O, 2,753 truth functions: TPR 91.9724 -> 92.1903
and PPV 86.7123 -> 86.9178.

Not one cell in any of the three corpora gains a false positive. One of the 83
non-Go cells loses recall: lz4_gcc-arm64_O1 recovers 0xcf48 and 0x1bb10, and
recovering 0xcf48 lets its analysis run on through the four truth functions
after it, for a net -2 TP and -2 FP on that cell. The aarch64_static fixture
the benchmark gate reads is bit-identical, and the intel backend never reaches
this code.

What the refusal was buying, on the same 72 cells: 558 addresses refused, of
which 462 are function starts the ground truth names. It was not holding back a
flood of interior addresses -- four in five of the addresses it refused were
real. The shape that motivated the refusal is reproduced at 230 routines whose entry is
one word before the start that was booked, 203 of them opening on a conditional
branch.

tests/testMachoFunctionStartCandidates.py pinned 269 functions for the primary
pass on osx.frostyferret and 140 of the linker's own LC_FUNCTION_STARTS entries
among them; both move by one, to 270 and 141. The address recovered is one the
linker declares, so the fixture is independent evidence that the extra start is
a function and not one the scan invented. The total with the table pass on is
unchanged at 275: the primary pass now finds by itself what that pass was
compensating for.

The other call site of is_conditional_branch, in locateAddressRefCandidates,
is unaffected: it clears register provenance at a control-flow edge, which a
conditional branch is regardless of what may open a function. Its docstring
carried the false claim and now records what replaced it.

tests/testAArch64HoistedGuardEntry.py pins the three cases the change turns on:
the hoisted guard is recovered and the routine is one function and nothing else;
the same word class after ordinary code inside an already-claimed routine is
still not a start; and a conditional branch opening an unclaimed gap now books
the block's first word rather than the word behind it. Reverting the change
turns exactly those three red and leaves every control green.
…heir neighbours

The new module was measured against the test files beside it: 43.3% of its
non-blank lines were comment or docstring, against 25.2% for the no-return
boundary tests, 20.0% for the tailcall seed-evidence tests and 18.9% for the
pdata carving tests. That is a real outlier and not a house style, so the
prose is cut to 30.3% and the file is 29 lines shorter.

What went is restatement -- a module docstring that told the whole story before
the first class told it again, class docstrings that repeated their own method
comments, and controls that explained twice why they are controls. What stays
is the reason each control exists, which is the part a reader cannot recover
from the assertions, and the note on the alignment scaffold, without which the
image reads as arbitrary.
…dy named its functions

locateAddressRefCandidates resolves adr Xd, #imm and the adrp/add pair and seeds
every executable-section address they materialize. It exists to reach entries no
call names - a pointer handed to a callback, a table walked at run time. An image
that ships a full function map has none of those left over: the symbol pass has
consumed the map, every entry is a candidate already, and what the scan adds is
the addresses that are materialized but are not entries.

Measured one corpus at a time, switching the pass off (or, on Mach-O, on):

  corpus                              cells   the pass gains   the pass costs
  Built C/C++ AArch64 ELF, stripped      72        +321 TP           +91 FP
  ARM64 Mach-O                           11         +17 TP            -1 FP
  Built Go linux-arm64 (pclntab)          6           0 TP        +2,668 FP
  Built Go darwin-arm64 (pclntab)         6           0 TP        +2,059 FP

Neither the container nor the language predicts which way a row goes - both
containers appear in both halves, and with the pass in the same state the two Go
rows sit 0.09 PPV apart. What predicts all four is whether the image had anything
left for the pass to find.

So the condition is asked of the image: of the addresses this image's own bl
instructions call, how many did metadata name before this pass ran? The bl
targets are the denominator because they are the one population both kinds of
image have in proportion to how many functions they contain, and because they
are known exactly here - the scan that resolves them has just finished.

The cutoff is read off the distribution rather than chosen. Across those 95
cells the highest coverage at which the pass still recovers a function is 0.8144
and the lowest at which it recovers none is 0.9807; no cell falls between, so
every cutoff in that interval behaves identically on every cell measured. 0.95
sits in its upper half because the errors are not symmetric: running the scan
when it is useless costs precision, skipping it when it is not costs recall, and
recall is the axis this project defends hardest.

Effect of the committed change, whole recovered sets against ground truth:

  Built Go, all seven platforms, 47 cells, 166,337 truth functions
    TP 165,627 unchanged, FP 9,816 -> 7,148, PPV 94.4050 -> 95.8628
    six cells change, none loses a true positive
  Built C/C++ AArch64 ELF, 72 cells      bit-identical
  ARM64 Mach-O, 11 cells                 bit-identical

The stripped C/C++ corpus is the one the pass exists for and it does not move at
all: coverage there is 0.0000 to 0.0064, nowhere near the cutoff. The intel
backend has no equivalent pass, so x86 cannot be affected - the whole change is
inside src/smda/aarch64/.

The two new sets are reset in init() for the same reason the two caches beside
them are, and initialised in __init__ as well because a manager driven one pass
at a time never reaches init(); testCandidateSafeguards.py does exactly that and
went red on an AttributeError before that was added.
The metadata-coverage test divided named call targets by all call targets and
compared the ratio to a cutoff, with no floor under the denominator. An image
that resolves a handful of bl targets, all of them named, therefore reported
full coverage and skipped the address-materialization scan on the strength of
that handful -- while its metadata may name no address-taken function at all.
The scan is the only pass that reaches those, so the failure mode is a recall
regression from a precision change.

The hazard is not hypothetical. Of the eighteen corpus cells the ratio alone
would skip, six are ARM64 Mach-O images with 4, 32, 49, 49, 60 and 65 call
targets between them; the first resolves four bl instructions in the whole
binary and metadata names all four. Those six happen to cost nothing either
way, which is luck rather than a property of the rule.

The other twelve are the Go cells, with 1,288 to 3,793 call targets, and they
are where the whole benefit is -- 4,727 false positives between them. So the
floor goes in the gap: no cell has between 65 and 1,288 call targets, which
means every value in that interval leaves all eighteen decisions exactly as
measured. 512 sits inside it, an order of magnitude above the largest image
this protects and well below the smallest image that benefits.

Corpus figures are unchanged from the measurement without the floor:

  Built Go, all seven platforms, 47 cells   TP unchanged, FP 9,816 -> 7,148
  Built C/C++ AArch64 ELF, 72 cells         bit-identical
  ARM64 Mach-O, 11 cells                    bit-identical

The two constants are now separately mutation-checked: raising the ratio to 2.0
turns six tests red, dropping the floor to 0 turns two different ones red, and
neither mutation touches the other's set. The synthetic images in the coverage
tests are built with more call targets than the floor for the same reason a real
image needs them -- below it the predicate declines to read a rate at all, and
the tests would have been asserting on the floor rather than on the coverage.
USE_MACHO_ADDRESS_REF_CANDIDATES extends the adr/adrp scan to Mach-O
instruction sections. It has been off since it was added, with the comment
"off until validated with exact-address ground truth for the Mach-O corpus."

That validation exists now, and what it says is split: the scan is worth real
functions on C/C++ Mach-O images and pure noise on Go ones, which is why the
flag could not simply be flipped -- it turns the pass on for every AArch64
Mach-O image, Go included, and on Go darwin-arm64 that was 2,059 false
positives for no recovered function at all.

The metadata-coverage test now decides that per image rather than the flag
deciding it per container, so the two cases separate. Measured on the change:

  corpus                              cells    dTP     dFP
  ARM64 Mach-O                           11    +21      -5
  Built Go, all seven platforms          47      0      -4

On the Mach-O corpus, 2,753 truth functions: TPR 91.9724 -> 92.7352 and PPV
86.7123 -> 86.9550. Twenty-two starts are gained and one is lost. Go does not
move because its images do not reach the scan: every one of them is over the
coverage cutoff, which is exactly what that gate is for. The ELF corpora are
untouched -- this flag only widens the scan to a container they are not.

tests/testMachoFunctionStartCandidates.py pinned 270 functions for the primary
pass on osx.frostyferret and 141 of the linker's own LC_FUNCTION_STARTS entries
among them; both move up, to 271 and 143. The two extra addresses are ones the
linker itself declares, so the fixture is independent evidence that the scan
recovered functions rather than inventing starts. The total with the table pass
on falls from 275 to 274 as the primary pass absorbs its work and one wrong
start goes.

The test asserting the flag defaults to off is now the thing that changed, so it
asserts the opposite; the opt-out case it used to cover is kept beside it as the
control that says those two targets are reached by this scan and by nothing else.
…didate set

USE_SYMBOLS_AS_CANDIDATES turns off booking symbols as candidates. It does not
stop the image's own language metadata from being read: a Go binary's pclntab is
parsed by locateLangSpecCandidates whatever that option says, and that pass runs
after the coverage test.

So with the option off, the test saw an empty metadata set on a Go image that
declares every one of its functions, concluded that nothing named the call
targets, and ran the adr/adrp scan the gate exists to suppress. Measured on the
six ARM64 Mach-O Go samples with symbol candidates disabled: 3,318 false
positives against master's 1,256, with recall identical either way. The default
path was never affected -- 1,249 both before and after.

The test now unions the candidate snapshot with the starts the language metadata
names, so it measures the image rather than the configuration. Reordering the
passes instead would have been the other way to fix it, but locateLangSpecCandidates
rewrites the image buffer on a Delphi knowledge base, so moving it ahead of
discovery changes what every later pass reads.

With the fix, symbols disabled costs 1,256 -- master's number exactly -- and the
Mach-O corpus keeps what this branch is for: +21 true starts against 5 fewer
false ones, unchanged.
Two bundled fixtures move, and both movements are recorded where they are
asserted rather than left for a reviewer to re-derive.

osx.frostyferret, the ARM64 Mach-O fixture: the primary pass recovers 256
functions where it recovered 246, and 128 of the linker's own
LC_FUNCTION_STARTS entries where it recovered 117. Eleven table entries are
gained - eight branch veneers the interior test refused while it could only
see the pre-analysis candidate snapshot, one routine opening on a hoisted
argument check, and two the adr/adrp scan reaches now that it runs on
Mach-O - and one address that is not in the table, 0x100004c48, stops being
reported. Every gained start is one the linker itself declares; the single
loss is not, so it is a false positive going.

aarch64_static, the ELF fixture: 278 functions either way, one instruction
more, and the routine at 0x40DF34 is reported at 0x40DF30. That word is a
hoisted `cbz x14` and the gap scan used to step over it, on the theory that
a function never opens with a conditional branch. 0x40DF30 is 16-aligned
and follows the previous function's ret and its padding nop; 0x40DF34 is
4-aligned and mid-line. Neither carries an inbound direct branch.

The baselines are updated in one commit at the end of the series rather
than spread across the commits that move them, so the movement can be read
in one place.
@r0ny123
r0ny123 force-pushed the upstream-aarch64-candidate-quality branch from 0518d2e to 3cf5e9c Compare September 7, 2026 17:11
@r0ny123

r0ny123 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Rebased to the flat ten and force-pushed — head is now 3cf5e9c, exactly the set and order you listed, with the three pre-squash copies of #310 and the redundant fix(ci) commit gone.

Confirmed content-neutral before pushing: the tree is 38b27ba6eaea1d85f65f11d19d9fff25b6f604f1, byte for byte what the merge-based version resolved to. So everything you measured still holds and there is nothing to re-verify — ruff check, ruff format --check and ty 0.0.74 are clean, and the full suite was 1918 passed / 1 skipped / 2593 subtests on this same tree.

#309 and #312 are untouched and still mergeable. #312 will hit the squash artifact once this lands, exactly as you predicted, and reduces to its single commit either way.

@r0ny123
r0ny123 force-pushed the upstream-aarch64-candidate-quality branch from 3cf5e9c to 7bc8107 Compare September 7, 2026 17:17
@danielplohmann
danielplohmann merged commit db5eff0 into danielplohmann:master Sep 8, 2026
25 checks passed
danielplohmann added a commit that referenced this pull request Sep 8, 2026
Finishes the draft now that #309, #311 and #312 have landed. Adds their
sections, bumps `VERSION` and `__version__` to 4.5.1, and moves the date to the
release day.

The three new entries: #309's exception-directory refusal of interior gap
candidates on x64, with the chained-versus-primary distinction and the
seeding-is-not-suppression principle that keeps carved records out of it; #311's
four candidate-quality defects, including the corrected entry at `0x40df30` on
`aarch64_static_xored` and the metadata-coverage gate; and #312's ARM64
counterpart, which reconstructs the extent an ARM64 `RUNTIME_FUNCTION` does not
carry and hoists the shared lookup into `common/`.

`USE_MACHO_ADDRESS_REF_CANDIDATES` gets its own **Defaults changed** section. It
is the only default whose value moves in this release, it changes recovery
output on Mach-O input with no action by the caller, and a default going from
off to on inside a follow-ups PR is exactly the kind of thing that gets lost.

The closing movement section is rewritten to describe v4.5.0 -> v4.5.1 rather
than any single PR, and both figures in it were measured on this tree against
independent truth rather than quoted:

  rust_pe_gnu_xored, vs its 2,186 retained COFF .text function symbols
    2,355 -> 2,201 starts, +26 real / -180 false
    recall    92.452% -> 93.641%
    precision 85.817% -> 93.003%

  11-sample ARM64 Mach-O corpus, vs LC_FUNCTION_STARTS (2,056 truth)
    TP 1,787 -> 1,818, FP 1,113 -> 1,107
    recall 86.916% -> 88.424%, no sample losing recall

Two headline figures are marked as the contributor's own because they cannot be
reproduced here at all: the `bti j` 803/0 split, since no bundled fixture holds
a single `bti j` word, and #312's ARM64 PE result, since a scan of all 104
fixture files finds 8 i386 PEs, 4 AMD64, one ReadyToRun image and no `0xAA64`.
Saying which figures are ours and which are theirs is more useful than a uniform
tone of confidence.

`Turtle_5f9cd91d8d1d`'s 12 dropped starts are attributed rather than left as an
unexplained delta: they bisect to #307's inbound-call fix and fall inside the
175 false positives that change already measured, and the sample carries no
LC_FUNCTION_STARTS to score them against individually.

Closes #319

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDCLUmqGzE5fwJqUWsSStr
danielplohmann added a commit that referenced this pull request Sep 8, 2026
* chore: draft the v4.5.1 release notes

The changelog entry for what has landed since v4.5.0 (6cc5aca): #302, #303,
#304, #305, #306, #307, #308, #310 and #316, plus the Dependabot ty bump
572acd7 that #302 had to answer for.

Held as a draft rather than a release. The version is deliberately NOT
bumped here -- #309, #311 and #312 are reviewed and wanted but waiting on a
rebase, and splitting one coherent accuracy pass across two releases makes
both entries weaker. What this commit does is stop the writeup living in a
scratchpad while that happens.

To finish it: add sections for #309, #311 and #312, bump VERSION in
src/smda/SmdaConfig.py and __version__ in src/smda/__init__.py, and move the
date to the actual release day.

No escaper output changed anywhere in this set -- the only edit to
intel/definitions.py adds two GAP_SEQUENCES entries, which feed padding
detection and not escaping, and testEscaperFingerprint passes unchanged --
so ESCAPER_DOWNWARD_COMPATIBILITY stays at 4.4.5 and
INTEL_PIC_HASH_ESCAPE_VERSION at 4.3.5, and no report needs reprocessing.

Figures are the contributors' own except where this session reproduced them,
and the two that were reproduced are stated as measured here: the padding
cut's analysis-time cost (+3.4% claimed, +3.84% measured) and the fixture
movement on rust_pe_gnu_xored (+26 real starts against +8 false, scored
against that fixture's own COFF symbol table rather than the corpus macro
mean).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: fold #318 into the v4.5.1 release notes

#318 landed as 395c88d after this entry was written. Its five corrections go
under Housekeeping: the README's metadata.language claim, the restored
`-> bool`, the ruff-check hook rename, the GAP_SEQUENCES reach note, and the
docstring on the queue-rebuild test.

Keeping this current as things merge is the point of the branch existing, so
it does not go stale between now and the release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: cut v4.5.1

Finishes the draft now that #309, #311 and #312 have landed. Adds their
sections, bumps `VERSION` and `__version__` to 4.5.1, and moves the date to the
release day.

The three new entries: #309's exception-directory refusal of interior gap
candidates on x64, with the chained-versus-primary distinction and the
seeding-is-not-suppression principle that keeps carved records out of it; #311's
four candidate-quality defects, including the corrected entry at `0x40df30` on
`aarch64_static_xored` and the metadata-coverage gate; and #312's ARM64
counterpart, which reconstructs the extent an ARM64 `RUNTIME_FUNCTION` does not
carry and hoists the shared lookup into `common/`.

`USE_MACHO_ADDRESS_REF_CANDIDATES` gets its own **Defaults changed** section. It
is the only default whose value moves in this release, it changes recovery
output on Mach-O input with no action by the caller, and a default going from
off to on inside a follow-ups PR is exactly the kind of thing that gets lost.

The closing movement section is rewritten to describe v4.5.0 -> v4.5.1 rather
than any single PR, and both figures in it were measured on this tree against
independent truth rather than quoted:

  rust_pe_gnu_xored, vs its 2,186 retained COFF .text function symbols
    2,355 -> 2,201 starts, +26 real / -180 false
    recall    92.452% -> 93.641%
    precision 85.817% -> 93.003%

  11-sample ARM64 Mach-O corpus, vs LC_FUNCTION_STARTS (2,056 truth)
    TP 1,787 -> 1,818, FP 1,113 -> 1,107
    recall 86.916% -> 88.424%, no sample losing recall

Two headline figures are marked as the contributor's own because they cannot be
reproduced here at all: the `bti j` 803/0 split, since no bundled fixture holds
a single `bti j` word, and #312's ARM64 PE result, since a scan of all 104
fixture files finds 8 i386 PEs, 4 AMD64, one ReadyToRun image and no `0xAA64`.
Saying which figures are ours and which are theirs is more useful than a uniform
tone of confidence.

`Turtle_5f9cd91d8d1d`'s 12 dropped starts are attributed rather than left as an
unexplained delta: they bisect to #307's inbound-call fix and fall inside the
175 false positives that change already measured, and the sample carries no
LC_FUNCTION_STARTS to score them against individually.

Closes #319

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDCLUmqGzE5fwJqUWsSStr

* chore: record binweight's serialized type change in the v4.5.1 entry

Left open as a question in yesterday's review and resolved as "yes, it belongs
in the entry". #302 moved `SmdaFunction.binweight`'s class default from int `0`
to float `0.0` while clearing ty 0.0.74's diagnostics, and `binweight` is
serialized through `toDict()`.

The blast radius is small but real: the per-block accumulation already adds
`float(...)`, so every function with at least one block was a float before this
release too. Only a function with no blocks -- a zero-function or error report
-- keeps the class default, and that value now writes as `0.0` where it wrote
`0`. Verified on both trees rather than reasoned about: `SmdaFunction.binweight`
is `0` at 6cc5aca and `0.0` at d111548.

MCRIT stores these reports, so a consumer diffing them byte-for-byte will see
it even though nothing reads the field as an integer. That is exactly the kind
of change that costs someone an afternoon if it is not written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDCLUmqGzE5fwJqUWsSStr

* chore: fold the two remaining #317 items into the v4.5.1 entry

#320 and #321 finish the review pass that #318 started, so the release entry
should carry all seven of its corrections rather than five.

Both are behaviour-neutral typing work, but the Housekeeping text says what each
one actually decided rather than listing them as annotations: `blocks` being
typed forces the PIC/OPC hash path to refuse an instruction with no bytes
instead of blanking it, and `extract_strings` could not be narrowed until three
helpers and three `SmdaReport` attributes were declared first.

Behaviour-neutrality is stated with the evidence rather than as a claim -- the
corpus benchmark ran on #320 and reports 0 of 155 files differing across
175,946 functions, which is a stronger statement than the bundled fixtures can
make.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDCLUmqGzE5fwJqUWsSStr

* chore: format the v4.5.1 entry as a nested list

@r0ny123 asked on #319 whether the changelog could be formatted as a list to
make it easier to read. At ~2,700 words on a single line, this entry was the one
that provoked the question, so it gets the treatment now rather than waiting for
the wider keep-a-changelog adoption tracked in #323.

Deliberately narrow: only the v4.5.1 entry changes shape. The other 192 entries
keep the one-line format, because converting them is a large, low-value rewrite
that loses nuance in translation, and #323 proposes preserving them verbatim
under an `Older releases` heading instead.

Structure is the release summary as the top-level bullet, one sub-bullet per
section, and a further level inside the three sections that cover more than one
change -- Recovery (intel) splits into the switch-table fix, the alignment cut
and the exception-directory refusal; Recovery (AArch64) into the inbound-call
fix, the BTI work and the candidate-quality follow-ups; Housekeeping into the
`ty` bump, the `ruff` pin, the five #318 corrections, the `binweight`
serialization note and the two #317 typing items.

The text is unchanged. Verified mechanically rather than by eye: stripping list
markers and collapsing whitespace gives a string identical to the original entry.
The two exceptions are disclosed rather than silent -- the first sub-bullet of
Recovery (intel) and of Recovery (AArch64) had their opening letter capitalised,
because splitting the section header onto its own line left them starting a
bullet mid-sentence. Housekeeping's opens on a code span and needed nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDCLUmqGzE5fwJqUWsSStr

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
r0ny123 pushed a commit to r0ny123/smda that referenced this pull request Sep 8, 2026
Four test changes, none of them in the diff's own subject matter.

testInteriorPrologueSuppression's _BufferBinaryInfo implements the parts of
BinaryInfo the seeding scan reads, and danielplohmann#309 gave locateExceptionHandlerCandidates
a _getLiefType() call it does not have. It now answers "OTHER", which is what
BinaryInfo answers for a buffer lief cannot parse and the case the PE-only
branches are guarded for.

The ARM64 landing-pad control had gone vacuous. It switches USE_LSDA_LANDING_PADS
off and asserts a declared pad comes back, so that the assertion above it means
something -- and on current master it does not come back, because all five pads
in that fixture are `bti j` and danielplohmann#310's USE_AARCH64_BTI_TARGET_TYPE refuses a
`bti j` on the word alone. The control now switches that flag off as well and
says why, so it asserts what it always meant to: with both rules off the pads
are booked, with either one on they are not.

The two pinned fixture baselines are re-pinned from a run on the merged tree.
aarch64_static goes 278 -> 276 functions: both readings of the routine at
0x40DDC0's tail are now refused, Binary Ninja's 0x40DF34 and the 0x40DF30 the
gap scan reaches since danielplohmann#311, because the FDE at 0x40DDC0 covers both. The Mach-O
fixture's primary pass goes 256 -> 271, finding 143 of the table's 147 entries
against 128, with the total unchanged at 274 once the table pass runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRvm5qFMW52aWnmVgR5vkD
@r0ny123
r0ny123 deleted the upstream-aarch64-candidate-quality branch September 9, 2026 10:13
@r0ny123
r0ny123 restored the upstream-aarch64-candidate-quality branch September 9, 2026 10:14
@r0ny123
r0ny123 deleted the upstream-aarch64-candidate-quality branch September 9, 2026 10:14
danielplohmann pushed a commit that referenced this pull request Sep 10, 2026
…300)

* feat(core): function-boundary accuracy from what the image declares

Ten source changes that raise function-start accuracy on compiler-built
binaries, each measured against compiler symbol tables on corpora built
from source. Macro means, six corpora, both sides run back to back:

  260 built C/C++   PPV 91.878 -> 94.725  TPR 95.523 -> 95.596
  72 AArch64 ELF    PPV 76.676 -> 80.554  TPR 95.939 -> 95.964
  24 Rust           PPV 78.951 -> 83.608  TPR 97.493 -> 97.617
  4 .NET            PPV 93.589 -> 95.332  TPR 99.461 -> 99.469
  45 Go, 11 ARM64 Mach-O                  bit-identical

No corpus loses recall at any step.

The unifying idea is that the evidence which works is what the image
declares, not what the engine derives. An exception landing pad is where
the personality routine resumes, so it is interior to a function by
construction; a range .eh_frame declares is one routine. Both are facts
the compiler wrote down, and both were previously read as function
entries because they open with the instruction set's indirect-branch
marker - endbr64 under -fcf-protection, bti under -mbranch-protection -
and sit in gaps precisely because nothing branches to them.

The largest single mechanism was endbr64 seeded as a start. Over six
representative C/C++ cells it accounted for 822 false positives; two
rules built on declared evidence take it to zero while true positives
rise from 5,695 to 5,718.

Where a rule refuses a candidate, where the scan resumes decides whether
it costs recall. Stepping one instruction past a refused pad lands
inside the pad and books that instead. Resuming at the end of the
declaring FDE passes over that function's body and nothing else: over
the three corpora carrying pads, 0 of 41,215 have a declared start
between the pad and that end, and 0 sit inside a PLT section.

Two guards were found by measuring what the interior rule cost without
them. A PLT is exempt, because the whole table sits under one FDE and
the range test would otherwise read every stub after the first as
interior, costing 3,457 real functions. And a range's own start must be
a recovered function, because an FDE can begin in the alignment padding
ahead of its function.

The decoders read structures the analysed file controls, so both are
bounded on axes the fixtures cannot exercise: a 205 KB .eh_frame whose
records each named a 64 KB LSDA took 155 seconds before each LSDA was
decoded once and a per-section budget bounded the call-site table bytes
decoded and the reads preceding them. A decoded landing pad outside the
range its own FDE declares is refused - across four corpora and three
system libraries, 43,881 real pads are every one inside their own FDE,
and all 4,828 an unrelated image produced from mis-pointed LSDA data are
outside.

The perf-benchmark workflow now times base and PR interleaved in one job
rather than on separate runners, and its noise band is described as what
it measures. The previous shape reported a branch 13.18% slower at
p = 0.0000 when the only source change was AArch64-only on an x86
corpus; the two sides had run on different machines an hour apart.

* fix(intel): poll the analysis budget while walking a declared table

_readExceptionTable walks one 12-byte RUNTIME_FUNCTION per iteration
over a range the analysed image declares, and did not poll the
analysis budget. Reading the table from the PE data directory rather
than from the .pdata section extent widened what that range can be:
the directory size is a 32-bit field the image controls and need not
correspond to any section, so a junk value walks the whole image
where the section extent bounded it before.

The walk is still bounded - a short read ends it, so it cannot pass
the bytes that exist - but bounding is not the same as stopping.
Measured on an 8 MB buffer of uniformly nonzero bytes declared as one
table, with the budget already spent: 699,050 entries walked before,
4,096 after, and the unexpired path walks all 699,050 either way. At
the configured MAX_IMAGE_SIZE that is roughly 8.7M entries of work
after the deadline has passed.

Every other loop over an image-declared extent in both candidate
managers already polls, including the AArch64 sibling that reads its
own exception directory the same way; this was the one site that did
not.

* fix(core): return a str from declaredArchitecture under ty 0.0.74

Merging master brings the ty bump and the annotations that went with it,
and this branch carries one function master does not, so nothing covered
it: under 0.0.74 the tree had 42 error-level diagnostics where master had
41.

`declaredArchitecture` returns whatever a loader's reader hands back. Those
readers are untyped, so the value arrives as Unknown, and 0.0.74 treats
returning it from a `-> str` signature as unsound rather than as gradual
typing. Coercing is honest: every reader already returns a str, and a value
that somehow is not one fails the membership test and answers "".

Code Quality on this branch is now zero errors under the version it pins,
where before the merge it would have failed the moment it met master.

* test(tests): repair what the rebase onto current master broke

Four test changes, none of them in the diff's own subject matter.

testInteriorPrologueSuppression's _BufferBinaryInfo implements the parts of
BinaryInfo the seeding scan reads, and #309 gave locateExceptionHandlerCandidates
a _getLiefType() call it does not have. It now answers "OTHER", which is what
BinaryInfo answers for a buffer lief cannot parse and the case the PE-only
branches are guarded for.

The ARM64 landing-pad control had gone vacuous. It switches USE_LSDA_LANDING_PADS
off and asserts a declared pad comes back, so that the assertion above it means
something -- and on current master it does not come back, because all five pads
in that fixture are `bti j` and #310's USE_AARCH64_BTI_TARGET_TYPE refuses a
`bti j` on the word alone. The control now switches that flag off as well and
says why, so it asserts what it always meant to: with both rules off the pads
are booked, with either one on they are not.

The two pinned fixture baselines are re-pinned from a run on the merged tree.
aarch64_static goes 278 -> 276 functions: both readings of the routine at
0x40DDC0's tail are now refused, Binary Ninja's 0x40DF34 and the 0x40DF30 the
gap scan reaches since #311, because the FDE at 0x40DDC0 covers both. The Mach-O
fixture's primary pass goes 256 -> 271, finding 143 of the table's 147 entries
against 128, with the total unchanged at 274 once the table pass runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRvm5qFMW52aWnmVgR5vkD

---------

Co-authored-by: Claude <noreply@anthropic.com>
danielplohmann added a commit that referenced this pull request Sep 10, 2026
Three review findings on #300, all in text or in one expression, fixed here rather
than sent back for another round.

The config comments for USE_LSDA_LANDING_PADS and USE_ELF_FDE_INTERIOR_GAPS carried
the per-rule figures measured before #304/#307/#309/#310/#311 landed -- the same
baseline the PR description retracted, because master's own PPV on that corpus had
moved from 76.676 to 91.497 while the branch waited. RESOLVE_TAILCALLS likewise still
described the AArch64 bl fall-through gate as it measured before #307 changed what
addTailcallCandidate does, and omitted the AArch64 ELF corpus entirely, which is the
one row where the gate is a trade. All three now carry the re-measured attribution
against the tree #300 landed on, and the tailcall block says what the 53 lost true
positives are and how USE_ELF_EH_FRAME_CANDIDATES repays them. A description records
a conversation; a config comment is what the next reader has.

getExceptionDirectory matched the data directory with "EXCEPTION" not in
str(directory.type), a substring of the enum's repr that would also claim any future
type whose name contains it. The typed lookup is already the idiom in two other
places, one of them the AArch64 walk over the same directory.

The aarch64_static baseline moved by two starts and the test comment explained one
of them. 0x40DF34 is refused, but it was not recovered before either, so the pair the
fixture actually lost is 0x400350 and 0x40DF30 -- both mid-function instructions
inside a declared FDE, which is a plainer reading than the Binary Ninja disagreement
the comment led with. 0x400350 is now asserted alongside the other two.

Also removes a comment left dangling in intel/FunctionCandidateManager.__init__ by
#312, which hoisted the attribute it documented into common/ where the same text
already sits.

Suite 2060 passed, 1 skipped, 2593 subtests; ruff clean; ty exit 0 with 0
error-level diagnostics.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants