Skip to content

ELF: keep the section header table when one section's sh_link is dangling - #802

Open
zardus wants to merge 1 commit into
masterfrom
feature/elf-lenient-sections
Open

ELF: keep the section header table when one section's sh_link is dangling#802
zardus wants to merge 1 commit into
masterfrom
feature/elf-lenient-sections

Conversation

@zardus

@zardus zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

One section CLE has no use for takes the whole section header table down with
it, silently. On binaries/tests/i386/hash_without_dynstr, and on the u-boot
2026.07 qemu images for x86, arm and arm64,
cle.Loader(path).main_object.sections is [] and .symbols is []
although .symtab is right there -- 11631 entries on a 2026.07 qemu-arm64
build, 3796 of them STT_FUNC. The .debug_* sections and every
section-derived memory region go with them. The only trace is one line:

ERROR:cle.backends.elf.elf:PyReadELF couldn't load this file. Trying again without section headers...

Downstream, CFGFast misses 365 functions across six u-boot objects on five
architectures -- bootmeth_read_all at 0x9bc8, bootdev_get_bootflow,
efi_var_collect_mem, enable_interrupts. u-boot reaches most of them only
through a linker list, so with no symbol and no call there is nothing left to
find them by. 299 of the 365 have a basic block starting at exactly the declared
address, so the code was decoded and no function was ever made there.

Root cause

ELF.__init__ probes the table with list(self._reader.iter_sections()) and,
on any exception, zeroes e_shoff, e_shentsize, e_shnum and e_shstrndx
and rereads the file from the program headers alone. pyelftools validates
sh_link when it builds a section, and u-boot keeps .hash while its linker
script discards .dynstr and .dynsym, so the linker writes sh_link = 0 and
the walk dies on ELFError: Section points at section 0 of type SHT_NULL, expected SHT_SYMTAB/SHT_DYNSYM. The fallback is right for a section table that
cannot be read at all; it is much too broad for one section that cannot be
interpreted.

Fix

A section pyelftools refuses to specialise is handed back as a plain section
instead, so the header and the data survive and only the interpretation is
missing -- which is the correct answer for a section whose link is broken. The
program-headers-only fallback stays for a genuinely unusable table:
binaries/tests/i386/oxfoo1m3 still takes it, unchanged. A relocation section
whose sh_link is then not a symbol table falls back to .symtab, extending
the NullSection case already there, and the debug-file reader is made lenient
for the same reason.

359 of the 365 functions come back: 134 on the x86 image, 119 on arm64, 106 on
arm. Four of the rest are ppce500 addresses where angr decodes no block at all,
which is a different question.

Restoring the table also makes CLE apply these images' relocation sections for
the first time, and it gets that wrong in two ways that rewrite .text. #801
fixes one and #773 the other, and this pull request needs both: on the x86 image
.text comes back with 60045 of its 558238 bytes differing from the file on
this change alone, 20632 with #801 as well, 39413 with #773 as well, and 0 only
with both. They merge cleanly in every pairing, so merging in number order --
#773, #801, #802 -- is enough.

Testing

tests/test_elf_resiliency.py loads binaries/tests/i386/hash_without_dynstr,
a freestanding clang and GNU ld build carrying the same section table, and
asserts that .symtab is present and that the three functions reachable only
through a pointer table have symbols. Both assertions fail on master. Loading
all 826 ELF files in angr/binaries and hashing each loaded image gives identical
results before and after this change.

Validation: #802 (comment)

sync: angr/binaries#219

session: sharpen

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Loading binaries/tests/i386/hash_without_dynstr before and after this change,
with the script the regression is built from:

import cle
path = "binaries/tests/i386/hash_without_dynstr"
obj = cle.Loader(path, auto_load_libs=False).main_object
print("sections:", [s.name for s in obj.sections])
print("function symbols:", sorted(s.name for s in obj.symbols if s.is_function))

Before.hash and .dynsym cannot be built, so the whole section header
table is discarded and the file is reread from the program headers: no sections,
and none of .symtab's symbols:

cle master
ERROR:cle.backends.elf.elf:PyReadELF couldn't load this file. Trying again without section headers...
sections: []
function symbols: []

After — the two sections that cannot be interpreted are kept uninterpreted
and everything else survives:

with this change
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.relocation:Unknown reloc 9 on X86
sections: ['', '.text', '.rel.text', '.data', '.rel.data', '.got.plt', '.bss', '.hash', '.dynsym', '.rel.dyn', '.symtab', '.strtab', '.shstrtab']
function symbols: ['_start', 'table_entry_one', 'table_entry_two', 'table_helper']

The Unknown reloc 9 line is the reason #801 should land first: with the
section table back, CLE starts applying the link-time .rel.text this image was
never meant to have applied again. #801 stops that, and the line goes away.

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 33c9a1585e0ec48a3c5dbb60df7c04d68f3221ae against baseline c7e0d4db664a45b59c84967ac42d04f82a97b087, with angr/binaries#219 at 756427a171c37695d5c910b2086ce21847a25b2f.

