perf(intel): refuse gap candidates the exception directory places inside a function - #309
Conversation
514674c to
536d66a
Compare
|
Reviewed, and I'd like to take this — but it needs a rebase first: GitHub now reports it The conflictOne file, 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 outstandingScored
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: I unit-tested And the regression guard is structural rather than incidental: instrumenting 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 blockerThe Also affected
No rush on any of it — and thanks for the care in these, the self-caught |
|
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 #312This repository squash-merges, so #310 landed as one new commit with a new SHA. #311 still carries #310's three original commits ( 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: The #312 will hit both problems: the same three duplicated commits, plus #309's What #311 verified as, so you know only the rebase is outstandingScored the bundled 11-sample ARM64 Mach-O corpus against
No cell loses recall. The hoisted-guard change shows up on The padding in front settles it — I also instrumented the coverage gate across all 13 bundled AArch64 images. It fires exactly once: twelve fall below Full suite on the rebase: 1917 passed, 2 skipped, 2593 subtests. Eight tests fail against pre-#311 source, plus Two observations on #311, neither blockingIt flips a default. 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. |
|
Thanks — that is a much better measurement than the one I opened with, and the 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 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: On the Full suite green on each merged tree, with And thank you for unit-testing |
|
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 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: 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 #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 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 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.
f8f8d01 to
7d028fc
Compare
|
All three are now flat on current master, so the whole stack is in the shape you asked for rather than just #311:
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 One thing I should flag rather than let you find it. Rewriting these turned up a leaked reference in two commit messages: a
|
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>
A 64-bit PE carries one
RUNTIME_FUNCTIONrecord 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
.pdataextent. The two populations separate exactly, not statistically: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.
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._PDATA_MAX_FUNCTION_SIZE, unwind pointer DWORD-aligned, and itsUNWIND_INFOfirst byte one of the eight legal values. These are the bounds_readExceptionRecordalready applies when it has to find the table itself.Extents are deliberately not merged: functions are laid out end-to-start, and merging adjacent records would collapse
.textinto 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_FUNCTIONrecords. 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.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
0x14001161bon an x64 dump. The image's own.pdatadeclares 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 amov. The function it sits inside is referenced, 133 instructions long, and ends inret.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
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:
linux-gnuandwindows-gnu-x86The 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.
.pdata, declared table.pdata, carved from a headerless dump.eh_frameUSE_ELF_FDE_INTERIOR_GAPSin #300.pdatanextGapCandidateraisesLC_FUNCTION_STARTSReview findings addressed
An adversarial review of this diff found seven items. The two that mattered:
declaredExceptionRangeContainingwas 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.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.pdatashould 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,.pdataand.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; retrather than a prologue shape on purpose: the first version usedpush 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.pygains one method on itsMockBinaryInfo— the double now answers what container it stands for, which is what the new PE check asks.Validation
ruff check/ruff format --checkpass, 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 — theSmdaConfig.pyclash 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 --checkandty0.0.74 clean; the full suite was 1913 passed / 1 skipped / 2591 subtests on this identical tree.Scope note: the
is_peguard also gates seedingPutting this on the record, since it is a real widening of scope that the title does not suggest.
The
is_peguard does not only gate suppression — it also stops.pdataseeding on a non-PE image. Before this, a 64-bit ELF carrying a section named.pdatawould seed candidates from those bytes; now it will not.I think that is right on its own terms:
RUNTIME_FUNCTIONis 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.