Skip to content

perf(intel): refuse gap candidates the exception directory places inside a function - #309

Merged
danielplohmann merged 1 commit into
danielplohmann:masterfrom
r0ny123:upstream-intel-pdata-interior-gaps
Sep 8, 2026
Merged

perf(intel): refuse gap candidates the exception directory places inside a function#309
danielplohmann merged 1 commit into
danielplohmann:masterfrom
r0ny123:upstream-intel-pdata-interior-gaps

Conversation

@r0ny123

@r0ny123 r0ny123 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

A 64-bit PE carries one RUNTIME_FUNCTION record per function, each naming the extent the unwinder needs. Candidate discovery already reads that table to seed function starts — and never consults it again. So the gap scan stayed free to carve extra entries out of addresses the image had already declared to belong to a routine that starts earlier.

Those are precisely the addresses the gap scan reaches: no reference and no prologue claims them, which is why they survive to it.

Root cause, measured before anything was written

Over three 64-bit PE cells of a built Rust corpus, of the false positives the gap scan alone booked, 734 of 743 (98.8%) sit strictly inside a declared .pdata extent. The two populations separate exactly, not statistically:

  • 0 of the 878 true positives the gap scan alone booked are interior to a declared extent.
  • 0 of the 5,114 truth functions on those cells are interior to a declared extent.

The fix

USE_PE_X64_PDATA_INTERIOR_GAPS, on by default. A gap candidate the exception directory places inside a function is refused, and the scan resumes at that function's end rather than one byte on.

Four conditions keep it from overreaching. Three were needed from the start; the fourth came from a corpus that caught the change being wrong.

  • A chained record (UNW_FLAG_CHAININFO) describes a fragment of another function, so its own first byte is interior too. A primary record's first byte is the entry and stays bookable.
  • A primary extent only suppresses once the function it names has actually been recovered.
  • Each extent is bounds-checked before it is believed: end above start, end within the image, span under _PDATA_MAX_FUNCTION_SIZE, unwind pointer DWORD-aligned, and its UNWIND_INFO first byte one of the eight legal values. These are the bounds _readExceptionRecord already applies when it has to find the table itself.
  • Extents are recorded only from a table the image declares. That one is the whole difference between a win and a regression — see below.

Extents are deliberately not merged: functions are laid out end-to-start, and merging adjacent records would collapse .text into a handful of spans in which every address but the first reads as interior.

The regression this shipped with, and what fixed it

The first version trusted the table however it was obtained. On built corpora that was invisible. On a 57-sample malware corpus it cost 1,274 functions on one memory dump, 254 of which a structural reading calls real.

The engine reads the exception table two ways and they are not equally trustworthy. When the image declares a section table, the directory's location is read from it. When it does not — normal for a memory dump, whose mapped bytes survive while its header does not — the table has to be found, by scanning for a run of entries that validate as RUNTIME_FUNCTION records. That reconstruction was already used to seed candidates, where a wrong entry costs one bad address. Extending it to suppression made a wrong entry cost every gap-only function inside whatever range it named.

malware dumps, n=57
trusting a carved table −1,274 functions on one sample
declared tables only −1 function, −1 false positive

The narrowing is free on every image that has a section header — built Rust is byte-identical across it — and is the whole of the regression on the images that do not. The direct control is a 56-sample headerless ByteWeight variant, the same PE64 binaries with their headers stripped: bit-identical on both sides, because the only table available there is a carved one and a carved one now suppresses nothing.

The general form is worth keeping: seeding is cheap and self-correcting; suppression is expensive and silent. A wrong seed is one false positive later passes may absorb. A wrong suppression removes a region and nothing reports that it was removed. A source good enough for one is not automatically good enough for the other.

The one remaining loss is a defect in the oracle

