Skip to content

fix(aarch64): read the BTI target type, and resume past a refused pad's block - #310

Merged
danielplohmann merged 4 commits into
danielplohmann:masterfrom
r0ny123:upstream-aarch64-bti-landing-pads
Sep 7, 2026
Merged

fix(aarch64): read the BTI target type, and resume past a refused pad's block#310
danielplohmann merged 4 commits into
danielplohmann:masterfrom
r0ny123:upstream-aarch64-bti-landing-pads

Conversation

@r0ny123

@r0ny123 r0ny123 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Two commits' worth of one thing: the AArch64 candidate scans read a bti j landing pad as a function entry, and refusing one has to resume in the right place or the false positive simply moves four bytes.

The measurement first

Every prologue-sole booking on a 72-cell built AArch64 ELF corpus, charged to the arm of is_function_prologue that actually accepted it — same order the function uses, so each address is charged to the branch that took it:

pattern FP TP FP share FP per TP
BTI landing pad 803 505 59.8% 1.59
stp x29,x30,[sp,#imm]! frame record 536 2,764 39.9% 0.19
PAC sign (paciasp/pacibsp) 0 1,656 0.0% 0.00
not a prologue word 4 0 0.3%

The two remaining arms — the callee-saved pair stp x{19..28},x{19..28},[sp,#-imm]! and the LR save str x30,[sp,#-imm]! — are the sole booker of nothing at all here, neither a true positive nor a false one.

Splitting the BTI row by which form the compiler wrote:

variant FP TP
bti c (call target) 0 505
bti j (jump target) 803 0

Exact, not statistical. 1,308 addresses, no overlap, across 72 cells.

Why the two forms mean different things

bti j permits a target reached by br — an indirect jump, which is what a switch dispatch does to a case block. bti c permits one reached by blr — an indirect call, which is how a function is reached. A call landing on a J-only pad faults. So the compiler that emitted bti j was naming an interior label, and it recorded that fact in the instruction.

Candidate discovery read all four forms as entry prologues, which put every jump pad in the image on the list. The existing guard reads the word before the pad and refuses one that follows ordinary code — but that cannot separate these two, because a case block is preceded by the previous case's terminating branch, which is exactly the boundary shape the guard accepts as an entry.

So the pass had the answer in front of it and was not reading it.

Commit 1: read the target type

USE_AARCH64_BTI_TARGET_TYPE, on by default. A bti j word is interior by what it says about itself, before anything around it is read.

It lives in _isLikelyInteriorBtiCandidate, the predicate the prologue scan and the gap scan share, rather than in the scan that produced the histogram. Both reach these addresses — the pads that became false positives are the ones behind an indirect branch nothing resolves, so no analysis covers them and both passes get a turn. A rule in one would have left the other booking exactly what the first refused.

corpus n PPV TPR Δ FP Δ TP
Built C/C++ AArch64 ELF 72 87.596 → 89.186 96.205 → 96.205 −2,941 −1

Recall unchanged to three decimals. 2,941 removed against 803 prologue-sole bookings, because a wrong entry does not only cost its own address — it starts an analysis that fragments the routine it sits inside.

The one true positive that moves is not lost

On one cell the entry at 0xe1744 is returned at 0xe1748 instead: one instruction later, at the stp x29, x30, [sp, #-96]! rather than at the paciasp in front of it. Both are prologue candidates, and this change shifts their order in the queue; the stp is analysed first and the paciasp behind it then aborts on the collision.

Read against truth rather than against the count, the window is better after, not worse:

in [0xe1500, 0xe1f00], truth declares 3 functions recovered of which real spurious
before 13 3 10
after 6 2 4

An exact-start metric charges that as a lost function plus a false positive. It is a boundary that moved by four bytes, not a routine that vanished.

Commit 2: resume past the block, not past the pad

Refusing a pad advanced the gap scan by one instruction. That puts it on the pad's own first body instruction — ordinary code, which passes every remaining guard and is promoted in the pad's place. The false positive does not go, it moves four bytes:

recovered functions
bti j, rule off 0x401000, 0x401008
bti j, rule on, resuming one instruction on 0x401000, 0x40100c
bti j, rule on, resuming past the block 0x401000

So the walk resumes past the block the pad labels — its first terminator — instead of past the pad.

Terminators are ret, br, b and the traps brk / hlt / udf. The traps matter because AArch64Backend.analyzeInstruction ends a function on all three through END_INS: without them the walk reads a block that has already closed and skips whatever lies behind it, and what lies behind a trap is exactly the unreferenced, gap-only routine this scan exists to reach. Reproduced on a five-word image — the walk resumed at 0x401018 instead of 0x40100c, swallowing a paciasp-opening routine, for each of the three trap forms.

It falls back to the single-instruction step when no terminator is within _GAP_RUN_LIMIT, so a run of undecodable bytes cannot make the skip travel an arbitrary distance. That bound is not hypothetical caution — an unbounded skip on a bad extent is what cost 1,274 functions on one dump while developing the x64 .pdata suppression, and the same shape is available here.

Applied to every arm of _isLikelyInteriorBtiCandidate, not only the target-type arm the first commit adds. The two older arms — a pad inside already-claimed code, and a pad following ordinary code — have the same weakness and predate that change. _GAP_RUN_LIMIT also replaces the bare 0x400 in _gapRunFlowsIntoInterior, which walks the same runs under the same bound and is the only other place that number appears.

corpus n PPV TPR Δ FP Δ TP
Built C/C++ AArch64 ELF 72 89.215 → 89.623 96.208 → 96.208 −442 0
ARM64 Mach-O 11 bit-identical — 93.992 / 95.619, 2,484 TP, 262 FP both sides

442 false positives removed, recall unchanged to three decimals, and not one true positive moved — TP is 59,012 on both sides.

Why the first commit's tests did not catch it

They asserted the refused address was absent:

self.assertNotIn(BASE + 0x8, self._btiPadOnly(0xD503249F))

which is true both when the false positive is gone and when it has moved to BASE + 0xc. The assertion was satisfied by the exact defect it was written to prevent. All four BTI cases now compare the whole recovered set, which is what makes the difference between "removed" and "moved" visible.

Controls

corpus n result
Built C/C++, x86-64 140 bit-identical — 93.740 / 96.968, 113,438 TP, 14,123 FP both sides
Built Go, all platforms 47 bit-identical — 94.036 / 99.367, 165,627 TP, 10,944 FP both sides
ARM64 Mach-O 11 bit-identical for commit 1 too — 93.986 / 95.616, 2,484 TP, 263 FP both sides

The x86-64 corpus is the control that matters for scope: the intel scan has no BTI shape and the rule must not reach it.

The Mach-O corpus is not a control for the rule itself, and is not offered as one. Those 11 cells book no BTI-opened prologue candidate at all, so the rule is inert there rather than confirmed. It is measured on one corpus, and this says so.

The PE ARM64 exception-record reader (is_exception_record_entry) accepts the same word through a different question — whether a record names an independent entry or a funclet — and is deliberately left alone. There is no ARM64 PE corpus here to measure it on, and shipping an unmeasured suppression rule is the thing the .pdata work above argued against.

Tests

  • Five cases in tests/testAArch64Disassembler.py over a landing pad reached only by an unresolved br — the shape that actually produced the false positives. A pad the engine can reach from inside a function is absorbed by the analysis whatever the prologue scan did, so it cannot show this rule doing anything; the first fixture written for this was that shape and it passed for the wrong reason. bti j refused; bti c and bti jc still booked in the same position; the flag off restores the old behaviour; and the predicate asserted directly, because the two callers differ only in which address they stand on.
  • test_the_body_of_a_refused_pad_is_not_promoted_in_its_place — the walk landing past the block's ret.
  • test_a_trap_ends_the_pad_run_like_any_other_terminator — all three trap forms as subtests.
  • test_a_pad_run_with_no_terminator_in_reach_falls_back_to_one_step — the bound, over 0x200 words with no terminator.

Mutation-tested both ways: reverting the call site to += INSTRUCTION_SIZE fails test_a_jump_only_bti_pad_is_not_an_entry (which it did not do before the assertions were tightened), and deleting the trap clause fails all three subtests.

One follow-up deliberately not in here

_gapRunFlowsIntoInterior keeps a different terminator set from the skip walk — it walks to ret/br/b and reads straight through traps. That asymmetry is correct rather than an omission: the skip walk decides how far to skip, and one instruction too far steps over a real entry and loses it, so it must stop at the earliest boundary it can defend; the suppression walk decides whether to suppress, nothing is skipped either way, and reading further only gathers more evidence about the same candidate. Counted on both corpora, adding is_trap there releases 12 candidates on ARM64 Mach-O (two sites, swept one word at a time), 0 of them true starts, and 0 candidates on the 72-cell ELF corpus.

The test file that pins that reasoning needs a guard from #300 (_gapRunFlowsIntoInterior asking the live function set, not just the pre-analysis candidate snapshot), so it is held back rather than included here.

Validation

ruff check / ruff format --check pass, full suite green on this branch rebased onto current master, diff coverage 100% with 0 missing lines on both changed files.

Relationship to #299 and #300

Both commits are also sitting inside #299, and the first of the two inside #300 as well. Those were opened from branches that had my fork's master merged into them repeatedly, so their diffs are much wider than their titles suggest, they overlap each other, and they carry this change at two different points of its history.

This PR is the same change on its own, rebased onto current master, so it can be reviewed and measured for what it actually is. If you would rather take it through one of those, close this one; if you take this one, the matching hunks fall out of theirs on rebase.

The ty commit at the tip

The last commit on this branch is #302's fix, cherry-picked unchanged.

Master fails Code Quality on its own right now: 572acd7 bumped ty to 0.0.74, and its new unsound-return-statement / unsound-assignment rules become errors under [tool.ty.rules] all = "error". That is 41 errors across 15 files, none of which this PR touches — so every PR opened against master inherits a red check that says nothing about its own diff.

Carrying #302's commit here means this PR's CI reflects only this PR's work. It is the same commit, unmodified, so the moment #302 lands this one is empty and falls out on rebase — there is nothing to untangle later. If you would rather look at this branch without it, merge #302 first and I will drop it.

Checked against ty 0.0.74 itself rather than whatever an older local pin resolves to: on master it exits 1 with 41 errors, on this branch it exits 0 with none.

… entry

The four BTI forms are not interchangeable. `bti j` permits a target reached
by `br`, an indirect jump, and never one reached by `blr`: a call landing on a
J-only pad faults. So a compiler that wrote `bti j` was naming an interior
label - a switch case or a computed-goto target - while `bti c` and `bti jc`
both permit a call and can be entries.

Candidate discovery read all four as entry prologues, which put every jump pad
in the image on the list. The guard in front of it reads the word before the
pad and refuses one that follows ordinary code, but that cannot separate these:
a case block is preceded by the previous case's terminating branch, which is
exactly the boundary shape the guard accepts.

Attributing every prologue-sole booking on 72 built AArch64 ELF cells to the
arm of `is_function_prologue` that accepted it:

  bti j                     803 false positives      0 real functions
  bti c                       0 false positives    505 real functions
  stp x29,x30,[sp,#imm]!    536 false positives  2,764 real functions
  paciasp / pacibsp           0 false positives  1,656 real functions

The split on the two BTI forms is exact rather than statistical, which is what
separates this from a threshold. The callee-saved pair and LR-save arms are the
sole booker of nothing at all in this corpus.

USE_AARCH64_BTI_TARGET_TYPE, on by default. The rule lives in the predicate the
prologue scan and the gap scan share, because both reach these addresses and a
rule in one of them would leave the other booking what the first refused.

Measured on 72 cells, PPV / TPR 87.596 / 96.205 -> 89.186 / 96.205: 2,941 false
positives removed, recall unchanged to three decimals.

The single true positive that moves is not lost. On one cell the entry at
0xe1744 is recovered at 0xe1748 instead - one instruction later, at the
`stp x29, x30, [sp, #-96]!` rather than at the `paciasp` in front of it, both
of which are prologue candidates and whose order in the queue this change
shifts. In the same window truth declares three functions; before this the
engine returned those three with ten spurious ones, after it returns two with
four.

Bit-identical on every corpus that is not AArch64 ELF: 140 x86-64 C/C++ cells,
47 built Go cells, and 11 ARM64 Mach-O cells. The Mach-O corpus is not a
control for the rule itself - it books no BTI-opened prologue candidate at all,
so the rule is inert there rather than confirmed. The PE ARM64 exception-record
reader accepts the same word through a different question and is deliberately
left alone, having no corpus to be measured on.
… pad

Refusing a BTI landing pad advanced the gap scan by one instruction, which put
it on the pad's own first body instruction. That word is ordinary code, so it
passed every remaining guard and was promoted in the pad's place four bytes
along: the false positive moved rather than went.

    bti j, rule off   0x401000, 0x401008
    bti j, rule on    0x401000, 0x40100c

The block a pad labels ends at its first terminator, and past that is the first
address the scan has not already decided against. Falls back to the single
instruction step when no terminator is in reach, so a run of undecodable bytes
cannot make the skip travel an arbitrary distance - the failure mode a bad
extent had in the x64 exception-directory rule.

Applied to every arm of the pad predicate rather than only to the target-type
one. The older arms - a pad inside claimed code, and a pad that follows ordinary
code - have the same weakness and predate this, so the measurement below covers
the general form.

    Built C/C++ AArch64 ELF, 72 cells   89.186 / 96.205 -> 89.599 / 96.205
                                        6,926 -> 6,482 false positives

444 removed, recall unchanged and not one true positive moved. Bit-identical on
11 ARM64 Mach-O cells and on 47 built Go cells; no x86 corpus can be reached,
the change being confined to the AArch64 candidate manager.

The tests that should have caught this asserted that the refused address was
absent rather than what the recovered set contained, so they passed while the
false positive was sitting four bytes away. They now compare the whole set.
…ion too

The walk read `ret`, `br` and `b` as terminators and not `brk`, `hlt` or `udf`.
`AArch64Backend.analyzeInstruction` ends a function on all three through
`END_INS`, so a pad whose body ends in a trap had its block read as continuing
to the next `ret` - and the skip then travelled past whatever lay between. What
lies after a trap is exactly the unreferenced, gap-only routine this scan exists
to reach, so the cost of the omission is recall.

Reproduced on a five-word image: the walk resumed at 0x401018 rather than
0x40100c, swallowing a `paciasp`-opening routine, for each of `brk #1`,
`hlt #1` and `udf #0x36`.

    Built C/C++ AArch64 ELF, 72 cells   6,482 -> 6,483 false positives
    ARM64 Mach-O, 11 cells              bit-identical

One address, which is the price of not skipping a block that had already ended.

The same omission exists in `_gapRunFlowsIntoInterior`, which walks the same
runs under the same bound, and is deliberately not fixed here: doing so costs
two false positives on the Mach-O corpus and gains nothing measured, so it wants
its own change and its own numbers rather than a ride on this one.
Keep the 0.0.74 bump and satisfy the new unsound-return/assignment/yield
rules with annotations on the public DTO and helper surfaces. Ignore ty
in Dependabot so the next patch release cannot redden master unattended.
@danielplohmann
danielplohmann merged commit 7d01480 into danielplohmann:master Sep 7, 2026
26 checks passed
@r0ny123
r0ny123 deleted the upstream-aarch64-bti-landing-pads branch September 7, 2026 17:38
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
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>
danielplohmann pushed a commit that referenced this pull request Sep 11, 2026
#310 refuses a `bti j` word as a function start and keeps `bti c`, on the
grounds that a jump-only hint marks an indirect branch target inside a
routine where a call hint marks a callable entry. #322 records the figure
behind it as unverifiable here, because no bundled fixture contained a
`bti j` word and every AArch64 fixture was bit-identical across the
change.

The first half of that has since stopped being true: #300 bundled
elf_cxx_landing_pads_arm64_xored, which carries five `bti j` words, and
they do reach the rule. The second half still holds, and now for a
reason worth writing down. All five sit inside exception landing pads,
where the shape test refuses them on its own account and the LSDA rule
refuses them earlier still, so the flag decides nothing on that fixture
and toggling it moves no address.

What the flag needs in order to decide by itself is a `bti j` the shape
test would otherwise accept: after alignment padding, opening a block
that looks like an entry, in an image declaring no landing pads at all.
A raw buffer is exactly that image, so this builds one rather than asking
for a binary that cannot be bundled.

With the flag off the hint word is booked as a function and the block
behind it is not; with it on the hint is refused and the block is
recovered in its place. `bti c`, `bti jc` and a bare `bti` are asserted
unaffected in both settings, which is what makes the case measure the
jump-only split rather than bti handling in general.
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