Skip to content

Treat a backend that cannot answer a compatibility probe as declining - #829

Open
zardus wants to merge 1 commit into
masterfrom
feature/probe-guard
Open

zardus wants to merge 1 commit into
masterfrom
feature/probe-guard

Conversation

@zardus

@zardus zardus commented Sep 7, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

Loader._static_backend picks a backend by asking each registered default backend's
is_compatible whether it can load the stream. A probe that raises instead of returning
False takes the whole load with it, so one backend's inability to parse bytes that are not
its format decides the answer for every backend behind it.

Two examples on cle master, needing nothing but a stock install and tests/x86_64/fauxware
from angr/binaries:

>>> cle.Loader(io.BytesIO(open("tests/x86_64/fauxware", "rb").read(63)))
ELFParseError: expected 2, found 1

>>> cle.Loader("an-empty-file")
ValueError: cannot mmap an empty file

Neither file is loadable, but neither error is cle's. The first comes out of pyelftools via
ELF.is_compatible, the second out of mmap via UefiFirmware.is_compatible, and in both
cases every later backend, Blob included, is never asked.

Root cause

21 of the 22 registered backends are is_default and get probed. Five are not total
functions of their input:

backend probe raises on
ELF, ELFCore elf.py:289, elfcore.py:75 ELFError, ELFParseError a truncated ELF; the probe builds a whole ELFFile to read e_type
Apk, Jar apk.py:243, jar.py:66 BadZipFile a local file header with no central directory
UefiFirmware uefi_firmware.py:56 AttributeError, ValueError a stream with no fileno; an empty file

ELF is probed second, so a truncated ELF header ends the load before anything but
CARTFile has been asked.

Fix

Hardening those five in turn leaves the next backend exposed and does not stay fixed. The
probes hand arbitrary bytes to pyelftools, zipfile and uefi_firmware, and the set of
exceptions they raise is not bounded: ELF and ELFCore raise both ELFParseError and
ELFError, and uefi_firmware's AttributeError is documented nowhere. The loop is the one
place the property can be stated once.

try:
    if rear.is_default and rear.is_compatible(stream):
        return rear
except Exception as e:  # pylint: disable=broad-except
    log.warning("Skipping the %s backend for %s: %r", rear.__name__, spec, e)
    stream.seek(0)
  • Caught: any Exception; that backend declines and the loop carries on. No probe in cle
    raises deliberately, so nothing intentional is swallowed.
  • Logged: one warning per raising probe, naming the backend and the exception. Not a
    traceback: a probe that cannot answer for bytes that are not its format is the expected
    case, not a fault.
  • Still propagates: BaseException, so KeyboardInterrupt and SystemExit end a load.
  • Still fails: a file no backend claims, with CLECompatibilityError: Unable to find a loader backend ... Perhaps try the 'blob' loader?

loader.py:735 already logs a swallowed exception in exactly this form:
log.warning("Dynamic load failed: %r", e).

The rewind matters because a probe that raises leaves the stream wherever it stopped,
measured at 63, 32 and 512 bytes in for the raisers above, and two probes read before they
seek: PE.is_compatible (pe.py:208) and Minidump.is_compatible
(minidump/__init__.py:92). Rewinding hands them the stream at the start.

Overlap with cle#720, which is ours and open. #720 hardens the UefiFirmware probe,
so it also fixes the empty-file example above and the ar static archives behind it, and it
fixes a second defect this change cannot reach: that probe maps the whole container when
handed a slice of one. Where the two meet they agree on what Loader returns, though #720
also asserts UefiFirmware.is_compatible itself returns False, which this change does not
make true. #720's code does not touch ELF, ELFCore, Apk or Jar; this change covers
them too. The two need no ordering against each other.

Testing

tests/test_backend_probe.py loads the unmodified tests/x86_64/fauxware with
CARTFile.is_compatible patched to consume 100 bytes and raise. CARTFile is probed first
and fauxware is claimed by ELF second, so the assertions are that ELF still loads with
the same entry as a clean load, that the failure is reported with the backend named, and that
the next probe finds the stream at position 0. All three fail on the merge base and pass
here.

Validation: #829 (comment)

session: sharpen

Loader._static_backend picks a backend by asking each registered default
backend's is_compatible whether it can load a stream. A probe that raised
instead of returning False took the whole load down with it, so one backend's
inability to parse bytes that are not its format decided the outcome for every
other backend behind it in the probe order.

Five of the twenty-one default probes raise on ordinary malformed input. ELF
and ELFCore build a whole ELFFile to read e_type, so a truncated ELF raises out
of pyelftools. Apk and Jar call ZipFile, so a local file header with no central
directory behind it raises BadZipFile. UefiFirmware asks the stream for a file
descriptor, so a stream with no fileno raises AttributeError and an empty file
raises ValueError out of mmap.

Guarding each of those in turn leaves the next backend exposed. The probes hand
arbitrary bytes to pyelftools, zipfile and uefi_firmware, and the set of
exceptions they raise is not bounded: ELF and ELFCore raise both ELFParseError
and ELFError, and uefi_firmware's AttributeError is documented nowhere. The
loop is the one place the property can be stated once. A probe that raises is
now logged with its backend named and then treated as declining.

