Skip to content

ELF: do not fail the load over a dynamic segment with no string table - #815

Open
zardus wants to merge 1 commit into
masterfrom
feature/elf-dynamic-without-strtab
Open

zardus wants to merge 1 commit into
masterfrom
feature/elf-dynamic-without-strtab

Conversation

@zardus

@zardus zardus commented Sep 4, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

CLE cannot load u-boot's x86-64 image at all. cle.Loader(path, auto_load_libs=False)
raises:

elftools.common.exceptions.ELFError: SHT_SYMTAB section points at section 0 of type SHT_NULL, expected SHT_STRTAB
  cle/backends/elf/metaelf.py:41 in _get_relro
  elftools/elf/elffile.py:191 in _get_linked_strtab_section

The object is u-boot v2026.07 for qemu-x86_64_defconfig, gcc 13.3.0, -Os -gdwarf-4
(sha256
8224b8886308b6b00282815df304b0f75831de3516df77406a05b85c40838a43). It is 24 MB and carries
a good .symtab: 13,273 entries, 4,372 of them STT_FUNC, and 4,243 functions with
DWARF ranges. board_init_f at 0x113b65d, board_init_r at 0x113bbb9 and
do_bootm at 0x1123650 are all in it and CLE sees none of them, because the
exception comes out of the constructor and there is no object at all.

Both readelfs read the file: GNU readelf 2.46 prints its dynamic section with no
complaint, llvm-readelf 21 warns about the same dangling link and carries on, both
exit 0.

Root cause

The file has no dynamic string table, and pyelftools will not go near a dynamic
segment without one. CLE walks into that twice.

_get_relro asks whether a PT_GNU_RELRO program header is present. It asks through
iter_segments(), and pyelftools builds each segment before its type can be read;
building the PT_DYNAMIC one walks the entire section table for the matching .dynamic
section. That table, read straight out of the file:

15 .hash    SHT_HASH    sh_link=20
20 .dynsym  SHT_DYNSYM  sh_link=0    (SHT_NULL)   size 0x18, one entry: the null symbol
40 .symtab  SHT_SYMTAB  sh_link=41 -> .strtab     13,273 entries

There is no .dynstr. u-boot's linker script discards it and GNU ld leaves .dynsym's
link pointing at section 0, so the walk goes .hash -> .dynsym -> string table and
raises. The whole load is lost to a question whose answer is Relro.NONE: the file has
three program headers, PT_LOAD, PT_DYNAMIC and PT_GNU_STACK, and no RELRO at all.

__register_dyn is the second. pyelftools resolves a tag's string as it hands the tag
back, so iter_tags() wants the dynamic string table for every tag rather than for the
four whose value is a string offset. This object's dynamic array is DT_DEBUG,
DT_RELA, DT_RELASZ, DT_RELAENT, DT_FLAGS_1, DT_RELACOUNT, DT_NULL -- no
DT_STRTAB, and not one string among them. pyelftools 0.33 asserts, 0.29 through 0.32
raise. CLE already handles a missing dynamic string table three lines further down:

        strtab = seg_readelf._get_stringtable()
        if strtab is None:
            log.warning("Unexpected return value from pyelftools: stringtable object is None.")
            return

The tag loop above it just runs first.

Fix

_get_relro reads the program headers rather than materialising the segments, which is
what the question was. __register_dyn takes the string table before the loop rather than
after it, treats "there is none" as an answer rather than an error, and records every
tag's value either way, skipping only the four string-valued tags when there is no table
to resolve them with. Objects that have one behave exactly as before.

It does not close the next line. get_section_by_name(".dynamic") walks the section
table too, so a file with a PT_GNU_RELRO header and a table pyelftools refuses still
fails in _get_relro. Nothing I can measure has both.

It is also not the whole of _get_relro: the open #731 guards the DT_FLAGS read further
down and MetaELF.extract_soname, which fail the same way. The two do not overlap and
merge cleanly in either order. #731 says an object like this "still will not load, since
ELF.__register_dyn() genuinely needs the strings"; it does not, when no tag carries
one.

It also does not restore the sections. On master the load now
completes with zero sections and zero symbols, because a second, independent failure --
the same dangling sh_link seen from ELF.__init__ -- makes CLE discard the section
header table. That is #802, which at 03316c16 does not overlap this diff and
merges with it cleanly. With both in, the object loads with its 43 sections, 13,273 symbols and 4,372
function symbols, and the loaded image is byte-identical to the file's PT_LOAD
contents.

