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

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

Problem

An ELF whose .dynamic section has sh_link 0 names no dynamic string table, and pyelftools reports that with a bare assert. The message-less AssertionError escapes MetaELF.extract_soname, so on a copy of tests/x86_64/cpp_qualified_symbols.so with that one header field cleared:

soname(cpp_qualified_symbols.so): RAISED builtins.AssertionError:
  File ".../cle/backends/elf/metaelf.py", line 486, in extract_soname
    for tag in seg.iter_tags():
  File ".../elftools/elf/dynamic.py", line 168, in _get_stringtable
    assert isinstance(self._stringtable, _StringTable)

Loader.find_object() runs that heuristic over files it never loaded, so it dies too, and _get_relro() fails the same way a moment later. Both run before the architecture is resolved, so an object that is broken in some other way — here one whose e_machine is also EM_NONE — never reaches its real diagnosis.

Root cause

extract_soname iterated the whole dynamic table to find one tag:

for tag in seg.iter_tags():
    if tag.entry.d_tag == "DT_SONAME":
        return maybedecode(tag.soname)

pyelftools resolves the dynamic string table for every tag it yields, so an object with no string table cannot be iterated at all, even though DT_SONAME is the only tag here that needs a string. The surrounding handler was except elftools.common.exceptions.ELFError, which an AssertionError walks straight past. _get_relro has the identical shape in [tag for tag in dyn_sec.iter_tags() if tag.entry.d_tag == "DT_FLAGS"], and DT_FLAGS is a number.

Fix

Both are best-effort heuristics with a defined "cannot tell" answer, so they now give it. extract_soname asks for seg.iter_tags("DT_SONAME"), so no other tag's strings are resolved, and an object with no soname never needs a string table at all; _get_relro returns Relro.PARTIAL, which is what not being able to confirm BIND_NOW already means. The handler is widened to except Exception deliberately: which of ELFError and AssertionError a malformed file produces has already changed between pyelftools releases.

soname(cpp_qualified_symbols.so): 'cpp_qualified_symbols.so'
soname(liblzma.so.5.6.1): None
find_object(cpp_qualified_symbols.so): None
Loader(no_dynstr.so): RAISED archinfo.arch.ArchNotFound: ... em_none ...

Nothing tries to recover a soname from a broken string table, and such an object still will not load, since ELF.__register_dyn() genuinely needs the strings — it now fails on that.

Testing

tests/test_soname.py::test_extract_soname_without_dynamic_strtab asserts MetaELF.extract_soname(no_soname) == "cpp_qualified_symbols.so"; ::test_extract_soname_reads_dt_soname pins the unmutated tests/x86_64/liblzma.so.5.6.1 still answering liblzma.so.5; ::test_load_without_dynamic_strtab pins that the load fails in the architecture lookup. All three build their inputs by zeroing that one header field in copies of binaries already on angr/binaries master, and all three fail on the merge base.

Validation: #731 (comment)

session: sharpen

@zardus

zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 2cbeef73fa9ad718b0659b6b5673626c979d9577 against baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b, 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.

Re-keyed 2026-08-28. The figures above were measured at e0ee7e4e6d4aa54d0f52398fe00228aaad79e66c on baseline b58ea02a446106647cdaae32bdf91b7062404cc1, which is the head the opening line named until now; the branch is at 2cbeef73fa9ad718b0659b6b5673626c979d9577 on 46a37333f4f59b0facf8774ee743ebc4cc074e9b. git range-diff b58ea02a446106647cdaae32bdf91b7062404cc1..e0ee7e4e6d4aa54d0f52398fe00228aaad79e66c 46a37333f4f59b0facf8774ee743ebc4cc074e9b..2cbeef73fa9ad718b0659b6b5673626c979d9577 reports every commit unchanged and git diff e0ee7e4e6d4aa54d0f52398fe00228aaad79e66c 2cbeef73fa9ad718b0659b6b5673626c979d9577 differs only by master's own advance (23 files changed, 1358 insertions(+), 92 deletions(-)). Master touched none of the files this change touches between the two baselines, so every figure above still describes this head.

@angr-bot

Copy link
Copy Markdown
Member

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

