fix(aarch64): candidate-quality follow-ups to the BTI landing-pad series - #311
Conversation
|
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 ( |
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.
0518d2e to
3cf5e9c
Compare
|
Rebased to the flat ten and force-pushed — head is now Confirmed content-neutral before pushing: the tree is #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. |
3cf5e9c to
7bc8107
Compare
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: 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>
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
…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>
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>
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 incode_mapand absent from that set. Both interior tests in the AArch64 candidate manager read that difference as evidence of an interior:_gapRunFlowsIntoInteriortreats 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;_isLikelyInteriorBtiCandidaterefuses a landing pad at an address the gap scan already recovered as a function.Both now ask
disassembly.functionstoo. On the bundledosx.frostyferretfixture this alone recovers 8 more functions, every one of them an addressLC_FUNCTION_STARTSdeclares, with nothing lost.2. The two gap-run walks stop at different words, and that is correct
_gapRunFlowsIntoInteriorand_endOfRefusedLandingPadRunshare_GAP_RUN_LIMIT, but since #310 the second also stops atbrk/hlt/udf. That reads like an omission —AArch64Backend.analyzeInstructionends a function on all three throughEND_INS— and it is not.The two walks answer different questions.
_endOfRefusedLandingPadRundecides 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._gapRunFlowsIntoInteriordecides 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_trapto 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 sit0x15cand0x1d8inside 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:
The claim is false. At
-O2a compiler hoists an argument check above the frame setup routinely: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.
LC_FUNCTION_STARTS)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, recovering0xcf48lets 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, inlocateAddressRefCandidates, 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
locateAddressRefCandidatesexists 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
blinstructions 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.bltargets 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
bltargets 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_CANDIDATESshipped 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 bundledosx.frostyferretfixture 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 ownLC_FUNCTION_STARTSentries 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 at0x40DF34is reported at0x40DF30. That word is a hoistedcbz x14.0x40DF30is 16-aligned and follows the previous function'sretand its padding nop;0x40DF34is 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 addingor 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 --checkpass,ty checkexit 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 --checkandty0.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:
I have not touched
CHANGELOG.mdhere — 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.