Testing

No regression test comes with this. Nothing tracked in angr/binaries reproduces it: of
its 858 ELF files, none raises out of _get_relro, and every one of the 606 that has a
PT_DYNAMIC segment yields a dynamic string table, so there is nothing already in the
tree to write a test against. The image above is 24 MB and does not belong in a test
repository. Objects that do reproduce the second site are public and small -- the
smallest I have is a 6,912-byte Alpine debug ELF -- so a fixture is available and I will
add one on request; it is left out here so this change does not depend on an open
angr/binaries pull request.

Loading all 858 before and after gives identical results on every field recorded, down to
a sha256 over every backer of the loaded image and the traceback frames of the ten that
fail on both sides. Both hunks run on that corpus, and the 598 files carrying a
DT_NEEDED, DT_SONAME, DT_RUNPATH or DT_RPATH resolve every one of those strings to
the same bytes. The cle suite is 3 failed, 257 passed, 9 skipped either way, the three
being pre-existing missing Mach-O fixtures.

Beyond that image, a corpus sweep here has at least 645 further ELF objects failing on
master in __register_dyn for want of a dynamic string table. I re-ran eight: all eight
load, one giving 38 sections and 8,359 symbols where master raised.

Validation: #815 (comment)

session: sharpen

u-boot's x86-64 image cannot be loaded at all. cle.Loader raises
ELFError("SHT_SYMTAB section points at section 0 of type SHT_NULL, expected
SHT_STRTAB") out of MetaELF._get_relro, so nothing else in the file is ever
read. The load dies while CLE is working out whether the file has RELRO, and it
does not: the file has three program headers, PT_LOAD, PT_DYNAMIC and
PT_GNU_STACK.

_get_relro asked iter_segments() whether a PT_GNU_RELRO header was there.
pyelftools builds each segment before its type can be read, and building the
PT_DYNAMIC one walks the whole section table looking for the matching .dynamic
section. The walk reaches .hash, whose sh_link is .dynsym, whose sh_link is 0
because u-boot's linker script discards .dynstr and GNU ld leaves the link
dangling. Whether a program header of a given type is present is a question
about the program headers, so read those instead. The .dynamic lookup on the
next line still walks the section table, so a file that has a PT_GNU_RELRO
header and the same broken link fails there instead; nothing measured does
both.

__register_dyn then failed a second time on the same file. pyelftools resolves a
tag's string as it hands the tag back, so it wants the dynamic string table for
every tag rather than for the four whose value is a string offset. This object
has no DT_STRTAB and no .dynstr, so there is nothing to build one from:
pyelftools 0.33 asserts, and 0.29 through 0.32 raise. CLE already handled a
missing dynamic string table three lines further down; the tag loop above it
just ran first. Take the table before the loop, treat its absence as an answer
rather than an error, and record every tag's value either way.
@zardus

zardus commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head e340fedd61271304c56f0e7f492f58d31b22db2a against baseline 3812052df2ad284cd16684fb7b7eb66e8d14dc6d.

The complete workspace gate could not be run: six corpus sweep lanes are live on this machine and pin four compiled libraries by content, so entering the workspace shell would reinstall the editables underneath them. What ran instead, on an isolated worktree of this head with the interpreter chosen by PYTHONPATH and the resolved cle.__file__ printed into each log:

python -m pytest tests -q -p no:randomly
suite baseline head
cle Python 257 passed, 3 failed, 9 skipped 257 passed, 3 failed, 9 skipped

The three failures are the same three on both sides and are not this change: test_gopclntab.py::test_macho_binary, test_gopclntab.py::test_macho_binary_supplies_the_function_symbols and test_macho.py::test_relocatable_object, all CLEFileNotFoundError for Mach-O fixtures this checkout of angr/binaries (3de2c41a) does not carry.

SUITES SKIPPED (did NOT run): angr Python, angr Rust, the pre-commit hook set, the angr-agentic workspace checks, and CI's own matrix. A gate this narrow is green over much less than the usual one.

Merge-base lint comparison, as angr/ci-settings currently defines that job: cle/backends/elf/elf.py 9.44 -> 9.44 and cle/backends/elf/metaelf.py 8.90 -> 8.90. The typecheck job counts pyright errors per changed file and neither file gains one, but the absolute counts move with the pyright build on PATH, so they are not quoted here.

No change to anything already in the corpus: loading every ELF file in angr/binaries at 3de2c41a -- 858 of them -- and comparing whether the load raised, the exception type and message, the traceback frames, the backend, the RELRO verdict, section and symbol counts, deps, provides, extra_load_path, every dynamic tag, the backer count and a sha256 over every backer of the loaded image in address order gives 0 differing files. Nothing differs at all, the traceback frames included. The same 10 files fail to load on both sides for the same four pre-existing reasons: 7 ELF core-file parse errors, 2 ArchNotFound (alpha, hppa) and 1 MemoryError.

Both hunks run on that corpus rather than being skipped: 530 of the 858 have a PT_GNU_RELRO program header and 606 a PT_DYNAMIC one, and _get_stringtable produces a table on all 606, so none of them takes the new branch and nothing in angr/binaries reproduces the defect. 598 carry at least one of DT_NEEDED, DT_SONAME, DT_RUNPATH or DT_RPATH -- 3 with DT_RPATH and 15 with DT_RUNPATH -- and every string they resolve to is byte-identical on both sides: 815 DT_NEEDED strings and 14,964 dynamic tag entries in aggregate, with a RELRO split of 319 NONE / 316 PARTIAL / 213 FULL either way.

The objects that do reproduce it are in a corpus sweep here. A snapshot of every ledger file in all 101 epochs, taken at 2026-09-04T02:41Z, gives 910 rows over 785 distinct objects in three groups that share no object:

where it fails rows distinct objects fixed here
_get_relro -> elffile.py:191 ELFError 10 1 yes
__register_dyn -> dynamic.py:183 AssertionError 752 645 yes
MetaELF.extract_soname -> dynamic.py:168 AssertionError 148 139 no

The sweep is still writing, so read those as lower bounds; a rescan forty minutes later had the second group at 688 objects and the third at 140.

The first group is the u-boot image in the description. 644 of the 645 in the second group match a megabench catalog row, and all 644 are redistribution: allowed -- they are Alpine -dbg packages off dl-cdn.alpinelinux.org, and the smallest is 6,912 bytes -- so a public fixture is available for that site if one is wanted. I re-ran eight, drawn from one shard: all eight raise AssertionError at dynamic.py:183 on 3812052d and load on this head, giving 36 to 38 sections and 1,294 to 8,359 symbols each.

The third group is a different call site with a different trigger -- a .dynamic whose sh_link points at a section that is not a string table, reached from dependency resolution rather than from the main object's load. This change does not touch extract_soname. The open #731 already widens that handler, so the third group has an owner; the two do not overlap and merge cleanly in either order.

One limit this change does not remove: _get_relro calls get_section_by_name(".dynamic") on the line after the one it fixes, and that walks the section table. A file with both a PT_GNU_RELRO header and a section table pyelftools refuses would still fail there. Nothing across angr/binaries or the ledger snapshot has both, so this is reachable by construction and unobserved. #731 guards the DT_FLAGS read below it, but not this line.

Not run here: the skipped suites above, and CI's own matrix.

session: sharpen

@zardus

zardus commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Loading the u-boot v2026.07 qemu-x86_64_defconfig image (sha256
8224b8886308b6b00282815df304b0f75831de3516df77406a05b85c40838a43), before and after
this change. Each block is what the command printed, with one substitution: the
site-packages prefix is written <venv>/. The script is:

import sys, traceback, logging, cle
logging.basicConfig(format="%(levelname)s:%(name)s:%(message)s")
path = sys.argv[1]
print(">>> cle.Loader(path, auto_load_libs=False)")
try:
    ld = cle.Loader(path, auto_load_libs=False)
except Exception:
    traceback.print_exc(file=sys.stdout)
    raise SystemExit
o = ld.main_object
print(o)
print("relro           ", o.relro)
print("entry           ", hex(o.entry))
print("sections        ", len(o.sections))
print("symbols         ", len(o.symbols))
print("function symbols", sum(1 for s in o.symbols if s.is_function))

Before -- the constructor raises, so there is no object at all:

cle master 3812052
>>> cle.Loader(path, auto_load_libs=False)
Traceback (most recent call last):
  File "/tmp/cleout/probe.py", line 6, in <module>
    ld = cle.Loader(path, auto_load_libs=False)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/cleout/base/cle/loader.py", line 187, in __init__
    self.initial_load_objects = self._internal_load(
                                ^^^^^^^^^^^^^^^^^^^^
  File "/tmp/cleout/base/cle/loader.py", line 805, in _internal_load
    obj = self._load_object_isolated(main_spec)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/cleout/base/cle/loader.py", line 1017, in _load_object_isolated
    result = backend_cls(binary, binary_stream, is_main_bin=self._main_object is None, loader=self, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/cleout/base/cle/backends/elf/elf.py", line 100, in __init__
    super().__init__(*args, **kwargs)
  File "/tmp/cleout/base/cle/backends/elf/metaelf.py", line 66, in __init__
    self.relro = _get_relro(tmp_reader)
                 ^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/cleout/base/cle/backends/elf/metaelf.py", line 41, in _get_relro
    if not any(seg.header.p_type == "PT_GNU_RELRO" for seg in elf.iter_segments()):
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/cleout/base/cle/backends/elf/metaelf.py", line 41, in <genexpr>
    if not any(seg.header.p_type == "PT_GNU_RELRO" for seg in elf.iter_segments()):
                                                              ^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 255, in iter_segments
    segment = self.get_segment(i)
              ^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 245, in get_segment
    return self._make_segment(segment_header)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 700, in _make_segment
    return DynamicSegment(segment_header, self.stream, self)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/dynamic.py", line 296, in __init__
    stringtable = next(
                  ^^^^^
  File "<venv>/elftools/elf/dynamic.py", line 299, in <genexpr>
    for section in elffile.iter_sections()
                   ^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 222, in iter_sections
    section = self.get_section(i)
              ^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 170, in get_section
    return self._make_section(section_header)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 762, in _make_section
    return self._make_elf_hash_section(section_header, name)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 849, in _make_elf_hash_section
    symtab_section = self._get_linked_symtab_section(linked_symtab_index)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 180, in _get_linked_symtab_section
    section = self._make_section(section_header)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 738, in _make_section
    return self._make_symbol_table_section(section_header, name)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 785, in _make_symbol_table_section
    strtab_section = self._get_linked_strtab_section(linked_strtab_index)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<venv>/elftools/elf/elffile.py", line 191, in _get_linked_strtab_section
    raise ELFError("SHT_SYMTAB section points at section %d of type %s, expected SHT_STRTAB" % (n, section_header['sh_type']))
elftools.common.exceptions.ELFError: SHT_SYMTAB section points at section 0 of type SHT_NULL, expected SHT_STRTAB

After -- the file loads. Its sections are still gone, because the same dangling
sh_link seen from ELF.__init__ makes CLE fall back to the program headers:

with this change
ERROR:cle.backends.elf.elf:PyReadELF couldn't load this file. Trying again without section headers...
WARNING:cle.backends.elf.elf:/tmp/cleout/u-boot-qemu-x86_64 has a dynamic segment but no dynamic string table.
>>> cle.Loader(path, auto_load_libs=False)
<ELF Object u-boot-qemu-x86_64, maps [0x1110000:0x1258a67]>
relro            Relro.NONE
entry            0x1110000
sections         0
symbols          0
function symbols 0

After, with #802 merged in as well -- the section table survives too, and the
object's own .symtab comes back. 43 sections, 13,273 symbols and 4,372 functions is
exactly what a struct parse of the file's own section and symbol tables gives:

with this change and #802 at 03316c1
WARNING:cle.backends.elf.elf:Section .dynsym is malformed; loading it without interpreting its contents.
WARNING:cle.backends.elf.elf:Section .hash is malformed; loading it without interpreting its contents.
WARNING:cle.backends.elf.elf:/tmp/cleout/u-boot-qemu-x86_64 has a dynamic segment but no dynamic string table.
>>> cle.Loader(path, auto_load_libs=False)
<ELF Object u-boot-qemu-x86_64, maps [0x1110000:0x1258a67]>
relro            Relro.NONE
entry            0x1110000
sections         43
symbols          13273
function symbols 4372

@angr-bot

angr-bot commented Sep 4, 2026

Copy link
Copy Markdown
Member

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

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