@zardus
zardus force-pushed the feature/fix-cle-soname-assert branch from e0ee7e4 to faf9706 Compare August 22, 2026 14:23
…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 force-pushed the feature/fix-cle-soname-assert branch from faf9706 to 2cbeef7 Compare August 26, 2026 22:46
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full soname and RELRO report before and after this change. The script copies tests/x86_64/cpp_qualified_symbols.so and tests/x86_64/liblzma.so.5.6.1 and sets the sh_link of each copy's .dynamic section header to 0, which is how an object with no dynamic string table encodes it; one copy additionally has e_machine rewritten to EM_NONE. The unmutated liblzma.so.5.6.1 is the control.

Before — every query dies with a message-less AssertionError from inside pyelftools, including Loader.find_object() on a file it never loaded and the load of the EM_NONE copy:

cle at the merge base, 46a3733
cle: <cle at the merge base>/cle/__init__.py
MUTATION: copies of the fixtures below with .dynamic sh_link set to 0 (SHT_NULL),
          i.e. an object that names no dynamic string table at all; in <TMP>/

-- control: unmutated liblzma.so.5.6.1, which has a DT_SONAME
soname(liblzma): 'liblzma.so.5'

-- mutated, no DT_SONAME present: basename should stand in
soname(<TMP>/cpp_qualified_symbols.so): RAISED builtins.AssertionError: 
    | Traceback (most recent call last):
    |   File "<cle at the merge base>/cle/backends/elf/metaelf.py", line 486, in extract_soname
    |     for tag in seg.iter_tags():
    |                ^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 202, in iter_tags
    |     yield DynamicTag(tag, self._get_stringtable())
    |                           ^^^^^^^^^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 168, in _get_stringtable
    |     assert isinstance(self._stringtable, _StringTable)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    | AssertionError

-- mutated, DT_SONAME present but unresolvable: None
soname(<TMP>/liblzma.so.5.6.1): RAISED builtins.AssertionError: 
    | Traceback (most recent call last):
    |   File "<cle at the merge base>/cle/backends/elf/metaelf.py", line 486, in extract_soname
    |     for tag in seg.iter_tags():
    |                ^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 202, in iter_tags
    |     yield DynamicTag(tag, self._get_stringtable())
    |                           ^^^^^^^^^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 168, in _get_stringtable
    |     assert isinstance(self._stringtable, _StringTable)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    | AssertionError

-- Loader.find_object runs the same heuristic on files it never loaded
find_object(<TMP>/cpp_qualified_symbols.so): RAISED builtins.AssertionError: 
    | Traceback (most recent call last):
    |   File "<cle at the merge base>/cle/backends/elf/metaelf.py", line 486, in extract_soname
    |     for tag in seg.iter_tags():
    |                ^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 202, in iter_tags
    |     yield DynamicTag(tag, self._get_stringtable())
    |                           ^^^^^^^^^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 168, in _get_stringtable
    |     assert isinstance(self._stringtable, _StringTable)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    | AssertionError
find_object(<TMP>/liblzma.so.5.6.1): RAISED builtins.AssertionError: 
    | Traceback (most recent call last):
    |   File "<cle at the merge base>/cle/backends/elf/metaelf.py", line 486, in extract_soname
    |     for tag in seg.iter_tags():
    |                ^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 202, in iter_tags
    |     yield DynamicTag(tag, self._get_stringtable())
    |                           ^^^^^^^^^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 168, in _get_stringtable
    |     assert isinstance(self._stringtable, _StringTable)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    | AssertionError

-- loading a mutated object whose e_machine is also EM_NONE:
   should fail in the arch lookup, not in the soname heuristic or the RELRO check
Loader(<TMP>/no_dynstr.so): RAISED builtins.AssertionError: 
    | Traceback (most recent call last):
    |   File "<cle at the merge base>/cle/backends/elf/metaelf.py", line 486, in extract_soname
    |     for tag in seg.iter_tags():
    |                ^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 202, in iter_tags
    |     yield DynamicTag(tag, self._get_stringtable())
    |                           ^^^^^^^^^^^^^^^^^^^^^^^
    |   File "elftools/elf/dynamic.py", line 168, in _get_stringtable
    |     assert isinstance(self._stringtable, _StringTable)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    | AssertionError
expected failure: ArchNotFound

LOG (none)

