Skip to content

feat(common): refuse a candidate the image declares interior, not only a gap one - #327

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

feat(common): refuse a candidate the image declares interior, not only a gap one#327
r0ny123 wants to merge 3 commits into
danielplohmann:masterfrom
r0ny123:feat/fde-interior-analysis-gate

Conversation

@r0ny123

@r0ny123 r0ny123 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Closes #324.

USE_ELF_FDE_INTERIOR_GAPS refuses a candidate that opens strictly inside a range the image's own .eh_frame declares. 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 after #300 are interior to a declared range, and not one of them is a gap candidate: 1,213 come from the initial seeding scans and 1,001 carry a call reference.

This asks the same question at the point analysis is about to begin on a candidate, whatever seeded it. Nothing new is read from the image and no heuristic is added; the evidence and the rule are the ones already shipped.

The guards

Both of the gap rule's conditions apply unchanged. A PLT is exempt, because the whole table sits under one FDE and the range test would read every stub after the first as interior to it. And the range's own start has to be a recovered function, because an FDE can begin in the alignment padding ahead of its function, which leaves the real entry a few bytes in interior to nothing.

A third is new, and the corpus is what found it. A declared range can reach past everything its function's control flow actually arrives at. Refusing an address out there discards bytes nothing else claims — along with any reference only those bytes carry. Without this guard the AArch64 corpus loses three true positives, and all three are the same shape: a real function whose only incoming reference sits in the unreached tail of another function's declared range. In brotli_gcc-arm64_O2-static the entry point's FDE is [0x401e40, 0x401e7c), its control flow stops before 0x401e78, and the bl at that address is the only thing naming the real function at 0x400600.

Requiring the owner's own recovered extent to surround the address costs 163 of the refusals and returns all three. It is also most of why Rust comes out unchanged.

Measured

Against compiler symbol tables, master e9dfe5f → this branch, both trees run back to back on the same machine. Re-measured after the rebase onto #325; the figures are unchanged, which is expected since that PR's one behaviour change is PE-only and this rule is ELF-only. No corpus loses a true positive.

corpus n PPV TPR ΔFP ΔTP
Built C/C++ AArch64 ELF 72 95.994 → 97.063 98.657 −683 0
Built C/C++ ELF (gcc, clang) 140 98.903 → 98.969 unchanged −77 0
Built C/C++ MinGW PE 120 bit-identical bit-identical 0 0
Built Rust (gnu targets) 24 bit-identical bit-identical 0 0
Built Go (pclntab truth) 23 bit-identical bit-identical 0 0
ARM64 Mach-O (LC_FUNCTION_STARTS) 11 bit-identical bit-identical 0 0
Malpedia dumps (.fnmap truth) 57 bit-identical bit-identical 0 0

Micro (pooled) figures; TP and FN are identical in every row. The four bit-identical rows are the control, not filler: 120 PE cells, 23 Go, 11 Mach-O and 57 memory dumps carry no .eh_frame, so any movement there would have meant the rule firing where it has no business firing.

Rust is bit-identical for a reason, not because the rule is inert there. Its images decode their ranges — 740 FDEs on the first cell — and 25 of that cell's 26 false positives are interior to one. The guards decline all of them: over its first four cells, 78 of 94 on the owner's recovered extent and the other 12 on the owner not being recovered.

The rule never refuses an address the image names. Across the four densest AArch64 cells it refuses 4,070 addresses and every one is inferred-only — not a symbol, not an exception record, not language metadata, not a gap or tailcall candidate.

Analysis is slightly faster, because a refused candidate is one nothing then analyses: zlib_gcc-arm64_O2-static 1.26s → 1.19s, lua_gcc-arm64_O2-static 2.04s → 1.92s (median of 3).

Tests

Nine new cases in tests/testFdeInteriorGaps.py. Eight drive declaredInteriorOwner directly, one condition each, and the ninth covers the wiring: it claims one address the fixture does recover and asserts analysis then declines it. Each was checked to fail when the guard it describes is removed, and only that one — dropping the extent guard fails two, the PLT exemption one, the recovered-owner condition one, and removing the call site fails the wiring case.

The bundled fixtures reach the gap scan's own refusal or the collision check before this arm sees anything, which is why the wiring case claims an address rather than asserting over what the fixture happens to drop. An earlier version of it asserted over the recovered set and passed with the feature removed; that is why it is written this way.

This file also had unittest.main() sitting above a test class, so running it directly never defined FdeInteriorGapRuleAArch64Test. Moved to the end.

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