0x14001161b on an x64 dump. The image's own .pdata declares a single function [0x140011424, 0x140011662); the corpus' manually annotated truth puts a second function inside it. The contested address is unaligned where 87.8% of that sample's truth is 16-aligned, has zero inbound references, and falls through without returning — eleven instructions ending in a mov. The function it sits inside is referenced, 133 instructions long, and ends in ret.

That is a tail block annotated as a function. It is stated rather than argued away, because a single contested address is exactly where the temptation to fit the measurement to the change is strongest.

What it moves

corpus n PPV TPR Δ FP Δ TP
ByteWeight MSVC PE64 68 99.080 → 99.869 99.838 → 99.851 −1,220 +48
Built Rust 24 75.915 → 79.477 97.496 → 97.584 −2,052 +48
Built Go 47 94.036 → 94.342 unchanged −538 0
malware dumps 57 92.639 → 92.645 98.561 → 98.552 −1 −1 (above)

Recall rises on three of the four. That was not the intent — the rule only refuses candidates — and the mechanism is worth naming: booking an interior address is not merely a wrong entry, it starts an analysis that runs past the end of the routine the address sits inside and absorbs the small aligned functions after it. All eight functions recovered on the cell examined sit 11 to 79 bytes past a declared extent's end.

Controls

Every corpus that does not carry a 64-bit PE exception directory is bit-identical:

corpus n result
Built C/C++ ELF, x86-64 140 bit-identical — 93.740 / 96.968, 113,438 TP, 14,123 FP both sides
ByteWeight MSVC PE32 68 bit-identical — 92.041 / 97.872
ByteWeight MSVC PE64, headerless dumps 56 bit-identical — 98.874 / 99.811, 106,403 TP, 2,052 FP both sides
Built Rust, linux-gnu and windows-gnu-x86 16 of 24 bit-identical

The x86-64 ELF corpus is the control that counts, because it runs the same backend and the same gap scan on a container that declares no exception directory. ARM64 corpora prove nothing here — AArch64 has its own candidate manager and never reaches this code.

The 32-bit result has a structural explanation rather than a lucky one: 0 of 68 32-bit PEs declare an exception directory, against 68 of 68 of the 64-bit ones.

Bug-class sweep

The class is a declared function extent the engine reads for candidates but never for suppression.

site state
intel, PE x64 .pdata, declared table fixed here
intel, PE x64 .pdata, carved from a headerless dump deliberately not used for suppression — see above
intel, ELF .eh_frame covered by USE_ELF_FDE_INTERIOR_GAPS in #300
aarch64, PE ARM64 .pdata not fixed here — the extent is reconstructible, but there is no ARM64 PE anywhere in the 305 operator-supplied samples available, and this PR is itself the argument against shipping an unmeasured suppression rule
CIL, Dalvik cannot occur; neither backend gap-scans, the base nextGapCandidate raises
Mach-O LC_FUNCTION_STARTS names starts, not extents; nothing to suppress with

Review findings addressed

An adversarial review of this diff found seven items. The two that mattered:

  • The walk-back in declaredExceptionRangeContaining was wrong, and its docstring justified it with a false claim — sorting by start does not order the ends, so "the previous record ends before this address" says nothing about the one before that. Two short extents in front of a long one ended the walk on top of the extent that actually covered the address. Replaced with a running maximum of reach. It failed open, so it could only cost precision, and it is unreachable from a conforming table — but the reasoning was wrong and written down as if it were not.
  • The recovered-function guard had no coverage. Its test used a record with End below Begin, so no extent was recorded at all and the assertion held for an unrelated reason; deleting the guard entirely left the suite green. Same for the image-bound check, shadowed by the span cap.

All three guards are now mutation-tested: deleting any one of them makes a test fail. Also from that review — a PE check, since getSections() yields for ELF and Mach-O and a section merely named .pdata should not be read as an exception directory; the DWORD-alignment test the carved path already required; and a note on the extent list's memory (~29 MB at 200,000 functions, uncapped on purpose, because a cap would silently stop suppressing on exactly the largest images).