After — the heuristics answer, and the EM_NONE copy fails on its real problem in ELF.extract_arch:

with this change, 2cbeef7
cle: <cle with this change>/cle/__init__.py
MUTATION: copies of the fixtures below with .dynamic sh_link set to 0 (SHT_NULL),
          i.e. an object that names no dynamic string table at all; in <TMP>/

-- control: unmutated liblzma.so.5.6.1, which has a DT_SONAME
soname(liblzma): 'liblzma.so.5'

-- mutated, no DT_SONAME present: basename should stand in
soname(<TMP>/cpp_qualified_symbols.so): 'cpp_qualified_symbols.so'

-- mutated, DT_SONAME present but unresolvable: None
soname(<TMP>/liblzma.so.5.6.1): None

-- Loader.find_object runs the same heuristic on files it never loaded
find_object(<TMP>/cpp_qualified_symbols.so): None
find_object(<TMP>/liblzma.so.5.6.1): None

-- loading a mutated object whose e_machine is also EM_NONE:
   should fail in the arch lookup, not in the soname heuristic or the RELRO check
Loader(<TMP>/no_dynstr.so): RAISED archinfo.arch.ArchNotFound: Can't find architecture info for architecture em_none with 64 bits and Iend_LE endness
    | Traceback (most recent call last):
    |   File "<cle with this change>/cle/backends/elf/elf.py", line 130, in __init__
    |     self.set_arch(self.extract_arch(self._reader))
    |                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |   File "<cle with this change>/cle/backends/elf/elf.py", line 356, in extract_arch
    |     return archinfo.arch_from_id(arch_str, "le" if reader.little_endian else "be", reader.elfclass)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |   File "archinfo/build/__editable__.archinfo-9.3.3.dev0-py3-none-any/archinfo/arch.py", line 917, in arch_from_id
    |     raise ArchNotFound(
    | archinfo.arch.ArchNotFound: Can't find architecture info for architecture em_none with 64 bits and Iend_LE endness
expected failure: ArchNotFound

LOG (none)

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

How much of a corpus's failure surface this removes, measured rather than
argued.

Sample. 12,000 objects drawn uniformly at random, from a seeded permutation,
out of a 624,920-object internal corpus of compiler- and vendor-produced
binaries; 11,989 were retrievable and probed. Rates carry 95% Wilson intervals.

Method. Each object is loaded with the catalogue's declared load recipe and
CFGFast is taken as far as the failure under test. A 1,241-object subset — the
whole of every failure class under study plus 722 objects that already reach CFG
— is probed against master (eac0e5540516b9199dd6a91933e80dc774ea3eac) and
against this branch's head (2cbeef73fa9ad718b0659b6b5673626c979d9577) in one
environment, so before and after are the same objects. The comparison is keyed
on exception type and function, not on file:line, because a patch that edits
the failing file moves every line below its hunk.

Before. 53 / 11,989 = 0.44% of the sample (CI 0.34–0.58) die on
pyelftools' assert self._get_stringtable() while cle reads the dynamic table,
and they split by where the assertion fires: 8 at elftools/elf/dynamic.py:168,
reached from extract_soname and _get_relro, and 45 at :183, reached from
ELF.__register_dyn.

After. This head clears all 8 of the :168 group — 0.07% of the sample
(CI 0.03–0.13). They are Guile 3.0 compiled-bytecode .go objects, ELF
containers that declare e_machine = EM_NONE; 6 Linux and 2 GNU/Hurd. None of
the 8 reaches CFG on this head, because past the assertion they stop at
ArchNotFound: Can't find architecture info for architecture em_none with 64 bits and Iend_BE endness. So what this change buys, measured, is that a missing
dynamic string table stops being an AssertionError from inside pyelftools and
becomes an honest architecture-resolution failure one layer up.

Residual. The 45 at :183 are unchanged by this head; #723 clears those,
and all 45 of them reach CFG on it. The two changes are disjoint: neither clears
any of the other's objects.

Control. 722 objects that already reached CFG on master are unchanged on
this head — 0 of 722 differ.

The corpus is not redistributable, so its objects are described by architecture,
format and OS rather than named; none of the 53 is byte-identical to anything
tracked in angr/binaries.

session: sharpen

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