Skip to content

ELF: Tolerate a missing dynamic string table when reading soname and RELRO - #731

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-soname-assert
Open

ELF: Tolerate a missing dynamic string table when reading soname and RELRO#731
zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-soname-assert

Conversation

@zardus

@zardus zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

An ELF whose .dynamic section has sh_link 0 has no dynamic string table, and pyelftools reports that with a bare assert rather than an ELFError. The message-less AssertionError escaped MetaELF.extract_soname(), killing Loader.find_object() and any load that ran the identity heuristic; _get_relro() failed the same way a moment later.

Both are best-effort heuristics with a defined "cannot tell" answer, so they now give it, and extract_soname() asks iter_tags() for DT_SONAME alone so an object with no soname never needs a string table. Such an object still will not load, since ELF.__register_dyn() genuinely needs the strings, but it now fails on its real problem.

The regression builds its inputs by zeroing that one header field in copies of binaries already in angr/binaries.

Validation: #731 (comment)

…RELRO

MetaELF.extract_soname() and _get_relro() both walk a dynamic table with an
unfiltered iter_tags(), which makes pyelftools resolve the dynamic string table
for every tag it yields. An object whose .dynamic section has sh_link 0 has no
string table to resolve, and pyelftools reports that with a bare assert rather
than an ELFError, so it escaped extract_soname()'s ELFError handler and killed
Loader.__init__() and Loader.find_object() outright.

Both are best-effort heuristics with a defined "cannot tell" answer, so give it
instead of raising. extract_soname() also asks for DT_SONAME specifically, which
lets an object that has no soname still fall back to its basename.
@zardus

zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head e0ee7e4e6d4aa54d0f52398fe00228aaad79e66c against baseline b58ea02a446106647cdaae32bdf91b7062404cc1, with angr/binaries at 58841bf0d9e71ca7b215f404ba61e1924c712906, pyelftools 0.33 and CPython 3.12.13 on Linux.

Reproducer, run from a cle checkout with binaries beside it:

import shutil, struct
from elftools.elf.elffile import ELFFile

shutil.copy("../binaries/tests/x86_64/cpp_qualified_symbols.so", "no_dynstr.so")
with open("no_dynstr.so", "r+b") as f:
    e = ELFFile(f)
    i = next(i for i, s in enumerate(e.iter_sections()) if s["sh_type"] == "SHT_DYNAMIC")
    f.seek(e["e_shoff"] + i * e["e_shentsize"] + 40)  # sh_link in Elf64_Shdr
    f.write(struct.pack("<I", 0))
    f.seek(18)  # e_machine -> EM_NONE
    f.write(struct.pack("<H", 0))

import cle
cle.Loader("no_dynstr.so", auto_load_libs=False, main_opts={"backend": "elf"})
  • Baseline: AssertionError with an empty message, via loader.py:802 -> find_object -> _possible_idents -> metaelf.py:486 -> elftools/elf/dynamic.py:168
  • Head: archinfo.arch.ArchNotFound: Can't find architecture info for architecture em_none with 64 bits and Iend_LE endness
  • Regression: pytest tests/test_soname.py — 2 failed, 1 passed on baseline; 3 passed on head
  • Full suite: pytest tests — 205 passed, 9 skipped on head; the new file is the whole delta, 202 passed and 9 skipped without it
  • Lint/format: pre-commit run --all-files — 22 hooks passed, 2 skipped for having no files, nothing rewritten
  • Workspace gate: cle only, the one repository this change touches — pass
  • CI: all 19 checks green, including Lint, Typecheck, the ten Linux test shards, macOS and Windows
  • Decompiler snapshots: angr/dec-snapshots reports the comparison against master as identical, no output changed

Notes:

  • Each of the three production edits is pinned separately: reverting the iter_tags("DT_SONAME") filter, the extract_soname() handler, or the _get_relro() handler individually fails a different assertion in tests/test_soname.py.
  • MetaELF.extract_soname() and _get_relro() return identical answers on baseline and head for all 706 ELF files under binaries/tests, so well-formed objects are unaffected.
  • Dynamic.iter_tags(type) is unchanged back to pyelftools 0.29, the floor pyproject.toml declares.
  • The unnarrowed except also covers python -O, where the stripped assert turns the same input into an AttributeError out of pyelftools instead.
  • The shape is not hypothetical. A corpus sweep hit it on unmodified GNU Guile compiled-bytecode objects — ELFOSABI_STANDALONE, EM_NONE, no .dynstr, .dynamic sh_link 0 — for example sha256 4ab13e78941b373379897ae01707905ab4e2d96026d00d52b143129663081bf7.
  • Objects with no dynamic string table still do not load. They now stop in ELF.__register_dyn(), which genuinely reads the strings.

Corpus measurement of the open queue, 2026-08-15 — this change clears none of the class it was filed against

Correcting the record. The open pull-request queue was scored against 733 objects drawn from a sweep's own failing units (35 error classes, 49 architectures, 16 containers), with each repository's current master as the baseline rather than the revisions the sweep pinned. Each object is loaded with auto_load_libs=False, use_sim_procedures=False and then run through CFGFast(normalize=True, data_references=False, resolve_indirect_jumps=True, force_complete_scan=False) with a 120-second timeout; cleared means an object that fails on master completes, moved means it still fails with a different error.

The class this PR was filed against is the message-less AssertionError out of extract_soname, 283 corpus units. 18 were measured, all of them EM_NONE ELFs. 0 of 18 clear, with this branch alone and with the whole open queue stacked. Every one moves to archinfo.arch.ArchNotFound: Can't find architecture info for architecture em_none with {32,64} bits and Iend_{LE,BE} endness — 8 at 64-bit big-endian, 5 at 32-bit little-endian, 3 at 32-bit big-endian, 2 at 64-bit little-endian.

That also refines the last bullet above. These objects do not go on to stop in ELF.__register_dyn(); they stop earlier, in architecture resolution, because EM_NONE names no architecture. Nothing open resolves em_none, so none of the 283 units becomes loadable in the current queue.

The change still stands on its own terms — pyelftools reporting a missing dynamic string table with a bare assert is not something a best-effort heuristic should die on, and the two handlers are pinned separately by the regression. It should simply not be credited with corpus recovery.

@angr-bot

Copy link
Copy Markdown
Member

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

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