The stream is rewound because a probe that raises leaves it wherever it
stopped, and two probes read before they seek: PE.is_compatible and
Minidump.is_compatible.
@zardus

zardus commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 131f14213ee8eef1d0f7c709ae2717d416514dc1 against baseline 0e77ade3c39a3cee05f65051e57955675e1ac21b.

Working tree clean. The diff is cle/loader.py +6/-2 and a new tests/test_backend_probe.py
(+55); nothing else. git merge-tree against the baseline exits 0.

The regression is load-bearing

The fix was committed first, then reverted against the commit, so both arms run the same
tests:

baseline    3 failed  (ValueError: this probe cannot answer for these bytes)
head        3 passed

Complete local gate

The gate ran on 73dd3a1b, whose only difference from this head is the commit message: both
commits name tree ab0f13f7ebc2426b24b176bc1cacdc4a9bbaddef, and the sha256 of the two
changed files is unchanged from the digests the gate log recorded before it began --
4c9a7716... for cle/loader.py and 20affcee... for tests/test_backend_probe.py. The
gate tests a tree, and this is the same tree.

nix/feature-tree.sh run probe-guard. The log records the head, the baseline, a dirty-path
count of 0 and the sha256 of both changed files, written into it before the run began. It ends
with the line quoted below and the isolation result; it does not record a shell exit status.
Three other gate runs were live at the start, which the isolation check tolerates because its
snapshot covers only the checkouts under repos/.

angr-agentic: workspace checks   177, 733 and 154 tests, all OK
test inputs live in angr/binaries   clean
a tests. import sits in a package   clean
angr-maintain-mono: pipeline     134 tests OK (skipped=2)
archinfo                          38 passed, 34 subtests
pypcode                           46 passed, 187 subtests
pyvex                             65 passed
cle                              264 passed, 9 skipped
angr                            2722 passed, 68 skipped, 2 xfailed, 275 subtests
angr: Rust                       642 passed, 0 failed
isolation                        OK, 70 shared objects in the checkouts under repos/
                                 unchanged in content, inode and mtime
All selected angr workspace test suites passed.

A green gate that skipped a suite is green over less than it appears to be. Absent
components: pysoot, angr-management. Deselected for an absent dependency and did not run:
angr:tests/llm (no pydantic_ai), angr:tests/mcp (no fastmcp). Not requested in a
venv-less feature tree: pre-commit, feature-instances.

Lint and type, against the merge base

run-ci-diff-checks.py, exit 0, no regressions:

lint      cle/loader.py                9.93 -> 9.93
          tests/test_backend_probe.py  new file, 10.00
pyright   cle/loader.py                5 -> 5 errors
          tests/test_backend_probe.py  0 -> 0 errors

Probe survey

21 of the 22 registered backends are is_default and are probed, in the order CARTFile,
ELF, ELFCore, CGC, BackedCGC, Coff, Hex, Apk, Dex, Jar, MachO, Minidump, PE, SRec,
StaticArchive, STM32Backend, TE, UefiFirmware, Universal2, XBE, Blob. Handing each probe
truncated and unusual streams, five are not total:

ELF          elf.py:289           ELFError, ELFParseError    a truncated ELF
ELFCore      elfcore.py:75        ELFError, ELFParseError    same
Apk          apk.py:243           zipfile.BadZipFile         PK magic, no central directory
Jar          jar.py:66            zipfile.BadZipFile         same
UefiFirmware uefi_firmware.py:56  AttributeError, ValueError stream with no fileno; empty file

Measured at the raisers: ELF leaves the stream at 63 bytes in on fauxware[:63] and at 32 on
fauxware[:32], ELFCore the same, Apk and Jar at 512 on simple1.jar[:512]. That is what the
rewind restores. An AST scan of every is_compatible in cle/ finds zero raise statements,
so the catch suppresses no intentional signal.

A third reproducer, on cle's pinned dependencies

arpy==1.1.1 (pyproject.toml:21) has an ArchiveFileData with read, tell and seek and
no fileno, confirmed against that sdist. With it resolved, loading
tests/aarch64/bsd_symdef_archive.a, tracked on angr/binaries master:

baseline    AttributeError: 'ArchiveFileData' object has no attribute 'fileno'
head        CLECompatibilityError: Unable to find a loader backend

The failing member is the __.SYMDEF SORTED index, which no backend claims, so it walks to
UefiFirmware at position 18. Across the corpus sweep ledgers read on 2026-09-07, 5,175 rows
over 3,939 distinct objects terminate at uefi_firmware.py:42:_to_bytes under
loader.py:1329:_static_backend, all from one collection and all ar static archives. Both
this reproducer and that population are also
addressed by cle#720, and by a second in-flight change that makes StaticArchive skip the
symbol index.

Hosted CI on this head