Tests

tests/testPdataInteriorGaps.py, 30 cases over a minimal spec-valid PE32+ x86-64 image with .text, .pdata and .xdata, driven end to end, plus the lookup and the extent predicate on their own where every branch can be reached with a chosen table. A headerless fixture with a stated bitness drives the carve path.

The end-to-end fixture's island is mov eax,1; ret rather than a prologue shape on purpose: the first version used push rbp; mov rbp,rsp, and the prologue scan booked it before the gap scan ever saw it, so the test would have passed for the wrong reason.

tests/testPdataExtraction.py gains one method on its MockBinaryInfo — the double now answers what container it stands for, which is what the new PE check asks.

Validation

ruff check / ruff format --check pass, full suite green on this branch with current master merged in (1913 passed, 1 skipped, 2591 subtests), diff coverage 100% with 0 missing lines.

Every figure above was taken on the change as proposed, not carried over from the run that motivated it. The built-Rust cells were re-measured after the review fixes and came back identical to the run that motivated them (79.477 / 97.584, 33,052 TP, 7,397 FP), so those fixes are behaviour-neutral on real images as well as bit-exact in intent.

Branch shape

Rebased onto current master (395c88d) as a single commit, dropping the merge and the fix(ci) commit that #302 made redundant.

Content-neutral: the tree is 0fb222b, byte for byte what the merge-based version resolved to, so the measurements above stand and nothing needed re-running. The one conflict — the SmdaConfig.py clash with #305, both appending an option block to the same region — is resolved by keeping both blocks, and the resulting option set is exactly the union of the two sides. ruff check, ruff format --check and ty 0.0.74 clean; the full suite was 1913 passed / 1 skipped / 2591 subtests on this identical tree.

Scope note: the is_pe guard also gates seeding

Putting this on the record, since it is a real widening of scope that the title does not suggest.

The is_pe guard does not only gate suppression — it also stops .pdata seeding on a non-PE image. Before this, a 64-bit ELF carrying a section named .pdata would seed candidates from those bytes; now it will not.

I think that is right on its own terms: RUNTIME_FUNCTION is a PE construct, and reading arbitrary bytes out of a same-named ELF section as unwind records is not sound to begin with. But it is a change to candidate discovery living inside a PR about refusing gap candidates, so it should be a decision rather than a side effect. No bundled non-PE image carries such a section, so it is inert on the current fixtures either way. Happy to split it out if you would rather it were its own change.

Relationship to #299 and #300

This change is also sitting inside #299 and #300. Both of 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 and they overlap each other as well.

This PR is the same change on its own, 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 merge.

@danielplohmann

Copy link
Copy Markdown
Owner

Reviewed, and I'd like to take this — but it needs a rebase first: GitHub now reports it CONFLICTING.

The conflict

One file, src/smda/SmdaConfig.py, and it is purely textual. #305 landed as 59b6e11 and appended USE_READYTORUN_NATIVE_ROUTING to the same region this appends USE_PE_X64_PDATA_INTERIOR_GAPS to. The resolution is to keep both blocks — exactly what you predicted in #305's "Notes for merging". Nothing here reads or writes the other's option.

I resolved it locally that way to review the branch, and everything below was measured on that resolution, so the rebase should be mechanical.

What I verified, so you know the review is done and only the rebase is outstanding

Scored tests/rust_pe_gnu_xored against its own COFF symbol table (a mingw build that retains 4,818 symbols), before and after:

before after delta
TP 2047 2047 0 — no true positive lost
FP 342 154 −188
PPV 85.684% 93.003% +7.32pp

Every one of the 188 suppressed addresses is a false positive on that fixture. That is a stronger result than the +3.56pp your built-Rust table reports, on the one bundled fixture that moves.

Your controls hold on the bundled set too: mirai_x64_xored (x86-64 ELF), cutwail_xored and rust_pe_msvc_i686_xored (32-bit PE) all come back bit-identical.