No bundled fixture baseline moves.

One sibling, deliberately left out

Three rules read declared evidence at the gap pointer only: the .pdata exception range, USE_LSDA_LANDING_PADS, and the one widened here. The landing-pad rule has nothing left to reach — of 302 false positives still standing on four AArch64 cells, none is a declared landing pad. The .pdata rule does: 6 of 74 remaining false positives on three MinGW PE cells are inside a declared range.

That one is left for its own change. It moves PE, which is exactly the control that makes this one safe to read, and it needs its own measurement over the PE corpora rather than riding on these numbers. Happy to open an issue for it, or fold it in here if you would rather see them together.

Corrected after review

The docstring's claim about what the rule can reach was wrong. It said that inside the owner's recovered extent "the owner already accounts for the address, so nothing is lost". The rule is consulted immediately after the collision check, which tests membership in code_map — every byte of every recovered instruction — so every address that reaches it is one the owner's analysis never covered, and function_borders is an envelope built from the instruction extremes rather than a coverage map.

It now names the population (alignment holes between blocks, an unreached tail, a data island), names the residual exposure (a real entry carrying no FDE of its own, sitting in one of those holes inside a neighbour's declared range), and says that what bounds it is the measurement rather than the structure. The magnitude stays out of the source: an absolute false-positive count is a property of every other rule in the engine on the day it was taken, while the invariant — no true positive lost — is a property of the mechanism.

A test-isolation fix found in the same pass. analysedFixture() patches declaredInteriorOwner onto the class and restores it in a finally, but the sample write and the disassembler construction sat outside the try; either raising would leave the claim installed for every later test in the process. Everything after the patch is inside the try now.

Full suite on the current tip: 2,119 passed, 1 skipped, 2,596 subtests, ruff clean, make typecheck exit 0.

@danielplohmann

Copy link
Copy Markdown
Owner

Reviewed against the source rather than the figures, since the figures are not reproducible here. The guards each have their own case and each case fails without its guard — the PLT exemption, the recovered-owner test, the recovered-extent test, the range-start exclusion and the flag control are eight unit cases, and testAnalysisItselfDeclinesWhatTheRuleClaims is what shows analysis consults the rule at all rather than the gap scan doing the work. Claiming one address rather than asserting over what the fixture happens to drop is the right way to write that, given what I measured below.

One thing in the docstring does not hold, and it is the sentence the next person will lean on

Inside the extent the owner already accounts for the address, so nothing is lost.

The hook sits immediately after state.isProcessedFunction(), which is start_addr in self.disassembly.code_map — every byte of every recovered instruction. So any candidate that lands on code the owner actually decoded is already gone by the collision path, and the only addresses that can reach declaredInteriorOwner are ones the owner's analysis never covered: alignment holes between its blocks, an unreached tail inside its envelope, a data island. function_borders is a (min, max) envelope built from the instruction extremes in FunctionAnalysisState, not a coverage map.

Confirmed on the two refusals I could provoke: both report in_code_map=False.

That does not make the rule wrong — it is the same evidence, and your 0 true positives lost across 212 ELF cells is what says it costs nothing. But the reason stated is not the reason it works, and the residual exposure is the one the sentence denies: a real entry with no FDE of its own, sitting in a hole inside another routine's declared range and recovered envelope, loses whatever reference only its bytes carry — the same shape as the three functions the extent guard rescued, one scale down. Anything carrying its own FDE is safe, since declaredFdeRangeContaining excludes a range's own start, which testARangeStartIsNotInteriorToItself pins.

Worth rewriting to say what the population is and that the measurement is what bounds the risk. A sentence, not a change of code.

What the bundled fixtures say about this rule: nothing

Instrumented declaredInteriorOwner over every bundled ELF that reaches it:

fixture refusals
elf_cxx_landing_pads_x64_xored 0
elf_cxx_landing_pads_arm64_xored 0
elf_cet_landing_pads_x64_xored 0
elf_ehframe_hdr_x64_xored 0
aarch64_static_xored 0

Exactly as your test's docstring says, and I am not asking for a fixture that changes it — by the standard we just settled on #322 item 2, the mechanism is pinned in-repo and the magnitude rests on your corpora. It does mean the v4.7.0 changelog entry has to say so: under the provenance rule from #323 the −683 and −77 go in as measured on your corpora and not reproduced here, the way v4.5.1 marked #310's and #312's figures.

Sequencing

I am holding this rather than merging it now, because #329 is stacked on it and has a blocking finding of its own — it breaks testArm64PeInteriorGapFixture on current master, details over there. Since the fix lands in the shared helper this PR introduces, doing both in one rebase over a043145 is less work than merging this and then rebasing that. Suite on your two plus #335 stacked over master: 2,127 passed, 2 skipped, 2,596 subtests, and the single failure is the #333 one described on #329.

@r0ny123

r0ny123 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

You are right, and it is worse than a loose sentence — it stated the opposite of what the code does.

Confirmed against the source here: isProcessedFunction() is start_addr in self.disassembly.code_map, code_map is written per byte of every recovered instruction in FunctionAnalysisState, and function_borders[start] = (fn_min, fn_max) is built from the instruction extremes in the same loop. So the collision path has already taken every address the owner decoded, and the only ones that can reach the rule are precisely the ones it did not.

The paragraph now says what the population is and what bounds the risk:

Inside the extent, the address is never one the owner decoded: a candidate landing on recovered code is refused a step earlier against a byte-level code map, and function_borders records only the extremes of that code, not its coverage. What reaches here sits in the holes — alignment between blocks, an unreached tail, a data island — so the exposure is a real entry with no FDE of its own in one of them, which loses whatever reference only its bytes carry. An entry that carries its own FDE is never interior to it, so the shape needs a routine covered by a neighbour's range, and nothing in the format forbids that; what bounds it is measurement rather than structure, no true positive lost across the ELF corpora this was measured over.

The magnitude stays out of the docstring deliberately — an absolute false-positive count is a property of every other rule in the engine on the day it was taken, and belongs in the entry that dates it. The invariant is what keeps its meaning in a comment.

One more, from re-reading the diff after yours

analysedFixture() patches declaredInteriorOwner onto the class and restores it in a finally, but the sample write and the disassembler construction sat outside the try. Either raising would leave the claim installed for every later test in the process — one failure becoming a cascade that does not name its cause. Everything after the patch is inside the try now.

On the changelog

Agreed, and it is yours to write at release: under the provenance rule the −683 and −77 go in as measured on corpora not bundled here, the way v4.5.1 marked the earlier two. Nothing in this PR's tests rests on them — the bundled ELF fixtures produce 0 refusals, exactly as the test docstring says.

Sequencing

#329's blocking finding is fixed and pushed; the shared-helper change lands there, on top of this. Master is merged into both rather than rebased — I would rather not force-push over a branch you have already reviewed, so taking the rebase at merge is yours.

Full suite green on this tip: 2,119 passed, 1 skipped, 2,596 subtests. ruff check . and ruff format --check . clean, make typecheck exit 0.

…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.
The docstring said that inside the owner's recovered extent "the owner
already accounts for the address, so nothing is lost". It does not. The
rule is consulted immediately after the collision check, which tests
membership in a byte-level code map, so every address that reaches it is
one the owner's analysis never covered; function_borders holds only the
extremes of that code, not its coverage.

That leaves a residual exposure the old sentence denied: a real entry
carrying no FDE of its own, sitting in a hole inside a neighbour's
declared range and recovered envelope. Nothing in the format rules that
shape out -- an entry with its own FDE is never interior to it, since a
range's own start is excluded -- so what bounds it is the measurement,
not the structure. Say that instead.
analysedFixture() patches declaredInteriorOwner on the class and restores
it in a finally, but the sample write and the disassembler construction sat
outside the try. Either raising would leave the claim installed for every
later test in the process, turning one failure into a cascade that does not
name its cause. Put everything after the patch inside the try.
@r0ny123
r0ny123 force-pushed the feat/fde-interior-analysis-gate branch from 891f046 to a4c1513 Compare September 11, 2026 08:30
r0ny123 added a commit to r0ny123/smda that referenced this pull request Sep 11, 2026
…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.
@r0ny123

r0ny123 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Rebased over a043145 as you asked, replacing the merge commit — three linear commits now, and the tree is unchanged by it (git diff against the pre-rebase tip is empty), so the suite, lint and typecheck results reported above still stand as measured.

a4c1513 test(tests): keep a failed claim from leaking onto the class
12ec24a docs(common): state what the interior refusal can actually reach
c4e3ca1 feat(common): refuse a candidate the image declares interior, not only a gap one

#329 is rebased onto this tip, so it still stacks cleanly and has to merge after it.

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.

AArch64 ELF: 96% of function-start false positives fall inside a range .eh_frame already declares

2 participants