Complete workspace gate, in a per-feature instance adopting angr, cle and binaries:

ANGR_FEATURE=<instance> nix develop --command \
  ./.agents/skills/angr-validate-workspace/scripts/run-all-tests.sh

All selected angr workspace test suites passed.

suite result
cle Python 243 passed, 9 skipped
angr Python 2530 passed, 46 skipped, 2 xfailed, 273 subtests passed (971s)
angr Rust 35 passed, 0 failed
every configured pre-commit hook, all files, angr and cle passed
angr-agentic workspace checks, test-inputs, per-feature instances, mono pipeline passed

SUITES SKIPPED (feature has not adopted, did NOT run): archinfo pypcode pyvex pysoot claripy angr-management. A green gate that skipped a suite is green over less than it appears to be.

Focused, on the same head:

python -m pytest tests/test_elf_resiliency.py    # 1 passed

Both of its assertions fail on c7e0d4db, where the fixture loads with no sections and no symbols at all.

The narrower fallback still fires where it should: binaries/tests/i386/oxfoo1m3, whose section header table is genuinely unreadable, still loads with 0 sections through the program-headers-only path and its loaded image is byte-identical to master's.

No change to anything already in the corpus: loading every ELF file in angr/binaries -- 826 of them -- and comparing section count, symbol count, relocation count, address range and a sha256 of the loaded image of the main object gives 0 differing files between this head and c7e0d4db, with the same 9 pre-existing load errors on both sides.

On six u-boot 2026.07 images built for qemu targets on five architectures, 365 functions declared STT_FUNC in the objects' own .symtab were absent from CFGFast; 299 of them had a basic block starting at exactly the declared address. On this head 359 are recovered -- 134 of 134 on x86, 119 of 119 on arm64, 106 of 106 on arm, 0 of 4 on ppce500 and 0 of 1 on each MIPS image. The four ppce500 addresses have no decoded block at all, and the two MIPS ones come back with #801. Measured with CFGFast(normalize=True, data_references=False, resolve_indirect_jumps=True), ground truth parsed out of each ELF's .symtab with struct, through neither cle nor DWARF.

Landing #801 and #773 first matters: with the section table back but the relocation handling unfixed, this head leaves 60045 of the 558238 bytes of the x86 image's .text differing from the file. #801 takes that to 20632 and #773 then to 0, and #773 without #801 leaves 39413, so both are needed. #801 used to carry #773's one-line fix as well; that duplicate was dropped to let the two apply together. Byte figures re-measured 2026-09-04 at cle master 3812052d with #773 f4d48fb3, #801 88a2363a and #802 03316c16.

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


CI disposition, head 03316c16 (rebased onto cle master eac0e554; was 33c9a158). No source
change: git range-diff reports the commit unaltered.

Every red check on the old head was inherited.

  • ci / Test (8), src/angr/tests/simos/test_uefi.py - fixed by cle master eac0e554 (cle#800),
    which sets TE.os = "uefi". angr's os_mapping is a defaultdict(lambda: SimOS), so a None os
    silently yields plain SimOS and the isinstance(project.simos, SimUefi) assertions fail. cle CI
    builds the branch tip rather than the merge commit, so only a rebase picks this up; cle master's
    own run 33257498966 has ci / Test (8) green on eac0e554.
  • The platform jobs' test_uefi_image_is_not_windows failure was the referenced sibling being stale:
    Add two i386 ELFs for CLE's linked-image ELF handling binaries#219 was one commit behind binaries master and so lacked
    tests/riscv64/uefi/HighMemDxe.efi. Use module __name__ for logger instead of string #219 rebased, commit unaltered, 756427a -> cf3cfde.

Still red, and not fixable here: ci / Test (1),
test_block_simplifier.py::test_a_long_stack_pointer_chain_reaches_a_fixed_point, needs
binaries/tests/i386/deep_sp_chain, which exists only on the open angr/binaries#215. angr master and
cle master fail it too, so it is upstream of this branch and clears when #215 merges.

@angr-bot

Copy link
Copy Markdown
Member

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

…ling

ELF.__init__ probes the section table with list(iter_sections()) and, on any
exception, zeroes e_shoff, e_shentsize, e_shnum and e_shstrndx and rereads the
file from the program headers alone. pyelftools validates sh_link when it builds
a section, and u-boot keeps .hash while its linker script discards .dynstr and
.dynsym, so the linker writes sh_link = 0 and the walk dies on ELFError. One
section nothing needs therefore cost .symtab -- 11631 entries on the arm64
image, 3796 of them STT_FUNC -- along with the .debug_* sections and every
section-derived memory region, leaving CFGFast to miss 365 functions across six
u-boot objects on five architectures.

A section pyelftools refuses to specialise is now handed back as a plain
section, so the header and the data survive and only the interpretation is
missing. The program-headers-only fallback stays for a table that cannot be read
at all. A relocation section whose sh_link is then not a symbol table falls back
to .symtab, extending the NullSection case already there, and the separate
debug-file reader is made lenient for the same reason.
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