I unit-tested declaredExceptionRangeContaining against the adversarial shape its own docstring names — two short records sorted in front of a long one — and the running-maximum reach array handles it correctly (0x1500 resolves to (0x1040, 0x2000) rather than stopping on the short records). Nested extents resolve to the innermost, which is the conservative direction. Chained-vs-primary at the extent's first byte behaves as documented.

And the regression guard is structural rather than incidental: instrumenting _admitExceptionRecord shows 1,666 records admitting with declared=True on an intact PE, while _carveExceptionRecords passes declared=False and contributes nothing to _pdata_ranges. The distinction you drew after the −1,274-function regression is doing real work, and it is worth keeping as a stated principle — seeding is cheap and self-correcting, suppression is expensive and silent — beyond this PR.

Full suite on the rebase: 1903 passed, 2 skipped, 2588 subtests. 25 of the 41 new tests fail against master, so they are non-vacuous.

One scope note, not a blocker

The is_pe guard also stops seeding from a non-PE .pdata section, not only suppression. Before this, a 64-bit ELF carrying a section named .pdata would seed candidates from those bytes; now it will not. That is defensible on its own terms — RUNTIME_FUNCTION is a PE construct — but it is a change to candidate discovery inside a PR about refusing gap candidates, and the title does not suggest it. No bundled non-PE image carries such a section, so it is inert on the current fixtures. Mentioning it so it is on the record rather than asking you to change it.

Also affected

#312 stacks on this and hits the same SmdaConfig.py conflict, so it will want the same treatment. #299 and #300 are conflicting too — #300 on SmdaConfig.py, #299 on SmdaConfig.py plus src/smda/common/BinaryInfo.py (that one against #302's annotations, which are now in master as 7f892b9).

#310 and #311 still merge cleanly, so those are not waiting on anything.

No rush on any of it — and thanks for the care in these, the self-caught .pdata regression writeup in particular was more useful to read than most postmortems.

@danielplohmann

Copy link
Copy Markdown
Owner

Update, and an extra thing for the same rebase pass.

#310 is merged (7d01480), and #311 is reviewed and wanted — but it now conflicts, for a different reason than #309 does, and the reason is on our side rather than yours.

The squash artifact on #311 and #312

This repository squash-merges, so #310 landed as one new commit with a new SHA. #311 still carries #310's three original commits (9233624, fa31fe1, e8626b7), and git no longer recognises them as ancestors of master — so they collide with the squashed version in src/smda/aarch64/FunctionCandidateManager.py. Nothing is wrong with the branch; it is the shape a stacked PR takes after its parent is squashed.

The resolution is to drop those three commits. #311's own ten apply cleanly onto current master — I verified by cherry-picking exactly this set and it went through without a conflict:

1ce9b87  fix(aarch64): read the live function set, not only the candidate snapshot
cdb3fab  test(aarch64): pin the gap-run terminator asymmetry rather than remove it
5a4c824  test(aarch64): keep the reasoning for the terminator asymmetry, not the numbers
c7d2b92  perf(aarch64): a hoisted guard is a function's first instruction, not a stray tail
b5cb3e4  test(aarch64): bring the hoisted-guard tests back to the density of their neighbours
bc455c3  perf(aarch64): skip the address scan on an image whose metadata already named its functions
b42b2b8  fix(aarch64): a coverage ratio needs a sample before it can be read
2cfef31  perf(aarch64): turn on the Mach-O address-materialization scan
ac2cb4a  fix(aarch64): read the coverage gate from the image, not from the candidate set
1ac1ab8  test(aarch64): move the pinned fixture baselines this series changes

The fix(ci) commit at the tip can go too — #302 is in master as 7f892b9.

#312 will hit both problems: the same three duplicated commits, plus #309's SmdaConfig.py collision, plus #311's ten once those land. Taking #309#311#312 in that order leaves #312 as the single commit you described.