Test macos-15 ran the suite and passed it: 264 passed, 9 skipped in 12.94 s, matching the
local run. It is reported as cancelled because the matrix does not set fail-fast: false, so
it was stopped 13 seconds after its sibling failed, with every step including Run tests
already successful.

Test windows-2022 failed, and not on this change. All 55 test files failed to collect and no
test ran, 54 of them predating this branch. Every one bottoms out in the same import:

.venv\Lib\site-packages\pyvex\native.py:42: in _parse_ffi_str
    os.replace(temp_file_name, cache_location)
E   PermissionError: [WinError 5] Access is denied:
    '...\Temp\tmp8s02m931' -> '...\Temp\pyvex_ffi_parser_cache.runneradmin.<hash>'

That is pyvex renaming its FFI parser cache into place at import pyvex, reached here through
import archinfo, before any cle code runs. Test windows-2022 passed on each of the nine
preceding cle CI runs, and the job has since been re-run on this same head and passed, so the
race is transient and unrelated to backend probing.

Test macos-15 still reads cancelled because a cancelled job cannot be re-run on its own and
re-running the whole workflow to change one mark is not worth the CI. Its suite had already
passed when it was stopped.

A prediction of every check green was recorded before the push, naming two ways it could
fail. Neither was this one, so the prediction was wrong: it reasoned only about what the
change could break and not about the environment the job runs in.

@zardus

zardus commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full loader output for cle.Loader on two inputs no backend can claim, before and after this
change: a 63-byte prefix of tests/x86_64/fauxware handed in as a stream, and a zero-byte
file. The before arm is the merge base 0e77ade3c39a3cee05f65051e57955675e1ac21b; the after
arm is this branch.

Before — backend detection dies inside a probe, so the caller gets pyelftools' and mmap's
exceptions rather than anything cle says, and no later backend is asked:

cle master
--- cle.Loader(BytesIO(fauxware[:63])) ---
elftools.common.exceptions.ELFParseError: expected 2, found 1

--- cle.Loader(<zero-byte file>) ---
ValueError: cannot mmap an empty file

After — the probe that cannot answer is logged with its backend named, the loop carries
on, and the load ends in cle's own error naming the way out:

with this change
--- cle.Loader(BytesIO(fauxware[:63])) ---
WARNING | cle.loader | Skipping the ELF backend for <_io.BytesIO object at 0x78e098578540>: ELFParseError('expected 2, found 1')
WARNING | cle.loader | Skipping the ELFCore backend for <_io.BytesIO object at 0x78e098578540>: ELFParseError('expected 2, found 1')
WARNING | cle.loader | Skipping the ELF backend for <_io.BytesIO object at 0x78e098578540>: ELFParseError('expected 2, found 1')
WARNING | cle.loader | Skipping the ELFCore backend for <_io.BytesIO object at 0x78e098578540>: ELFParseError('expected 2, found 1')
WARNING | cle.loader | Skipping the ELF backend for <_io.BytesIO object at 0x78e098578540>: ELFParseError('expected 2, found 1')
WARNING | cle.loader | Skipping the ELFCore backend for <_io.BytesIO object at 0x78e098578540>: ELFParseError('expected 2, found 1')
cle.errors.CLECompatibilityError: Unable to find a loader backend for <_io.BytesIO object at 0x78e098578540>.  Perhaps try the 'blob' loader?

--- cle.Loader(<zero-byte file>) ---
WARNING | cle.loader | Skipping the UefiFirmware backend for /tmp/tmpvjyno1tt/empty: ValueError('cannot mmap an empty file')
WARNING | cle.loader | Skipping the UefiFirmware backend for /tmp/tmpvjyno1tt/empty: ValueError('cannot mmap an empty file')
WARNING | cle.loader | Skipping the UefiFirmware backend for /tmp/tmpvjyno1tt/empty: ValueError('cannot mmap an empty file')
cle.errors.CLECompatibilityError: Unable to find a loader backend for /tmp/tmpvjyno1tt/empty.  Perhaps try the 'blob' loader?

Neither file becomes loadable; both were unloadable and stay unloadable. What changes is that
the failure is cle's, it names the way out, and every backend behind the raising one now gets
asked. The lines repeat because backend detection runs three times for the same object here,
and in the first case ELF and ELFCore both raise on the same bytes.

@angr-bot

angr-bot commented Sep 7, 2026

Copy link
Copy Markdown
Member

Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_829

@zardus

zardus commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The Test windows-2022 failure on attempt 1 of this run is not this change. It is
pyvex's FFI parser cache losing a race at import. One process raised PermissionError: [WinError 5] Access is denied from os.replace at pyvex/native.py:42; 53 of the other errors
are the module 'pyvex' has no attribute 'vex_ffi' cascade behind it, and the last
is xdist reporting that gw0 and gw1 collected different tests. The job ended
55 errors in 5.03s with none of the 273 collected tests executed
(log), and
cancelled the macOS leg with it, because that matrix sets no fail-fast: false.

Re-running the Windows job on the identical head passed. angr/pyvex#569 fixes the
race; it has been open since 2026-08-22 and is green on 1b919ef.

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