Skip to content

ELF: do not lose the load when the section header table starts past EOF - #856

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-c-367
Open

zardus wants to merge 1 commit into
masterfrom
feature/fix-c-367

Conversation

@zardus

@zardus zardus commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

An ELF whose e_shoff points past the end of the file loses its whole load, with pyelftools' exception escaping Loader.__init__. On the new tests/armel/fauxware.truncated.elf:

Traceback (most recent call last):
  ... cle/loader.py __init__ -> _internal_load -> _load_object_isolated ...
  File "cle/backends/elf/elf.py", line 101, in __init__
    super().__init__(*args, **kwargs)
  File "cle/backends/elf/metaelf.py", line 66, in __init__
    self.relro = _get_relro(tmp_reader)
  File "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()):
  ... pyelftools: iter_segments -> _make_segment -> DynamicSegment.__init__ -> iter_sections ...
  File "elftools/elf/elffile.py", line 713, in _get_section_header
    raise ELFParseError(msg)
elftools.common.exceptions.ELFParseError: Reading section 0 at offset 4528 past EOF 4164

main_opts={"discard_section_headers": True} raises exactly the same thing, even though that option is documented on ELF as "Do not parse section headers. Use this if they are corrupted or malicious."

Root cause

MetaELF.__init__ runs before ELF.__init__'s own body. It builds a plain ELFFile and asks _get_relro for the RELRO level, and two of that function's lines read the section header table through pyelftools:

  • elf.iter_segments() builds a DynamicSegment for PT_DYNAMIC, and DynamicSegment.__init__ calls elffile.iter_sections() looking for the DynamicSection at the same file offset;
  • elf.get_section_by_name(".dynamic") builds the _section_name_map cached property, which enumerates every section.

ELFFile._get_section_header raises as soon as a section header starts past stream_len, so either line is fatal on a truncated file. Which one fires depends on the file: one with a PT_DYNAMIC segment dies on the first, one without it on the second.

ELF.__init__ is already written for this file. Immediately after super().__init__() it probes the table itself, and when the walk raises it installs a PatchedStream that zeroes e_shoff, e_shentsize, e_shnum and e_shstrndx so pyelftools rereads the file from the program headers alone. Neither that nor discard_section_headers is reachable, because the RELRO probe got there first.

2a8adaa8 built that fallback in 2018 and fixed this exact bug in extract_soname in the same commit, by reading the program headers instead -- that is #113, where the diagnosis was that this is "supposed to be a pre-analysis stage that does extremely minor introspection". 4a7e4f7a put a section read back into the same stage in 2021 (#278), this time for RELRO.

Fix

Guard the RELRO probe, so a section header table pyelftools cannot parse reaches the recovery written for it instead of ending the load.

The level is reported as Relro.NONE. The parse can fail inside the PT_GNU_RELRO test itself, so "no RELRO" and "cannot tell" are not distinguishable here, and nothing reads the difference: the only consumer of the attribute anywhere in CLE or angr is MetaELF._block_references_addr, which compares against Relro.FULL, and FULL is exactly the answer that needs the dynamic table we could not reach.

The guard is at the call site rather than inside _get_relro, on purpose. #113's own conclusion -- "Once we get out of here and into the main loading phase we should opt out of using sections" -- is the right long-term shape, and the open #815 is already moving _get_relro's first line onto the program headers. But switching to segments does not settle this, because the segment route raises too: that is what the open #731 is about, iter_tags() on a PT_DYNAMIC segment with no string table. The invariant worth holding is that this probe cannot be fatal whatever it reads, and that belongs where the probe is called. #815 and #731 narrow when the guard fires; neither is needed for it.

Testing

tests/test_truncated_section_headers.py loads the new fixture, which is tests/armel/fauxware cut at 0xf04 + 0x140, the end of its last PT_LOAD's file bytes: every byte the program headers call loadable is present, and the section header table, at 0x11b0, is gone. Both tests fail on the merge base with the ELFParseError above and pass with the change, and the untruncated tests/armel/fauxware loads identically on both sides, with its 30 sections and Relro.PARTIAL.

The defect was found on a VirusShare sample that cannot be shared, SHA-256 7cb8df1ec5e810bf42b3cd9fca3c28a2117f696cd800dc8620f34fdf622448bd, an ARM ET_EXEC with e_shoff 1293496 in a 1010694-byte file and a load-error in a corpus sweep with the same exception. On the base it loads nothing and angr.Project raises out of its constructor; with this change it loads from its program headers and CFGFast recovers 3079 functions over 14207 nodes. The regression set is the 947 other objects measured the same way on both sides -- every \x7fELF path angr/binaries tracks under tests/, the other 77 objects of that sample's shard, and the other 12 objects in any corpus here whose header declares the same e_shoff-past-the-end malformation -- and not one of their rows changed. Every per-set count is in the validation record.

Commands, from the cle worktree with angr/binaries beside it and OBJECT the unavailable sample:

$ pytest -q tests/test_truncated_section_headers.py
$ python -c 'import cle; print(cle.Loader(OBJECT).main_object)'
$ python -c 'import angr; print(len(angr.Project(OBJECT, auto_load_libs=False).analyses.CFGFast(normalize=True).kb.functions))'
$ cmp -n 4164 ../binaries/tests/armel/fauxware ../binaries/tests/armel/fauxware.truncated.elf

The fixture is new because nothing already tracked has the shape: of the 858 paths under tests/ whose first four bytes are \x7fELF at 9eb02bd1032e1d96e729df2739f9a10a337bd154, zero have a section header whose offset falls past the end of the file. Merge angr/binaries#246 first. Master is separately red on tests/test_macho.py::test_relocatable_object_no_symtab, whose fixture sits in another open angr/binaries pull request; the validation record names it, the four checks that costs, and why only one such reference can be resolved per build. Validation: #856 (comment)

sync: angr/binaries#246

🤖 Generated with Claude Code

session: sharpen

MetaELF.__init__ computes the RELRO level before ELF.__init__ gets to run, and
pyelftools reads the section header table to answer it: iter_segments() builds a
DynamicSegment for PT_DYNAMIC and that constructor walks iter_sections(), while
get_section_by_name(".dynamic") builds a name map over the whole table. On a file
whose e_shoff points past the end, pyelftools' ELFParseError escapes
Loader.__init__ and the whole load is lost -- including when
discard_section_headers is set, which is documented for section headers that are
corrupt or malicious.

ELF.__init__ already recovers from a section header table it cannot walk, by
reloading the file with the section header fields zeroed so that pyelftools reads
it from the program headers alone. Guarding the RELRO probe lets that recovery
happen. RELRO is a property CLE reports rather than one the load needs, and the
level is reported as none because the parse can fail inside the PT_GNU_RELRO test
itself, which leaves "no RELRO" and "cannot tell" indistinguishable; only
Relro.FULL changes any behaviour, and FULL is what needs the dynamic table.

tests/test_truncated_section_headers.py loads
tests/armel/fauxware.truncated.elf, which is fauxware cut at the end of its last
PT_LOAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus

zardus commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full load result for tests/armel/fauxware.truncated.elf, before and after this change. The private VirusShare sample the defect was found on, SHA-256 7cb8df1ec5e810bf42b3cd9fca3c28a2117f696cd800dc8620f34fdf622448bd, cannot be shared; it fails and recovers identically, and the numbers for it are in the validation record.

Before — pyelftools' ELFParseError escapes Loader.__init__, so nothing is loaded at all, not the segments, not the entry point, not the dependency list:

cle master 997d432
Traceback (most recent call last):
  ... cle/loader.py __init__ -> _internal_load -> _load_object_isolated ...
  File "cle/backends/elf/elf.py", line 101, in __init__
    super().__init__(*args, **kwargs)
  File "cle/backends/elf/metaelf.py", line 66, in __init__
    self.relro = _get_relro(tmp_reader)
  File "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 "elftools/elf/elffile.py", line 255, in iter_segments
    segment = self.get_segment(i)
  File "elftools/elf/elffile.py", line 245, in get_segment
    return self._make_segment(segment_header)
  File "elftools/elf/elffile.py", line 700, in _make_segment
    return DynamicSegment(segment_header, self.stream, self)
  File "elftools/elf/dynamic.py", line 296, in __init__
    stringtable = next(
  File "elftools/elf/dynamic.py", line 299, in <genexpr>
    for section in elffile.iter_sections()
  File "elftools/elf/elffile.py", line 222, in iter_sections
    section = self.get_section(i)
  File "elftools/elf/elffile.py", line 167, in get_section
    section_header = self._get_section_header(n)
  File "elftools/elf/elffile.py", line 713, in _get_section_header
    raise ELFParseError(msg)
elftools.common.exceptions.ELFParseError: Reading section 0 at offset 4528 past EOF 4164

After — ELF.__init__'s existing fallback is reached, the section header table is discarded, and the file loads from its program headers:

with this change
object        ELF  arch <Arch ARMEL (LE)>  entry 0x84ad
os            UNIX - System V   linking dynamic   pic False   execstack False
relro         Relro.NONE
object range  [0x8000:0x1104f]   mapped_base 0x8000
loader range  [0x8000:0x100028]   (includes the extern object)
deps          ['libc.so.6', 'ld-linux-armhf.so.3']
segments      3
  vaddr 0x8000  memsize 0x7e0  filesize 0x7e0  offset 0x0  relro False
  vaddr 0x10f04  memsize 0xfc  filesize 0xfc  offset 0xf04  relro True
  vaddr 0x11000  memsize 0x50  filesize 0x44  offset 0x1000  relro False
sections      0
symbols       12
  '' rebased 0x0 size 0 type SymbolType.TYPE_NONE import False export False
  '__gmon_start__' rebased 0x0 size 0 type SymbolType.TYPE_NONE import True export False
  '__libc_start_main' rebased 0x846c size 0 type SymbolType.TYPE_FUNCTION import True export False
  '__stack_chk_fail' rebased 0x8454 size 0 type SymbolType.TYPE_FUNCTION import True export False
  '__stack_chk_guard' rebased 0x11048 size 4 type SymbolType.TYPE_OBJECT import False export True
  'abort' rebased 0x84a0 size 0 type SymbolType.TYPE_FUNCTION import True export False
  'exit' rebased 0x8494 size 0 type SymbolType.TYPE_FUNCTION import True export False
  'open' rebased 0x8488 size 0 type SymbolType.TYPE_FUNCTION import True export False
  'printf' rebased 0x843c size 0 type SymbolType.TYPE_FUNCTION import True export False
  'puts' rebased 0x8460 size 0 type SymbolType.TYPE_FUNCTION import True export False
  'read' rebased 0x8448 size 0 type SymbolType.TYPE_FUNCTION import True export False
  'strcmp' rebased 0x8430 size 0 type SymbolType.TYPE_FUNCTION import True export False
relocations   12
imports       ['__gmon_start__', '__libc_start_main', '__stack_chk_fail', 'abort', 'exit', 'open', 'printf', 'puts', 'read', 'strcmp']

@zardus

zardus commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 72d88761a6ece4ae084567d6583d1fba2ed5a8f2 against baseline 997d4322678fe5876d9f83e0dd34e1052813b9db, with angr/binaries at e6e7dc44fac6059734935ab75cf1beadb6c61ebf (angr/binaries#246) on both arms.

The two arms are store builds of the same worktree through the same nixpkgs pin -- baseline /nix/store/jjff9hprrycc48gxlf7296q2a33iw56i-python3-3.12.13-env, head /nix/store/x4p2x0b6s8jwvqxj66hs2l8cnavgd5lr-python3-3.12.13-env -- and diff -rq over the two installed cle packages names 203 differing paths, of which 202 are __pycache__ byte-code: exactly one .py file differs, backends/elf/metaelf.py, and its diff is this change's hunk verbatim at 14 lines, with each arm's copy cmp-identical to that file at the revision it was built from. No editable install, no PYTHONPATH, PYTHONHASHSEED=0.

Dataset evidence. The affected sample is a VirusShare object that cannot be shared: SHA-256 7cb8df1ec5e810bf42b3cd9fca3c28a2117f696cd800dc8620f34fdf622448bd, an ARM ET_EXEC whose e_shoff is 1293496 in a 1010694-byte file, with PT_GNU_RELRO present and no PT_DYNAMIC. It is a load-error in sweep epoch 2026-09-28-deep-virusshare-r210720: ELFParseError: Reading section 0 at offset 1293496 past EOF 1010694 at elffile.py:713:_get_section_header. The corpus declares backend: elf with no options, so the recipe is main_opts={}, and cle.Loader(OBJECT) reproduces it exactly.

  • baseline: load-error elftools.common.exceptions.ELFParseError: Reading section 0 at offset 1293496 past EOF 1010694, and the same with main_opts={"discard_section_headers": True}
  • head: loads. ELF, Arch ARMEL (LE), entry 0x8130, Relro.NONE, 3 segments, 0 sections, linking=static, Loader address range [0x8000:0x148d7f]; same with discard_section_headers
  • angr.Project(OBJECT, auto_load_libs=False) then analyses.CFGFast(normalize=True): baseline raises the same ELFParseError out of the Project constructor; head gives 14207 CFG nodes, 19236 edges and 3079 functions

How rare the shape is in the corpus, measured rather than assumed. 13 catalog rows across every dataset on this machine declare elf.shdr_table_beyond_eof; all 13 are VirusShare. Loaded on both arms with each row's declared recipe, 12 of the 13 already load on master and one does not -- the sample above. That is the point of the root cause: a truncated section header table on its own is survivable, because _get_relro returns at its first line when the program headers carry neither PT_GNU_RELRO nor PT_DYNAMIC, and ELF.__init__'s fallback then handles the table. Only the combination reaches a section read. All 13 load at the head, and the 12 unaffected ones load identically on both arms, same backend and same digest of their load shape.

Public reproducer. tests/armel/fauxware.truncated.elf, cle.Loader(path, auto_load_libs=False): baseline load-error ELFParseError: Reading section 0 at offset 4528 past EOF 4164; head loads with entry 0x84ad, 3 segments, 0 sections, 12 symbols, linking=dynamic, deps ['libc.so.6', 'ld-linux-armhf.so.3']. Control, the untruncated tests/armel/fauxware: loads on both arms and identically on both -- Relro.PARTIAL, 30 sections, 133 symbols, entry 0x84ad. The control's own mapped range is [0x8000:0x1104f] on both arms, and the fixture's is the same [0x8000:0x1104f] where it loads.

The fixture raises through the other of the two section reads, which is worth saying because it decides the shape of the fix. Its traceback on the baseline is metaelf.py:41 _get_relro -> elffile.py:255 iter_segments -> elffile.py:700 _make_segment -> dynamic.py:296 DynamicSegment.__init__ -> elffile.py:222 iter_sections -> elffile.py:713 _get_section_header. The corpus sample, having no PT_DYNAMIC, gets past that line and dies at metaelf.py:43 in get_section_by_name. A guard on either line alone would leave the other, which is why this one is at the call site.

Regression, tests. pytest --import-mode=append -q tests/test_truncated_section_headers.py: baseline 2 failed, both with the ELFParseError above; head 2 passed.

Regression, public corpus. Every path angr/binaries tracks under tests/ whose first four bytes are \x7fELF -- 858 of them -- loaded with cle.Loader(path, auto_load_libs=False) on both arms, each row recording status, backend and a digest of backend, arch, entry, relro, segment count, section count, symbol count, min_addr, max_addr and object count. 849 loaded and 9 load-error on both arms, and diff of the sorted rows exits 0: zero rows changed. Positive control: editing one row makes the same diff exit 1. The 9 pre-existing failures are 7 BSD and x32 core dumps in elfcore.py, which the open #734 fixes, and tests/alpha/test-instr_alpha and tests/hppa/test-instr_hppa, for which archinfo ships no architecture.

Regression, private corpus. All 78 objects of the VirusShare shard the cited sample belongs to, same command and same row format, each with its own declared recipe. Exactly one row changed -- the cited sample, load-error to loaded. Of the other 77, 55 are CLECompatibilityError, CLE declining cleanly, and 22 load; both figures are identical on the two arms and every row is byte-identical. Across the three regression sets the distinct total is 948 objects, so the regression set proper -- everything but the cited sample -- is 947 objects, of which none got worse and none changed at all.

Workspace gate. One run of the full local gate over this branch and its sibling checkouts, at this head, in the head environment above, with PYTHONHASHSEED=0. Thirteen suites ran, and the gate's own cleanliness check passed with no checkout changed.

  • cle: 1 failed, 284 passed, 9 skipped. The failure is tests/test_macho.py::test_relocatable_object_no_symtab, CLEFileNotFoundError on tests/x86_64/relocatable_object_no_symtab.macho, a fixture that does not exist at angr/binaries master and is still sitting in the open Add a Mach-O relocatable object that carries no symbol table binaries#235. The same gate builds a throwaway feature from plain master and runs the same suite as 1 failed, 282 passed, 9 skipped -- the identical single failure, with exactly the two new tests missing. So this change adds two passes and moves nothing else in the suite.
  • angr: 3755 passed, 47 skipped, 2 xfailed, 1203 subtests passed. archinfo 42 passed and 34 subtests; pypcode 46 passed and 187 subtests; pyvex 87 passed; pysoot and angr-rust built and checked as the pysoot-check and rustylib-check derivations.
  • test-inputs passed -- five checkouts add no binary or manufactured input outside angr/binaries, and the new fixture is in angr/binaries, where one belongs. test-packages passed over six checkouts. Every configured pre-commit hook passed across every repository: 122 passed or skipped, none failed.
  • angr-management did NOT run: this feature has not adopted it, so the gate is green over less than it appears to be.
  • Two suites that cover neither repository failed at the time of the run, on concurrent work in the development workspace rather than on this change: workspace, four failures in that workspace's own sweep tests, and mono, one failure comparing a component list against a file being edited beside it. That work has since landed -- the workspace tree is clean and the constant those four tests disagreed with now matches its committed value -- so the failures were the edit in flight and nothing about this change.

Lint and type, against the merge base, which the workspace gate never runs. run-ci-diff-checks.py --repository cle --base 997d4322678fe5876d9f83e0dd34e1052813b9db reproduces this repository's Lint and Typecheck jobs file by file. Two changed files; pylint cle/backends/elf/metaelf.py 9.76 -> 9.76 and tests/test_truncated_section_headers.py new at 10.00; pyright errors metaelf.py 21 -> 21 and the test file 0 -> 0. Exit 0, no regression.

This is where an earlier head of this branch failed, and it is worth saying because the local gate cannot see it: writing the guard as except elftools.common.exceptions.ELFError: took metaelf.py from 21 pyright errors to 22, adding a second "common" is not a known attribute of module "elftools" beside the one master already has in extract_soname, and reading ld.main_object.relro in the test took it from 0 to 1, because Loader.main_object is typed Backend. The published head imports ELFError from elftools.common.exceptions, which is also how the module imports its three other elftools names, and narrows with assert isinstance(obj, ELF), which is what tests/test_macho.py does.

Conflicts. git merge-tree --write-tree against the head of every one of the 92 open pull requests on this repository, each head checked to resolve with git rev-parse first (92 paginated plus 37 open issues is this repository's reported 129). 11 report a conflict, and every one of those 11 reports the identical conflict against origin/master itself, so none is caused by this change: #817 in tests/test_macho.py, #812 and #805 in tests/test_pe.py, #756 and #732 in cle/backends/pe/pe.py, #686 in cle/__init__.py and cle/backends/__init__.py, #674 in cle/backends/universal2.py and tests/test_universal2.py plus two workflow files master has deleted, #628 and #381 in cle/backends/elf/elf.py, #441 in cle/backends/ihex.py, #379 in cle/backends/java/apk.py. Nothing conflicts in cle/backends/elf/metaelf.py. Three open pull requests touch that file -- #731 and #815 in _get_relro, #814 further down in the PowerPC64 initial TOC value -- and none of the three conflicts with this change. One collision was found and removed before this head: the test file was first called tests/test_elf_resiliency.py, which #802 also adds.

CI prediction, written before the first check ran. This pull request will be red on four checks, and none of them is this change. Expect ci / Test (2), Test (Pyodide), Test macos-15 and Test windows-2022 to fail -- the last may be reported cancelled rather than failed -- every one of them on tests/test_macho.py::test_relocatable_object_no_symtab with CLEFileNotFoundError on tests/x86_64/relocatable_object_no_symtab.macho. Everything else should pass: ci / Build, ci / Lint, ci / Typecheck, the other nine ci / Test shards, ci / Decompiler Snapshot Testing, ci / Publish Unit Tests Results, docs/readthedocs.org:cle and pre-commit.ci - pr. Anything red beyond those four is a defect in the validation above, not a CI quirk.

The reason is a merge order this repository cannot express. b0203a53 added that macho test on 2026-09-29 for a fixture that exists only in the open angr/binaries#235, so master's own suite has been red against angr/binaries master ever since. Both resolvers take exactly one angr/binaries pull request per build: ga-build.sh's resolve_refs.py keeps the first owner/repo#N token naming a repository it tests and prints Warning: multiple references to pull requests of for every later one, and angr/ci-settings/actions/binaries-ref greps the body and takes head -n 1. Both were read out of angr/ci-settings at master. This body names its own fixture pull request first, which is the one the new tests need, so they run and pass and the macho test does not. Two of our open pull requests bracket the prediction: cle#854, which names no angr/binaries pull request, shows 16 green and exactly those four red; cle#855, which names angr/binaries#235, is 20 of 20 green. What clears the four is not angr/binaries#235 merging on its own: both resolvers check out a referenced pull request's head ref -- resolve_refs.py sets branch='refs/pull/N/head' and binaries-ref emits ref=refs/pull/$number/head, neither a merge with master -- and this branch's head carries the armel fixture and not the macho one. They go green once the macho fixture is reachable from what CI checks out: this branch's binaries pull request rebased onto a master that has it, or that pull request merged or closed so the resolver falls back to branch='master', with angr/binaries#235 merged either way. Nothing in this change can make them green before then.

Caveats, one line each:

  • The catch is elftools.common.exceptions.ELFError, which is what the measured objects raise. pyelftools asserts rather than raising on some other malformed inputs -- ELF: Tolerate a missing dynamic string table when reading soname and RELRO #731's description has an instance -- and such a file would still lose its load here. No object in any dataset on this machine shows that, so widening the catch would be a change with no measurement behind it.
  • discard_section_headers=True now loads this file, but it gets there through ELF.__init__'s fallback rather than by skipping the table: that constructor still probes iter_sections() before it reads the flag. The option's documented effect is restored; the wasted walk is not addressed here.
  • Relro.NONE on an affected file means CLE could not determine the level, not that the file has no RELRO: the new fixture and the corpus sample both carry PT_GNU_RELRO. The attribute has no way to say "unknown", and nothing reads the difference between none and partial, so this changes no behaviour -- but it is a less informative answer than the program headers alone support. Once ELF: do not fail the load over a dynamic segment with no string table #815 and ELF: Tolerate a missing dynamic string table when reading soname and RELRO #731 land, _get_relro gets further before giving up and the guard fires on fewer files.
  • The gate's workspace suite failures named above were concurrent work in the development workspace, not in either repository this change touches, and I did not re-run that suite after the work landed.
  • The private-corpus arms were measured on objects staged out of the dataset shards into a scratch directory; the shard files themselves were not written to, and the staged copies are deleted after this record.

@angr-bot

angr-bot commented Oct 4, 2026

Copy link
Copy Markdown
Member

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

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