What #311 verified as, so you know only the rebase is outstanding

Scored the bundled 11-sample ARM64 Mach-O corpus against LC_FUNCTION_STARTS, before and after:

before after delta
TP 1787 1818 +31
FP 1112 1107 −5
TPR 86.916% 88.424% +1.51pp

No cell loses recall. osx.frostyferret gains 11, which lines up with the +8 you attribute to reading the live function set and the +2 to turning the Mach-O scan on; osx.gimmick +11, rustypages +4, lockbit +3, poseidonstealer +2.

The hoisted-guard change shows up on aarch64_static_xored too, which keeps 278 functions while one start moves four bytes earlier. Disassembling around it:

0x40df2c  nop                    <- inter-function padding
0x40df30  cbz x14, #0x40dfc8     <- the new entry
0x40df34  add x4, x1, x2         <- what was booked before

The padding in front settles it — 0x40df30 is the boundary, and the old guard was booking the function one word inside its own body. That is a corrected entry rather than a moved one, and it is the clearest demonstration of your point that the claim in that comment was just false.

I also instrumented the coverage gate across all 13 bundled AArch64 images. It fires exactly once: twelve fall below _METADATA_MIN_CALL_TARGETS and are left to the scan — BlueNoroff_469fd8a280e8 resolves four bl targets in the whole binary, which is precisely the small-sample case your comment cites — and Turtle_5f9cd91d8d1d clears the threshold at 98.3% (1243 of 1265) and skips it, with output bit-identical either way. So on bundled data the gate is a work saving with no accuracy consequence, which is the right way round for something whose two errors are asymmetric.

Full suite on the rebase: 1917 passed, 2 skipped, 2593 subtests. Eight tests fail against pre-#311 source, plus testAArch64MetadataCoverageGate.py erroring on import, so the set is non-vacuous.

Two observations on #311, neither blocking

It flips a default. USE_MACHO_ADDRESS_REF_CANDIDATES goes False → True. That is justified by the gate now deciding per image rather than per container, and the bundled evidence supports it — but it is worth its own line in the changelog rather than arriving inside a follow-ups PR.

Six changes under one title. Having read it, the bundling is defensible: 1, 3 and 5 all feed the same two scans and 2 is comment-only. Splitting them now would cost more than it buys. Mentioning it because the measured effects are per-change while the merge is atomic, so if one of them ever needs reverting it will not come apart cleanly.

Nothing here needs a reply — the rebase is the only thing between these and merging, and #310's result is already in.

@r0ny123

r0ny123 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — that is a much better measurement than the one I opened with, and the rust_pe_gnu_xored scoring against its own COFF table is the control I should have built in the first place.

All five of my open PRs are mergeable again: #309, #311, #312, #299 and #300.

I merged master in rather than rebasing, so nothing force-pushes out from under the review you have already done — the resulting tree is the resolution you describe, keep both option blocks, and the option set is exactly the union of the two sides.

One thing worth passing on, because it bit three of the five. The merge silently reverted #318 the first time. These branches had been carrying #302's commit so their own CI would be readable while master was red. #302 dropped the -> bool on smda_instruction_matches_capstone; #318 put it back with the bool() wrap. Master's net change at that line is therefore zero, so the three-way merge saw only my side as having changed it and took the deletion — quietly undoing your fix in a file none of these PRs has any business touching. Caught it by diffing each merged tree against master and asking why a file outside the PR's scope still differed. All three now leave that file alone.

I checked the general form of it too rather than just the one instance: for every file each branch touches, every line master has added since 572acd7 is present in the merged tree. Zero missing on all five.

#311 needed a merge after all. It was clean when you looked, but #310 landing changed that — #311 stacks on #310, so its own commits are now on both sides and four regions conflicted. All four resolve to the branch's side, which I checked rather than assumed: is_conditional_branch appears twice in both 572acd7 and master, so master added nothing there and the branch's removal of one occurrence is its own intended change.

