ELF: Survive a GNU hash table that declares no buckets - #792
Conversation
pyelftools counts dynamic symbols out of DT_GNU_HASH before anything else and calls max() on the bucket array without a default, so an ELF whose .gnu.hash declares nbuckets == 0 raises ValueError out of DynamicSegment.num_symbols() and takes the whole load with it. Nothing else about such a file need be broken: its DT_SYMTAB, relocations and version tables can be entirely intact, and the hash table only accelerates lookup by name. No linker emits an empty bucket array -- GNU ld and LLD both write nbuckets = 1 for a table with nothing to hash -- so this arrives on corrupted and deliberately mangled input, which the loader is meant to degrade on rather than refuse. Fall back to the same bound pyelftools uses for a file that carries no hash table at all, the nearest dynamic pointer above DT_SYMTAB, restricted to tags that really do hold addresses so a size field cannot truncate the table. cle's own GNUHashTable had the matching hole one step later: with an empty bucket array and a non-empty bloom filter, get() divides by zero. ELFHashTable already returns no match for that shape; make GNUHashTable agree. The fixture is binaries/tests/x86_64/gnu_hash_resiliency_0, which is test_killing_ref with the bytes of its two hash tables zeroed, so the test can require that the loader come out the same either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS Validation record for head The fallback bound is exact, not merely plausible, and the fixture lets you check that without any file you do not have. Its
No linker can produce this input, and that was checked positively rather than only by absence. Across 8,424 ELF files from a distribution's package closure, 8,332 of which carry The second hunk covers the same shape one step later. Loading is otherwise unchanged. Every ELF in angr/binaries was loaded twice, once per revision, and the runs compared on backend, architecture, entry, linking, dependencies, object count, and digests of the full symbol, section, segment, relocation and PLT tables: 831 files paired, 0 regressions, 0 differences of any kind. A further non-redistributable set of 794 files was compared the same way: 0 regressions, 0 differences, and 1 file that previously lost its entire load and now loads and decompiles. The workspace gate ran on these two branches in isolation and passed. It ran in a per-feature instance holding cle at One coverage gap, stated rather than left to be discovered. The paired load comparison does not reuse this workspace's decompilation harness: that harness decompiles every function of a binary, which is not affordable across 1,625 files, and its comparison tool keys on per-function results a load-only record does not have, so a load-only probe was written for it. |
|
Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_792 |
|
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS Full loader output for Before — the load aborts in pyelftools while sizing the dynamic symbol table from an empty bucket array, and the object is lost entirely: cle master (d2ecea0)After — the fallback bound takes over, one warning is logged, and the object loads identically to the uncorrupted with this change (43611ed) |
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS
Problem
An ELF whose GNU hash table declares no buckets aborts the whole load. On
binaries/tests/x86_64/gnu_hash_resiliency_0, the fixture in the linked binaries pull request —test_killing_refwith the 96 bytes of itsDT_HASHandDT_GNU_HASHtables zeroed, the way some stripping and obfuscation tools leave a binary:Nothing else about such a file need be broken. Its symbol table, relocations and version tables can be entirely intact, and both hash tables only accelerate lookup by name, so cle refuses a file it could read.
Root cause
ELF.__register_dyncallsseg_readelf.num_symbols()unguarded. pyelftools sizesDT_SYMTABfrom whichever hash table it finds, andGnuHashSection.get_number_of_symbolsdoesmax(self.params['buckets'])on an array that is empty here. The exception escapes__register_dyn,__register_segmentsandELF.__init__, so a malformed lookup accelerator costs the entire load.The same shape reaches cle's own table one step later:
GNUHashTable.getdoesh % self.nbuckets, which isZeroDivisionErrorwhennbuckets == 0.ELFHashTable.getalready returned no match for that case, so the two siblings disagreed.Fix
ELF.__num_dynamic_symbolscatchesELFErrorandValueErrorfromnum_symbols()and falls back to the bound pyelftools itself uses for a file with no hash table at all: the nearest dynamic pointer aboveDT_SYMTAB, restricted to tags that really do hold addresses rather than sizes or string-table offsets.GNUHashTable.getreturns no match whennbuckets == 0. Same file, this head:The fallback bound is exact rather than merely plausible, and checkable from the file:
DT_SYMTABis0x3d0andDT_STRTABis0x4a8, so atDT_SYMENT24 the table holds 9 entries with no remainder, and independentlyDT_VERSYM 0x5feplus two bytes per symbol for 9 symbols reaches0x610, which is exactlyDT_VERNEED.Testing
tests/test_gnu_hash_resiliency.py::test_zeroed_hash_tablesloads the corrupted fixture and the uncorruptedtest_killing_refand requires the same entry, the same imports, the same relocation count and the same(name, relative_addr)symbol set. It fails on the baseline with theValueErrorabove and passes here;pytest testsreports 240 passed and 9 skipped against 239 and 9 on the baseline.No linker emits an empty bucket array, checked positively: across 8,424 ELF files from a distribution's package closure the smallest
nbucketsis 1, and a shared object exporting nothing links tonbuckets = 1under GNU ld 2.46 and under LLD 21.1.8 alike. Every ELF inangr/binarieswas loaded on both revisions and compared on backend, entry, dependencies and digests of the symbol, section, segment, relocation and PLT tables: 831 files, 0 differences.Validation: #792 (comment)
sync: angr/binaries#200
session: sharpen