Skip to content

fix(common): bound the LSDA reads that lead nowhere - #335

Open
r0ny123 wants to merge 1 commit into
danielplohmann:masterfrom
r0ny123:fix/lsda-read-budget
Open

fix(common): bound the LSDA reads that lead nowhere#335
r0ny123 wants to merge 1 commit into
danielplohmann:masterfrom
r0ny123:fix/lsda-read-budget

Conversation

@r0ny123

@r0ny123 r0ny123 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Item 6 of #322.

The diagnosis in the issue is right

_fdeLandingPads checks the table budget before reading, and the comment there says that budget "gates the read as well as the decode". It does not. The budget is charged where a table's length field is parsed, so an LSDA failing before that point — an unsupported LPStart application mode, a short buffer, a TType offset that will not read — costs a read of up to MAX_LSDA_BYTES and charges nothing.

Measured: a section naming MAX_RECORDS distinct such pointers performs 199,999 reads handing back 12.2 GB, spending none of its budget.

But the fix as described would cost recall

"Charge the read at the point it happens" cannot mean charging it to MAX_LSDA_TABLE_BYTES. read_va is asked for MAX_LSDA_BYTES and returns whatever the section holds, so a real image asks for vastly more than it decodes:

cell reads bytes asked for tables decoded
googletest_gcc-x64_O2-static 1,473 92.1 MB ~26 KB
googletest_gcc-arm64_O2-static 1,133 70.8 MB 26,584 B
fmtheavy_linux-gnu-x64_debug 179 11.2 MB

The 8 MB budget would be gone within about 128 of that first cell's 1,473 reads, and every LSDA after that reads as "declares nothing" — landing pads lost on an ordinary C++ binary. The existing comment's "counts what is decoded rather than what is read" is not a wording slip; it is why the current bound works.

What separates the two cases

Only the reads that lead nowhere. Those are rare on real images and universal on hostile ones:

corpus worst cell's failing reads bytes spent on them
built C/C++ ELF (140 cells) 476 29.75 MB
built AArch64 ELF (72 cells) 100 6.25 MB
Rust (24 cells) 0 0
a section built to exhaust it 199,999 12.2 GB

MAX_LSDA_FAILED_READ_BYTES = 256 MB holds the hostile shape to 4,096 reads and 256 MB — a 48× reduction — with 8.6× headroom over the heaviest real image measured.

Verification

All 447 cells across all six corpora are bit-identical, which is the assertion that this reaches only sections built to exhaust it:

corpus cells identical
built AArch64 ELF 72 72
built C/C++ (260-cell matrix) 260 260
Rust 24 24
Go 23 23
ARM64 Mach-O 11 11
malpedia dumps 57 57
  • python -m pytest tests/2,063 passed, 1 skipped, 2,593 subtests
  • ruff check . and ruff format --check . → clean
  • make typecheck → exit 0, 261 diagnostics, identical to master
  • Diff coverage against e9dfe5f100% (9 lines, 0 missing)

Three new cases: that a decoding LSDA is not charged (the condition that keeps the bound off working images — a budget of 1 byte still reaches every table), that an exhausted bound stops calling the reader at all, and that the constant clears the heaviest real image by a stated margin. Verified to fail when the bound is removed.

One thing worth knowing about the shape

The failure has to come before the table's length field to reach the new bound at all. One that fails after it is already charged to the existing budget — which is why the same hostile section built with a too-long length field runs 512 reads rather than 200,000 and needs nothing new. My first attempt at reproducing this used exactly that shape and measured the old bound working, not the gap.

On scope

I said on the issue I would leave this until you picked the number, since sizing it is a corpus judgement. The measurements above make it one: 29.75 MB is the worst real spend, 12.2 GB the hostile one, and any cap between them works. 256 MB is my proposal rather than a finding — change the constant and nothing else moves.

`_fdeLandingPads` checks the call-site table budget before reading an
LSDA, and the comment says that budget "gates the read as well as the
decode". It does not. The budget is charged where a table's length field
is parsed, so an LSDA that fails before that point - an unsupported
LPStart application mode, a short buffer, a TType offset that will not
read - costs a read of up to MAX_LSDA_BYTES and charges nothing at all. A
section naming MAX_RECORDS distinct such pointers reads 12.2 GB over
199,999 reads while spending none of its budget.

Charging those reads to the table budget instead is not the fix. read_va
is asked for MAX_LSDA_BYTES and returns whatever the section holds, so a
real image asks for far more than it decodes - the heaviest cell measured
reads 92 MB to decode 26 KB of tables - and 8 MB would be gone within
about 128 of the 1,473 reads that cell makes, costing landing pads on an
ordinary C++ binary.

Only the failures need a bound, and they separate the two cases. The
heaviest real image spends 29.75 MB on reads that decode to nothing, over
476 of them; the Rust cells spend none; a hostile section spends all of
it. MAX_LSDA_FAILED_READ_BYTES holds that to 256 MB over 4,096 reads,
which is 8.6x headroom over the heaviest real image and a 48x reduction
on the hostile one.

All 447 cells across the built AArch64 ELF, C/C++, Rust, Go, ARM64 Mach-O
and malpedia corpora are bit-identical, which is the assertion that this
reaches only sections built to exhaust it.

The failure has to come before the table's length field to reach the new
bound at all; one that fails after it is already charged, which is why the
same shape built with a too-long length runs 512 reads rather than 200,000
and needs nothing new.
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.

1 participant