On the is_pe scope note — you are right, and I would rather it were a decision than a side effect. I have written it into the PR body so it is on the record: the guard also stops seeding from a non-PE .pdata, not just suppression, so a 64-bit ELF with a section of that name no longer seeds candidates from those bytes. I think that is correct on the merits, since RUNTIME_FUNCTION is a PE construct and reading those bytes as unwind records was never sound, but it is a candidate-discovery change inside a PR about refusing gap candidates. Happy to split it into its own change if you would prefer to take the two separately.

Full suite green on each merged tree, with ruff check, ruff format --check and ty 0.0.74 clean: #309 1913 passed, #311 1918, #312 1959, #300 2014, #299 2047 — 1 skipped in each.

And thank you for unit-testing declaredExceptionRangeContaining against the sorted-short-records shape. That is the case I was least sure of, and I would not have known the running-maximum reach array actually held up under it without you checking.

@r0ny123

r0ny123 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Your squash diagnosis is exactly right, and it is what I hit — #311 carried #310's three pre-squash commits, so they collided with the squashed 7d01480 in the candidate manager.

I resolved it by merging master in rather than rebasing, so nothing force-pushes out from under a review you have already done. The result is the same tree you verified. I cherry-picked your exact ten onto current master and compared:

your rebase       tree 38b27ba6eaea1d85f65f11d19d9fff25b6f604f1
#311 as it stands tree 38b27ba6eaea1d85f65f11d19d9fff25b6f604f1

Byte-identical. So squash-merging #311 as it is now lands precisely the content you measured — the rebase would change the history shape without changing a single byte of the outcome. Given that, I would rather not force-push over a reviewed branch for cosmetics, but say the word and I will flatten it to the linear ten if you prefer that shape in the log.

The fix(ci) commit needs no action either — it dropped out by itself when master came in, since #302 is 7f892b9 now. None of these branches carries it any more.

#312 checked the same way. I simulated your order: applied #309 onto master-plus-#311, then diffed #312 against that. It reduces to exactly its own content — five files, the ARM64 option, the aarch64 gap-scan block, the _pdata_ranges hoist into common/ with the matching removal from intel/, and its test file. The single commit you described.

So #309#311#312 works as you laid it out, with no rebase pass needed anywhere.

On the default flip — agreed, that deserves to be visible at release rather than buried in a follow-ups PR. I have put a ready-to-lift paragraph in #311's body under "Changelog note". I deliberately did not touch CHANGELOG.md itself: its entries are per released version and there is no unreleased section, so adding one means choosing a version number, and that is yours.

On six changes under one title — fair, and I will take the point about revert granularity. The risk is real precisely because the measurements are per-change while the merge is atomic; if one of them does need backing out later, it will not come apart cleanly and someone will be reconstructing it by hand. Not worth splitting now, but worth me not repeating on the next one.

…ide a function

A 64-bit PE carries one RUNTIME_FUNCTION record per function, each naming the
extent the unwinder needs. Candidate discovery read that table to seed function
starts and never consulted it again, so the gap scan stayed free to carve extra
entries out of addresses the image had already declared to belong to a routine
starting earlier. Those are exactly the addresses the gap scan reaches: no
reference and no prologue claims them, which is why they survive to it.

Over three 64-bit PE cells of the built Rust corpus, 734 of the 743 false
positives the gap scan alone booked sit strictly inside a declared extent, while
0 of its 878 true positives and 0 of the 5,114 truth functions do. The two
populations separate exactly rather than statistically.

USE_PE_X64_PDATA_INTERIOR_GAPS, on by default, refuses such a candidate and
resumes the scan at that function's end. Four conditions bound it:

- a chained record (UNW_FLAG_CHAININFO) describes a fragment, so its own first
  byte is interior too; a primary record's first byte is the entry;
