Skip to content

feat(common): let the exception directory refuse an interior candidate too - #329

Open
r0ny123 wants to merge 2 commits into
danielplohmann:masterfrom
r0ny123:feat/pdata-interior-analysis-gate
Open

feat(common): let the exception directory refuse an interior candidate too#329
r0ny123 wants to merge 2 commits into
danielplohmann:masterfrom
r0ny123:feat/pdata-interior-analysis-gate

Conversation

@r0ny123

@r0ny123 r0ny123 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Closes #328. Stacked on #327 — it extends the helper that PR introduces, so it must merge after it. The diff shown against master will include #327's commit until then; this PR's own commit is 2efa621.

Third of the three rules named in the #324 sweep, and the last one with anything left to reach. #327 widened the .eh_frame arm to a candidate from any source; this does the same for the PE exception directory, under the guards that arm established.

The rule

The same two conditions, unchanged: the RUNTIME_FUNCTION record's own function has to be recovered, and its recovered extent has to surround the address. The two evidence sources stay format-disjoint exactly as the gap scan's comment already notes — _pdata_ranges is only ever filled from a PE exception directory and the .eh_frame ranges decode nothing unless lief reports an ELF — so no address is ever arbitrated between them.

One deliberate difference from the gap scan. There, a fragment record is evidence on its own: it says the extent is part of some other function, and the scan needs nothing more. Here it is not taken as that. A fragment's own start is not the function covering the address, so it fails the recovered-owner test and declines rather than refusing. At a point where the better evidence — an actual recovered owner — is available, using the weaker form would be the wrong reading. No fragment record appeared among the interior false positives in the sample either way.

Measured

feat/fde-interior-analysis-gate (#327) → this branch, so the figures isolate this arm.

corpus n TP ΔFP bit-identical cells
Built C/C++ MinGW PE x64 120 unchanged −47 109 / 120
Built Rust, windows-gnu-x64 2 unchanged −2
Built C/C++ ELF (gcc, clang) 140 unchanged 0 140 / 140
Built C/C++ AArch64 ELF 72 unchanged 0 72 / 72
Built Go 23 unchanged 0 23 / 23
ARM64 Mach-O 11 unchanged 0 11 / 11
Rust, ELF and 32-bit PE cells 22 unchanged 0 22 / 22
Malpedia dumps 57 unchanged 0 57 / 57

No corpus loses a true positive, and only PE x64 cells differ anywhere — which is the control this arm has to pass, the mirror of #327's ELF-only one.

The part worth reading before merging

On memory dumps this evidence does not exist. None of the 57 malpedia dumps declares an exception directory at all — _pdata_ranges is empty, so the rule never reaches its first condition. That is the corpus SMDA cares most about, and it is the honest limit of this change.

Between that and 47 false positives on one PE corpus, this is a small win, and #328 says so rather than selling it. It is here because it is free of recall risk under guards that are already measured, and because leaving one of three siblings unwidened without a reason is worse than either doing it or declining it on the record. If you would rather close #328 and keep the surface smaller, that is a reasonable call and the numbers are the argument either way.

One thing I had wrong

I filed #328 saying 14 of 1,038 on a 20-cell sample, and scaled that to roughly 80 across 120 cells. The real figure is 47. The sample over-predicted; the table above is the measurement.

I also assumed the Rust corpus was ELF, which is why the two moving cells looked like an anomaly at first. They are windows-gnu-x64 — PE x64, exactly where this applies. The corpus is mixed, and every one of its ELF and 32-bit cells is bit-identical.

Tests

Seven cases in tests/testFdeInteriorGaps.py::DeclaredInteriorOwnerPeTest, one condition each, exercising the arm through a stubbed exception directory so the ELF and PE arms are tested apart. Each was checked to fail when the guard it describes is removed: dropping the arm fails two, dropping its flag check fails one.

  • python -m pytest tests/2,076 passed, 1 skipped, 2,593 subtests
  • ruff check . and ruff format --check . → clean
  • make typecheck → exit 0, 0 error-level diagnostics, no new diagnostics
  • Diff coverage against this PR's base → 100% (12 lines, 0 missing)

No bundled fixture baseline moves.

…y a gap one

The .eh_frame rule added in danielplohmann#300 refuses a candidate that opens strictly
inside a range the image declares, and it is reached only from the gap
scan: it tests self.gap_pointer, so candidates from the prologue scan,
from branch targets and from the tailcall paths never meet it. On the
72-cell AArch64 ELF corpus 2,245 of the 2,484 false positives still
standing are interior to a declared range, and none of them is a gap
candidate.

Ask the same question where analysis is about to begin on a candidate
from any source. Both of the gap rule's guards apply unchanged: a PLT is
exempt because the whole table sits under one FDE, and the range's own
start has to be a recovered function because an FDE can begin in the
alignment padding ahead of its own.

A third guard is new, and the corpus is what found it. A declared range
can reach past everything its function's control flow arrives at, and
refusing an address out there discards bytes nothing else claims along
with any reference only those bytes carry. Without it the AArch64 corpus
loses three functions, each the sole target of a call sitting in exactly
that unreached tail. Requiring the owner's own recovered extent to
surround the address costs 163 of the refusals and returns all three.

Measured against compiler symbol tables, no corpus losing a true
positive:

  72 AArch64 ELF cells    PPV 95.994 -> 97.063   -683 FP, TP and FN identical
  140 built C/C++ ELF     PPV 98.903 -> 98.969    -77 FP, TP and FN identical

The 120 MinGW PE cells, 23 Go cells, 11 ARM64 Mach-O cells and all 57
malpedia dumps are bit-identical, which is the control that it reaches
only images carrying an .eh_frame. Rust is bit-identical too, and not
because the rule is inert there: its images decode their ranges and 25
of 26 false positives on the first cell are interior to one, but the
guards decline all of them. Analysis is slightly faster, because a
refused candidate is one nothing then analyses.

Also moves a stray unittest.main() in the test file, which sat above a
test class and so left that class undefined when the file is run
directly.
…e too

Third of the three rules that read declared evidence only at the gap
pointer, and the last one with anything left to reach. danielplohmann#327 widened the
.eh_frame arm; this does the same for the PE exception directory, under
the guards that arm established: the record's own function has to be
recovered, and its recovered extent has to surround the address.

A fragment record is not the shortcut it is in the gap scan. Its own
start is not the function covering the address, so it fails the
recovered-owner test and declines rather than refusing, which is the
conservative reading at a point where a better one is available.

It is worth much less than the .eh_frame arm, which is why it is a
change of its own rather than part of danielplohmann#327:

  120 MinGW PE x64 cells   -47 FP at identical TP and FN
  2 Rust windows-gnu-x64    -2 FP at identical TP and FN

Nothing else moves. The 140 x86-64 ELF cells, 72 AArch64 ELF cells, 23
Go cells, 11 ARM64 Mach-O cells, the 32-bit and ELF Rust cells and all
57 malpedia dumps are bit-identical, and only PE x64 cells differ in any
corpus. The dumps are the reading worth keeping: none of the 57 declares
an exception directory at all, so this evidence does not reach a mapped
image, which is the corpus this project cares most about.
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.

The .pdata arm of the gap-only rules: measured at 47 false positives, and inert on dumps

1 participant