fix(aarch64): read the BTI target type, and resume past a refused pad's block - #310
Merged
danielplohmann merged 4 commits intoSep 7, 2026
Conversation
… 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.
r0ny123
force-pushed
the
upstream-aarch64-bti-landing-pads
branch
from
August 30, 2026 03:45
3883e50 to
e8626b7
Compare
This was referenced Aug 30, 2026
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.
This was referenced Sep 7, 2026
Merged
This was referenced Sep 8, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits' worth of one thing: the AArch64 candidate scans read a
bti jlanding 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_prologuethat actually accepted it — same order the function uses, so each address is charged to the branch that took it:stp x29,x30,[sp,#imm]!frame recordpaciasp/pacibsp)The two remaining arms — the callee-saved pair
stp x{19..28},x{19..28},[sp,#-imm]!and the LR savestr 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:
bti c(call target)bti j(jump target)Exact, not statistical. 1,308 addresses, no overlap, across 72 cells.
Why the two forms mean different things
bti jpermits a target reached bybr— an indirect jump, which is what a switch dispatch does to a case block.bti cpermits one reached byblr— an indirect call, which is how a function is reached. A call landing on a J-only pad faults. So the compiler that emittedbti jwas 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. Abti jword 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.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
0xe1744is returned at0xe1748instead: one instruction later, at thestp x29, x30, [sp, #-96]!rather than at thepaciaspin front of it. Both are prologue candidates, and this change shifts their order in the queue; thestpis analysed first and thepaciaspbehind it then aborts on the collision.Read against truth rather than against the count, the window is better after, not worse:
[0xe1500, 0xe1f00], truth declares 3 functionsAn 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:
bti j, rule off0x401000,0x401008bti j, rule on, resuming one instruction on0x401000,0x40100cbti j, rule on, resuming past the block0x401000So the walk resumes past the block the pad labels — its first terminator — instead of past the pad.
Terminators are
ret,br,band the trapsbrk/hlt/udf. The traps matter becauseAArch64Backend.analyzeInstructionends a function on all three throughEND_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 at0x401018instead of0x40100c, swallowing apaciasp-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.pdatasuppression, 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_LIMITalso replaces the bare0x400in_gapRunFlowsIntoInterior, which walks the same runs under the same bound and is the only other place that number appears.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:
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
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.pdatawork above argued against.Tests
tests/testAArch64Disassembler.pyover a landing pad reached only by an unresolvedbr— 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 jrefused;bti candbti jcstill 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'sret.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_SIZEfailstest_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
_gapRunFlowsIntoInteriorkeeps a different terminator set from the skip walk — it walks toret/br/band 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, addingis_trapthere 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 (
_gapRunFlowsIntoInteriorasking 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 --checkpass, 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
tycommit at the tipThe last commit on this branch is #302's fix, cherry-picked unchanged.
Master fails
Code Qualityon its own right now: 572acd7 bumpedtyto 0.0.74, and its newunsound-return-statement/unsound-assignmentrules 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
ty0.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.