- a primary extent only suppresses once the function it names is recovered;
- each extent is bounds-checked before it is believed - end above start, end
  within the image, span under the maximum function size, unwind pointer
  DWORD-aligned, and its UNWIND_INFO first byte one of the eight legal values;
- extents are recorded only from a table the image declares.

The last one is the difference between a win and a regression. When no section
table is present - normal for a memory dump - the directory has to be located by
scanning for entries that validate as records, and a table found by searching for
one is a reconstruction. It is good enough to add candidates, where a wrong entry
costs one address, and not good enough to remove regions, where a wrong entry
costs every gap-only function it covers. Trusting a carved table cost 1,274
functions on one dump; declared tables only costs 1.

Extents are deliberately not merged: functions are laid out end-to-start, and
merging adjacent records would collapse .text into a handful of spans in which
every address but the first reads as interior.

Measured, PPV / TPR:

  ByteWeight MSVC PE64, 68 cells   99.080 / 99.838 -> 99.869 / 99.851
  built Rust, 24 cells             75.915 / 97.496 -> 79.477 / 97.584
  built Go, 47 cells               94.036 / unchanged
  malware dumps, 57 cells          92.639 / 98.561 -> 92.645 / 98.552

Recall rises because booking an interior address was not merely a wrong entry:
it started an analysis that ran past the end of the routine the address sat
inside and absorbed the small aligned functions after it.

Bit-identical on every corpus that carries no 64-bit PE exception directory:
140 x86-64 ELF cells, 68 32-bit PE cells (0 of which declare one, against 68 of
68 of the 64-bit ones), and the 56 headerless PE64 dumps, where the only table
available is a carved one.
@r0ny123
r0ny123 force-pushed the upstream-intel-pdata-interior-gaps branch from f8f8d01 to 7d028fc Compare September 7, 2026 17:20
@r0ny123

r0ny123 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

All three are now flat on current master, so the whole stack is in the shape you asked for rather than just #311:

commits head tree
#309 1 7d028fc 0fb222b
#311 10 7bc8107 38b27ba
#312 14 cae175f 0b2ce4d

Every one is content-neutral — each tree is byte for byte what the merge-based version resolved to, so nothing above needs re-measuring. #312 had accumulated more than the two of us spotted: five merge commits, the three pre-squash copies of #310, and three duplicate fix(ci) commits. All gone; its 14 are #309's one, #311's ten and its own three, and it reduces to those three once the first two land.

One thing I should flag rather than let you find it. Rewriting these turned up a leaked reference in two commit messages: a #147 that meant my fork's issue and resolves here to an unrelated actions/checkout bump, plus two dangling "the issue" phrases pointing at fork issues no reader here can reach. Reworded to stand on their own — no content change, trees unaffected. My fault for not catching it before opening these; I have checked the rest of the messages on all three and they are clean now.

#299 and #300 still carry fork PR numbers in their merge-commit subjects (Merge pull request #132 from r0ny123/... and similar), which link to unrelated PRs here. I have left those alone deliberately — flattening two branches you have already reviewed is a bigger rewrite than the problem warrants, and squash-merging drops those subjects anyway. Say the word if you would rather they were cleaned up.

@danielplohmann
danielplohmann merged commit 68f2cfd into danielplohmann:master Sep 8, 2026
26 checks passed
@r0ny123
r0ny123 deleted the upstream-intel-pdata-interior-gaps branch September 8, 2026 10:16
@r0ny123
r0ny123 restored the upstream-intel-pdata-interior-gaps branch September 8, 2026 10:16
danielplohmann added a commit that referenced this pull request Sep 8, 2026
Finishes the draft now that #309, #311 and #312 have landed. Adds their
sections, bumps `VERSION` and `__version__` to 4.5.1, and moves the date to the
release day.

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

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

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

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

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

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

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

Closes #319

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

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

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

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

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

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

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

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

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

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

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

* chore: cut v4.5.1

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

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

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

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

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

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

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

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

Closes #319

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

No corpus loses recall at any step.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants