Skip to content

ELF: read the symbol-version tables from the image, not the file - #836

Open
zardus wants to merge 1 commit into
masterfrom
cle383-verneed
Open

zardus wants to merge 1 commit into
masterfrom
cle383-verneed

Conversation

@zardus

@zardus zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

cle.Loader refuses any ELF whose symbol-version tables sit at a virtual address that is not
also a valid file offset. On binaries/tests/x86_64/vmprotect_sample1.vmp.bin, the sample
@fkil attached to #383 on 2023-04-11, which has had no reply since:

  File "cle/backends/elf/elf.py", line 1137, in __register_dyn
    for _, aux in readelf_verneed.iter_versions():
  File "elftools/elf/gnuversions.py", line 154, in iter_versions
    for verneed, vernaux in super(GNUVerNeedSection, self).iter_versions():
  File "elftools/elf/gnuversions.py", line 108, in iter_versions
    entry = struct_parse(
  File "elftools/common/utils.py", line 45, in struct_parse
    raise ELFParseError(str(e))
elftools.common.exceptions.ELFParseError: expected 2, found 0

Nothing is salvaged: the exception escapes __register_segments, so the object does not load
and no CFG, symbol table or decompilation follows.

Root cause

__register_dyn builds seven pyelftools section objects out of DT_ tags, each with an
sh_offset taken through AT.from_lva(...).to_rva() — an image-relative address, not a file
offset. Five of them are then pointed at the image so that address means something (line numbers
here are master's, 0e77ade3):

dynsym.stream = self.memory             # line 1107
readelf_versym.stream = self.memory     # line 1168
readelf_relocsec.stream = self.memory   # line 1214
readelf_jmprelsec.stream = self.memory  # line 1235
readelf_relrsec.stream = self.memory    # line 1256

readelf_verneed and readelf_verdef are the two exceptions, so pyelftools keeps the
ELFFile's file stream and seeks an RVA in it. In the sample DT_VERNEED is 0xCE3808
against an image base of 0x400000, so the seek goes to 0x8E3808 in a 0x2E847B-byte file —
0x5FB38D past the end — and the two-byte read of vn_version comes back empty. The table
really begins at file offset 0x2E3808, 0x600000 lower.

Nothing shows on an ordinary binary because the segment holding the version tables is laid out
at the image base plus its own file offset, so the two numbers coincide. Both lines have been
wrong since 99379eb (#324) added symbol versioning in April 2022 — it gave the versym table a
memory stream and these two none.

Fix

Replace the stream on both sections, which is @fkil's second suggestion and what the other five
already do. The four assignments carry # type: ignore because pyelftools declares stream and
elffile with types cle deliberately violates here, and Typecheck budgets pyright errors per
file: the five older sections predate the budget, so their identical assignments are
grandfathered while new ones fail the check. His first suggestion, converting the offset with to_raw() instead, would leave
these two as the only tables in __register_dyn read out of the file, and he flagged himself
that it may be wrong when the stream is a memory dump.

Symbol versions now decode: 112 of the sample's symbols get a library version, including
printf @GLIBC_2.2.5, __gxx_personality_v0 @CXXABI_1.3 and _ZNSs6appendEPKcm
@GLIBCXX_3.4.

The verdef half gets no fixture with a mismatched offset — see Testing — but it is the same
mistake and is fixed with it rather than left half-repaired. It is not untested: on this branch
GNUVerDefSection.iter_versions is called with a Clemory stream and decodes all 23 versions
of binaries/tests/x86_64/libc.so.6, which cle's own suite already loads.

Testing

tests/test_symbol_versions.py loads the sample, checks that DT_VERNEED's file offset is
inside the file while its RVA is past the end — so the test fails loudly if the fixture is
ever swapped for an ordinary binary — and asserts three versioned symbols by name. On master
it fails with the ELFParseError above; on this branch it passes.

No tracked fixture had the offsets apart: over the 940 tracked paths whose content is an ELF in
angr/binaries at 0166109e there are 578 DT_VERNEED and 40 DT_VERDEF entries, and all 618
put the table at an RVA that is also its file offset. Hence the new one.

Depends on angr/binaries#230 for the fixture, which cle/ci.yml resolves through
angr/ci-settings/actions/binaries-ref.

Expected CI, recorded before the push: all 20 checks green — the 18 Actions runs ci / Build,
ci / Lint, ci / Typecheck, ci / Test (0..9), ci / Decompiler Snapshot Testing (0),
ci / Publish Unit Tests Results, Test (Pyodide), Test macos-15 and Test windows-2022,
plus the two commit statuses docs/readthedocs.org:cle and pre-commit.ci - pr. The previous
head got 19 of those and failed ci / Typecheck on the four assignments now marked
# type: ignore; every other check passed, including ci / Build, so the fixture reached
every job.

Fixes #383. Validation: #836 (comment)

🤖 Generated with Claude Code

session: sharpen

@zardus

zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 19f21250e972d1f6d0329785077df9e2e1ec6675 against baseline
0e77ade3c39a3cee05f65051e57955675e1ac21b, with angr/binaries at
47f3e68de3e3c57687f5b492af278adca0f6c7c7 (baseline 0166109eb1fa0aec5baefb12403a487268f0e9ca).

The regression, both ways

tests/test_symbol_versions.py was run from the worktree against a store build of the tree,
twice, from two different store environments.

Branch, env /nix/store/lg6acihykl3byg0pbn3yxfrr9yviqssm-python3-3.12.13-env: 1 passed.

Master: the same test file with cle/backends/elf/elf.py checked out at 0e77ade3, which
rebuilds the env to /nix/store/rvwzv0jp2jv6h80qvsqxqw928900whzf-python3-3.12.13-env: 1 failed,
ELFParseError: expected 2, found 0, with stream_pos = 9320456 on a _io.BufferedReader over
the fixture. 9320456 is 0x8E3808, the RVA, seeked in a file stream on a 0x2E847B-byte file.

Two controls, each of which had to move the number:

  • The fixture guard, file_offset < filesize < verneed - obj.linked_base with file_offset
    from obj.addr_to_offset(verneed), holds on the fixture and is false on
    tests/x86_64/fauxware, so substituting an ordinary binary fails the test instead of passing
    it vacuously.
  • An earlier draft of the version assertion included GCC_3.0, which is in the decoded table
    but referenced by no symbol, and the test failed on it. The assertions read decoded data, not
    merely the absence of an exception.

Fixture census

Over the 940 tracked paths whose content is an ELF at 0166109e: 578 DT_VERNEED entries and
40 DT_VERDEF entries, and all 618 place the table at an RVA that is also its file offset. The
same instrument at 47f3e68d reports 941/579/40 and exactly one mismatch, the new fixture,
which calibrates the zero.

The verdef half

No fixture has its DT_VERDEF offsets apart, but the changed line is exercised: on this branch
GNUVerDefSection.iter_versions runs with a cle.memory.Clemory stream and yields all 23
entries of binaries/tests/x86_64/libc.so.6 (DT_VERDEFNUM 23, sh_info 23), a file cle's own
suite loads through tests/test_plt.py and tests/test_amd64_relocations.py.

Gate

Gate run 2788910, ./feature.sh test cle383, exit 0, at cle
19f21250e972d1f6d0329785077df9e2e1ec6675:

workspace        ran, passed
test-inputs      ran, passed
test-packages    ran, passed
pre-commit       ran, passed
feature-build    ran, passed
mono             ran, passed
archinfo         did NOT run: feature cle383 has not adopted archinfo
pypcode          did NOT run: feature cle383 has not adopted pypcode
pyvex            did NOT run: feature cle383 has not adopted pyvex
pysoot           ran, passed
cle              ran, passed
angr             ran, passed
angr-rust        ran, passed
angr-management  did NOT run: feature cle383 has not adopted angr-management
worktree-cleanliness ran, passed; no checkout changed

PARTIAL PASS: 10 of 14 suites ran and passed; 4 did NOT run.

A green gate that skipped a suite is green over less than it appears to be: archinfo,
pypcode, pyvex and angr-management did not run. The gate also reports PIN SKEW: angr is 12 commits behind its origin/master and pyvex is 1 behind its own, so the angr suite ran at
23b470d9f rather than at angr's tip.

Four runs, each answering a different question:

  • 1824104 exited 1 on one suite, feature-build, which builds and gates a throwaway feature
    and reaches none of this diff. A concurrent edit had left the shared repos/archinfo checkout
    dirty and the build refused: archinfo at .../repos/archinfo is not what <store env> was built from (source moved).
  • 1942253 exited 0 with the diff unchanged and that checkout clean, so feature-build was
    never this change; that run also adopted and ran angr and angr-rust, which 1824104 had
    skipped.
  • 2131936 ran at a3815ecf, the head this pull request first published.
  • 2788910 is this head. git diff a3815ecf 19f21250 is four lines, each a trailing
    # type: ignore comment, for the reason below.

What the gate does not check, and CI does

run-ci-diff-checks.py --repository cle reproduces the two checks that gate on the diff, and the
gate runs neither. It caught one regression before the first push: the new test had gone from 0
pyright errors to 2, one for reading _dynamic off a Backend and one for comparing
addr_to_offset's int | None. The test now narrows with assert isinstance(obj, ELF) and
checks for None before comparing, and the command reports ok cle/backends/elf/elf.py: errors 15 -> 15 and ok tests/test_symbol_versions.py: errors 0 -> 0, with pylint 10.00 on both files.

It did not catch the second, and why is worth stating. ci / Typecheck failed at a3815ecf
with cle/backends/elf/elf.py errors increased from 46 to 50, the four being the stream and
elffile assignments on the verneed and verdef sections. The same command here reports 15
errors for that file where CI reports 46, and pyright's version is not what separates them:
CI installs 1.1.414, and running that exact version here still gives 15.

pyelftools does. angr/ci-settings installs it from git — ci-image/conf/requirements.txt
line 13, under the comment "We frequently need upstream pyelftools fixes" — and on master
Section.__init__ is annotated elffile: ELFFile with self.stream: IO[bytes]. This env pins
released 0.32, where neither is annotated, so an assignment has nothing to conflict with and
pyright reports nothing. Every one of cle's seven
fake sections assigns those two attributes, so the whole family is invisible here and visible
there.

The error set is what the check gates on, and it moves the right way: pyright over
cle/backends/elf/elf.py at 0e77ade3 and at 19f21250 produces the same errors — a diff of
the sorted messages is empty — while a3815ecf adds exactly Cannot assign to attribute "stream" and ... "elffile" for GNUVerNeedSection and for GNUVerDefSection. CI's own count
for the first two of those trees is the 46 and 50 in the job output above.

The repair is # type: ignore on those four lines, which is cle's own idiom — nine uses under
cle/ outside this file — and works because cle ships no pyright configuration, so
enableTypeIgnoreComments is at its default.

session: sharpen

@zardus

zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

versions.py, run against cle master and against this branch. DT_VERNEED is 0xCE3808, the
image base is 0x400000, the file is 0x2E847B bytes long, and the table's real file offset is
0x2E3808.

import cle

ld = cle.Loader("binaries/tests/x86_64/vmprotect_sample1.vmp.bin", auto_load_libs=False)
obj = ld.main_object
print(obj)
print(sorted({s.version for s in obj.symbols if s.version}))
print(len([s for s in obj.symbols if s.version not in (None, "*local*", "*global*")]))
for name in ("printf", "__gxx_personality_v0", "_ZNSs6appendEPKcm"):
    print(name, sorted({s.version for s in obj.symbols if s.name == name}))

Before — the RVA 0x8E3808 is seeked in the file stream, 0x5FB38D past its end, and the
object does not load at all:

cle master 0e77ade
Traceback (most recent call last):
  File "elftools/construct/core.py", line 351, in _parse
    return self.packer.unpack(_read_stream(stream, self.length))[0]
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "elftools/construct/core.py", line 293, in _read_stream
    raise FieldError("expected %d, found %d" % (length, len(data)))
elftools.construct.core.FieldError: expected 2, found 0

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "elftools/common/utils.py", line 43, in struct_parse
    return struct.parse_stream(stream)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "elftools/construct/core.py", line 190, in parse_stream
    return self._parse(stream, Container())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "elftools/construct/core.py", line 647, in _parse
    subobj = sc._parse(stream, context)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "elftools/construct/core.py", line 353, in _parse
    raise FieldError(ex)
elftools.construct.core.FieldError: expected 2, found 0

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "versions.py", line 3, in <module>
    ld = cle.Loader("binaries/tests/x86_64/vmprotect_sample1.vmp.bin", auto_load_libs=False)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "cle/loader.py", line 187, in __init__
    self.initial_load_objects = self._internal_load(
                                ^^^^^^^^^^^^^^^^^^^^
  File "cle/loader.py", line 805, in _internal_load
    obj = self._load_object_isolated(main_spec)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "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 "cle/backends/elf/elf.py", line 208, in __init__
    self.__register_segments()
  File "cle/backends/elf/elf.py", line 1036, in __register_segments
    self.__register_dyn(seg)
  File "cle/backends/elf/elf.py", line 1137, in __register_dyn
    for _, aux in readelf_verneed.iter_versions():
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "elftools/elf/gnuversions.py", line 154, in iter_versions
    for verneed, vernaux in super(GNUVerNeedSection, self).iter_versions():
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "elftools/elf/gnuversions.py", line 108, in iter_versions
    entry = struct_parse(
            ^^^^^^^^^^^^^
  File "elftools/common/utils.py", line 45, in struct_parse
    raise ELFParseError(str(e))
elftools.common.exceptions.ELFParseError: expected 2, found 0

After — the version tables are read from the image, 112 symbols get a library version back,
and the two entries sorted(...) shows beside the six real ones are the *local* and
*global* pseudo-versions:

with this change
Symbol imported without a known size; emulation may fail if it is used non-opaqely: _ZNSs4_Rep20_S_empty_rep_storageE, _ZTVN10__cxxabiv117__class_type_infoE, _ZTVN10__cxxabiv120__si_class_type_infoE. See https://docs.angr.io/extending-angr/environment#simdata
<ELF Object vmprotect_sample1.vmp.bin, maps [0x400000:0xce77b7]>
['*global*', '*local*', 'CXXABI_1.3', 'GLIBCXX_3.4', 'GLIBC_2.2.5', 'GLIBC_2.3', 'GLIBC_2.3.4', 'GLIBC_2.4']
112
printf ['GLIBC_2.2.5']
__gxx_personality_v0 ['CXXABI_1.3']
_ZNSs6appendEPKcm ['GLIBCXX_3.4']

@angr-bot

Copy link
Copy Markdown
Member

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

__register_dyn builds seven pyelftools section objects out of DT_ tags, each
with an sh_offset taken through AT.from_lva(...).to_rva() -- an
image-relative address, not a file offset. Five of them are then pointed at
self.memory so that address means something: dynsym, versym, and the reloc,
jmprel and relr tables. The verneed and verdef tables were the two
exceptions, so pyelftools kept the ELFFile's file stream and seeked an RVA in
it.

Nothing shows on an ordinary binary, where the segment holding the version
tables is laid out at the image base plus its own file offset and the two
numbers coincide. On the new fixture
binaries/tests/x86_64/vmprotect_sample1.vmp.bin they do not: DT_VERNEED is
0xCE3808 against an image base of 0x400000, so the seek goes to 0x8E3808 in
a 0x2E847B-byte file -- 0x5FB38D past the end -- and the two-byte read of
vn_version comes back empty:

    ELFParseError: expected 2, found 0

The table really begins at file offset 0x2E3808, 0x600000 lower. The
exception escapes __register_segments, so the object does not load at all and
nothing downstream runs.

Replace the stream on both sections, which is what the other five already do.
Symbol versions now decode: 112 of the fixture's symbols get a library
version.

The two assignments carry "# type: ignore" because pyright cannot see that
pyelftools reads these attributes back through duck typing, and Typecheck
budgets errors per file: the five older sections predate the budget, so their
identical assignments are grandfathered and two new ones would fail the check.

Diagnosed by @fkil in #383, who also proposed this fix. Wrong since 99379eb
added symbol versioning in 2022, which gave the versym table a memory stream
and these two none.

Fixes #383

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

wrong offsets for elftools.elf.elffile.GNUNeedVerSection

2 participants