Skip to content

ELF

Five entries about binfmt/elf.py. The first covers the widest set of shapes — a .dynamic/.dynsym/.symtab lookup has to reject each of them the same way — and the pattern behind all of them is worth internalising before reading any: an attacker-controlled label winning a lookup, so the check meant to catch a mismatch never runs. One more decision about this module — its module-local line-count exemption — is about tooling policy rather than the reader, and lives under Scanning, caching and layout.

Sections are found by type, not by a name nobody checks

Accepted, and it changes records.

Comparing section.name against .dynamic, .dynsym and .symtab trusts a label, but the ELF loader never reads section names or the section header table at all: it walks PT_DYNAMIC and the tags it points at. A name-based lookup trusts a label nothing downstream of the compiler checks. Found by name:

honest: EVP_DigestInit_ex, SSL_new imported, libc.so.6 in DT_NEEDED
.dynsym renamed .dynsyx in .shstrtab, same bytes otherwise  -> NO_CRYPTO_DETECTED,
                                    needs_human_review: false, partial_analysis: false

Renaming .dynamic too empties needed as well, and the object still loads and runs through ctypes exactly as before: nothing about what the loader does differs, only what one label in .shstrtab says.

The check has two parts, because two shapes produce the same silence. The three sections are found by sh_type. pyelftools builds the right wrapper class from sh_type alone; the name only ever becomes an attribute this reader is the only thing reading. .go.buildinfo, .note.go.buildid and .comment stay name-based — they are plain SHT_PROGBITS/SHT_NOTE sections with no type of their own, so a name is the only signal there is.

The second shape is not a renamed label but no section header table at all. e_shoff == 0 is a loadable object's own right, since the dynamic linker never reads one, and unhandled it reads worse than a header that would not parse: the unparsed path scans the whole file for strings, but an empty section list feeds nothing to the strings pass, so a statically-linked cryptography extension with no section headers would lose even its OpenSSL version banner — its only evidence — with no error and partial_analysis: false. It falls back to the same whole-file strings pass, tagged elf_section_table_absent.

That token is its own rather than a reuse of elf_sections_unread, which means "a section header could not be read" at a specific index. Here there is no section list to try at all, so those sections are not merely unread but unavailable.

What type-based section lookup has to reject

A decoy of the matching type ahead of the real table must not hide it. "The first section of a matching type wins" would let a decoy hide the real table — the exact failure this entry exists to prevent, one level down. Reproduced on a real, loadable /usr/lib64/libcrypto.so.3: two 64-byte decoy section headers spliced in before the real .dynsym and .dynamic, every dependent index shifted to stay valid. The object still loads and runs; name-based lookup alone reads needed = ('libc.so.6', 'libz.so.1'), a soname and 6043 dynsyms, CONDITIONAL — "first match wins" over the decoyed section list would read needed = (), 0 symbols, partial_analysis: false, 0 errors, NO_CRYPTO_DETECTED.

The symbol/string cross-check ("A symbol table is checked against the string table, not taken at its word") does not help here: it is sound only over the string table the chosen section's sh_link names, and the decoy's sh_link points at .shstrtab, which disarms it entirely rather than tripping it. That disarm property is its own attack surface, closed separately below.

The lookup reports ambiguity instead of returning a single answer, and the caller trusts neither candidate: everything derived reads empty, tagged elf_section_type_ambiguous, so an object carrying two SHT_DYNSYM sections never reads as one carrying none. Tests cover both orderings — the decoy after the real section (the shape a first-match-wins lookup would get right by accident) and before it (the shape it would get wrong).

A forged sh_type on a legitimately-named section must not read as fully absent, which would be worse than trusting the name blindly. One four-byte edit — .dynsym's sh_type changed from SHT_DYNSYM to SHT_PROGBITS, the name left untouched — and the object still loads. Name-based lookup alone still finds it, the wrapper class is wrong and the count fails, so it correctly sets partial_analysis: true; the raw symbol bytes are read by offset and size directly regardless, so it still recovers 64 symbols on a real libcrypto.so.3. Type-based lookup alone would find nothing and treat that as genuinely absent: clean, no evidence, no flag — strictly worse than trusting the name.

_type_mismatch closes it: a name-based lookup checks whether a section still called one of the three names exists whose declared sh_type does not match. If it does, that is a section that exists and cannot be trusted, folded into the same cause a read failure carries.

The mismatch check must not run only when the type-based lookup found nothing, or one decoy of the target type disables it entirely — the same hazard as above, through a third door. One harmless decoy makes the type-based lookup succeed unambiguously, so a gated mismatch check never runs; combine that with forging the real section's own sh_type away and the real section is invisible from both directions at once.

honest:       partial=False needed=('libc.so.6',) syms=['EVP_DigestInit_ex','SSL_new']
              linkage={'openssl':'unknown'} verdict=OPAQUE      needs_human_review=True
decoy+forge:  partial=False needed=('libc.so.6',) syms=[]       errors=0
  (gated)     linkage={'openssl':'none'}    verdict=NO_CRYPTO_DETECTED needs_human_review=False

The headline failure verbatim, reached through the very check meant to close a narrower version of it, and worse than name-based lookup alone, which still catches this exact byte pattern. The mismatch check runs unconditionally, every time, at no cost on an honest object.

_type_mismatch's own building block must reject the same hazard, applied to name instead of type. Finding "the section named X" with a first-match-wins lookup is the identical hazard above, applied to name instead of type. A decoy reusing the real section's own name, correctly typed, sorts first and reports no mismatch, so a same-named real section behind it with its own forged sh_type would go unseen from both directions again — the headline failure again, through the building block meant to close it. More than one section sharing a name is treated the same way as more than one sharing a type.

sh_link itself must be validated, not trusted at face value. The string table is resolved by raw index; trusting it with no check that the resolved section is even a string table, and no reconciliation against .dynamic's own DT_STRTAB tag (which the reader parses anyway), leaves it open to the identical hazard above. sh_link is a field the loader never reads either.

Reproduced against a reader that trusts sh_link, on real, unmodified loadable objects — /usr/lib64/libcrypto.so.3 and a real CPython _hashlib extension: append N NUL bytes plus a new SHT_STRTAB header pointing at them, then repoint .dynsym's sh_link at that index. Every name index resolves to "" against an all-NUL table, and "" is a name resolved, not one flagged unresolved. libcrypto.so.3's 64 crypto symbols and _hashlib's 24 both go to zero, with partial_analysis: false, no error, and both objects still loading.

.dynamic's own sh_link has the identical hole and is worse: it can fabricate evidence, not just erase it. A decoy that happens to spell a real dependency name at the offset a real DT_NEEDED tag points at reports a dependency the object never declares — the fabrication direction of "a name reported is a name read in full", rather than the truncation direction.

Both are reconciled against data the reader has in hand: a resolved section corroborates only when its sh_type is SHT_STRTAB and its sh_addr matches DT_STRTAB's d_ptr. Either mismatched, or no DT_STRTAB to compare against at all, fails closed. This cascades correctly: an object whose .dynamic cannot be corroborated has no DT_STRTAB to hand .dynsym either, so .dynsym's string table is untrusted too even when its own sh_link is perfectly honest.

What was rejected. Reading PT_LOAD and the program headers directly — the loader's own path, immune to a section header table that lies in ways these checks do not cover. It is the more complete approach and materially more work, a second parser for information this reader gets from section headers in the common case.

Accepted residual

A decoy resolved through sh_link that is correctly typed and whose sh_addr matches, but whose sh_offset alone points at fabricated bytes, still reads clean. Nothing outside PT_LOAD segment contents corroborates that a given virtual address really lives at a given file offset. Do not chase this by adding another targeted field comparison; the next real gap in this family is answered by program-header-based translation as a whole, not by another field.

Full entry


A compressed section is checked before it is inflated

Accepted. Refuse to inflate past what can be used.

Section.data() decompresses a SHF_COMPRESSED section's declared ch_size bytes before the reader's own budget ever applies. ch_size is a 64-bit field the object declares about itself, except here the number buys an actual zlib call rather than a Python loop, so the cost is a memory spike. The reproduction, without a check: a 255 KiB ELF declaring a 256 MiB .rodata, peaking at 512 MiB in 0.82 s. The archive-level compression-ratio guard never sees this shape, because the zlib stream sits inside the zip member, whose own ratio looks ordinary.

Checked with a call pyelftools makes anyway, not a new one. pyelftools reads the compression header eagerly and cheaply and exposes section.compressed and section.data_size, so the guard reads a property and refuses without calling .data() at all. Both ELF classes are covered by construction.

.go.buildinfo carries a second, uncompressed exposure the same check must close. Section.data() checks SHT_NOBITS before it checks compression, and for such a section returns that many zero bytes with no file bytes read to justify the length. .go.buildinfo is found by name with no type check, so a section named .go.buildinfo and flagged SHT_NOBITS would reach the call exactly the way an honest one does through a guard keyed only on compression: a 298-byte object declaring 2048 MiB peaks at 2 GiB, partial_analysis: false, no error — three orders of magnitude cheaper to build than the compressed reproduction, and silent rather than merely expensive.

The boundary is "more than", not "at least". A section declaring exactly the remaining budget is refused nothing.

strings_truncated can under-report a budget refusal, and this is left as-is rather than papered over. A refused section was never read at all, so nothing knows how many of its bytes would have been genuine strings. Setting the flag anyway does not hold up: a fixture has .rodata filled with the OpenSSL banner text and SHF_COMPRESSED set over bytes that were never compressed, whose first 24 bytes decode as a header with ch_size around 3.76×10^18 purely by accident of what those ASCII bytes are. There was never a real 3.76-exabyte payload to have dropped part of.

What was rejected. Decompressing with a caller-supplied max_length, keeping whatever prefix fits. It is bounded correctly — verified directly — and that is not what rules it out: it needs a second zlib call outside pyelftools, a second reader for one fact, and it would split two call sites onto different rules for the identical shape. And treating any compressed eligible section as unread regardless of size, which throws away an honest, small, well-under-budget banner for no reason.

An ordinary section is bounded too

Checking the budget only when the section is compressed or SHT_NOBITS is not enough. An ordinary, uncompressed, file-backed section falls through that and entirely and reaches .data() unconditionally, at whatever size sh_size names, with only the accumulated buffer cut afterwards. So the declared size is checked against the budget for every section regardless of shape.

With the narrower guard: an honest 8 MiB .rodata against a 64 KiB budget is one 8388608-byte read. A .dynsym/.dynstr from 200,000 real symbol names against the same budget reads partial_analysis: false — a fully clean, complete record, paid for at the size of the honest table rather than the budget, with no way for a consumer to tell.

Two more shapes this budget must also close.

A refused .dynsym or .dynstr must not reach the cross-check unguarded, or it can fabricate symtab_understates_rows — a specific, checkable claim ("every structural check passes and the count is simply not the truth") — against a table that was never shown to lie, only left unread. With .dynsym refused, no rows are yielded, so the sibling branch would find a real crypto name in an honest .dynstr unclaimed by any row and report it as an understated count. Reproduced without the ordering: 101 entries against a 600-byte budget read ('elf_dynsym_unread', 'symtab_understates_rows') with the error ".dynsym declares fewer entries than .dynstr holds names for", both false. The refusal is checked first and the whole per-symbol walk skipped.

An over-budget ordinary section keeps its in-budget prefix, not discarding its entire content, the same as every other reader in the codebase. Reproduced against a real host library, /usr/lib64/libLLVM.so.22.1 (68.3 MB of eligible sections against the 64 MiB default): keeping the in-budget prefix reads it strings_bytes_unread, no errors; refusing it outright would read it elf_section_data_unread plus a spurious elf_parse_error, which fires "truncated, corrupt or an unrecognised format" over BIN_STATIC_OPENSSL — a false-positive downgrade on a perfectly ordinary, healthy object, at a size range that ML and scientific wheels commonly reach. A keep_prefix flag hands back the section's own real first max_bytes for .rodata/.comment/.go.buildinfo, one plain read, no decompression.

.dynsym/.dynstr keep the all-or-nothing refusal on purpose: a byte-bounded prefix of a symbol table is not a set of complete rows, and an entry near the cut is as likely to point past a truncated string table as into it. The honest way to bound a symbol table without losing rows to that ambiguity exists in the Mach-O reader; bringing it here is a real change, and a different, larger one.

Full entry


.symtab is matched for crypto symbols when .dynsym is genuinely absent

Accepted, and it changes records.

Crypto symbol matching over .dynsym alone is correct for a dynamically linked object, where .dynsym is what the dynamic linker actually uses, but a relocatable object (ET_REL, a .o/.obj before linking, the shape every static archive member has) or a statically linked executable normally has no .dynsym at all, only .symtab. A genuine EVP_DigestInit_ex definition there, with no accompanying string banner, would be invisible to the tool's primary detection mechanism.

Full matching — imports and definitions, .symtab as the object's only table — applies only when .dynsym is genuinely absent: a dynamically linked shared object or executable always carries a live .dynsym (strip cannot remove it without breaking dynamic linking), so this mode leaves that part of the corpus unaffected by construction. A statically linked executable is matched too, which is this reader's intended reach, not an incidental side effect. "Genuinely absent" excludes an ambiguous or type-forged .dynsym, and a .dynsym whose own section header failed to parse and so never reached the section list at all — all three make the lookup return None without the object genuinely having none. Beside a live .dynsym, .symtab supplies definitions only (the next entry).

Shapes this check must also reject, beyond a corroborated sh_link. A .symtab repointed at a decoy, appended, all-NUL SHT_STRTAB resolves every name to "" — not unresolved, resolved — so a crafted object could hide a genuine crypto definition and read completely clean, the identical decoy _validated_strtab exists to close for .dynsym, reachable here because .symtab has no independent authority to corroborate sh_link against. _any_strtab_holds_a_name_not_read closes it by not trusting the one table sh_link names: the cross-check asks every SHT_STRTAB section in the object, so the real .strtab still gets to contradict the decoy regardless of which one .symtab claims. Skipping a SHT_STRTAB section it cannot fully read within budget would reopen the identical decoy under a second construction — a small decoy plus a genuine .strtab inflated past the budget elsewhere. An unread SHT_STRTAB counts as a hit, not a skip. Separately, .symtab carries STT_FILE/STT_SECTION pseudo-symbols .dynsym does not — a source file named EVP_md5.c would match a group by filename coincidence alone; filtered by type, while still counted as read for the understated-rows check, so filtering evidence does not itself manufacture a false "the object understates its own rows" claim.

binfmt.ar needs nothing of its own for an ELF member: it calls read_binary per member regardless of format, so this reads every ELF archive member through binfmt.elf directly. A Windows .lib's COFF members are untouched; binfmt.pe deliberately does not read the COFF symbol table at all.

It costs the linkage answer. elf_symtab_unread is not exempt from costing the linkage answer, since .symtab feeds the imported/defined split: see Linkage reads a second split over the same vocabulary.

Full entry

.symtab local definitions are read when .dynsym is present

Accepted, and it changes records.

Matching .symtab only when .dynsym is genuinely absent (the entry above) leaves every dynamically linked object unaffected by construction — and on its own leaves the case this tool exists for unread. cryptography 50.0.1 carries 776 local EVP_* definitions in .symtab and none in .dynsym, whose only exports are the module's own PyInit symbols. The statically linked OpenSSL sits in a table the reader parses for stripped anyway, and an absence-gated reader never looks.

Only definitions are read beside a live .dynsym: a dynamically linked object must declare every import in .dynsym to link at all, so taking imports from a debug table would restate them under a second provenance, or let a planted undefined entry read as a dependency the object does not have. Of .symtab's two cross-checks, the understated-rows one does not run in that mode, because it exists to catch the object's only table understating itself, and a partially stripped .symtab is ordinary rather than a lie. The unresolved count asks a different question — was a name the reader was pointed at readable — and an honest table never fails it, so it runs in both modes.

Measured over 18 native wheels against the absence gate alone: five verdict blocks differ and one headline class moves; three wheels go openssl_linkage: none → static on the AWS-LC they compile in, with no finding and no (group, binding) kind lost anywhere. Every row is walked, on every such object. A symbol_locator prefilter over .strtab would avoid that, and is rejected: it is worth 0.32s against 0.55s on a 26.7 MiB object with half a million symbols, and a .strtab shrunk to hide a name is one the prefilter reads as holding nothing, so the walk that would catch it never runs (cryptography 0.43s → 0.50s, scipy 8.37s → 8.40s).

The cross-check that compares .symtab's rows against every SHT_STRTAB in the object runs here too, because it is the only thing that sees a sh_link repointed at a decoy table of NULs, where every row "resolves" to the empty name. It is fed the names both tables resolved, so an imported name .dynsym accounted for does not read as one the object hid.

What it costs is stated in full in DESIGN.md: a crafted object can plant names in a table trusted less than .dynsym, and the consequence is a false definition, which over-flags rather than under-flags.


A version banner that lives in code is read from executable sections, for the groups that say so

Accepted, and it changes verdicts.

AWS-LC's FIPS build delocates its own version banner into .text so its integrity hash covers it: AWS-LC FIPS 4.2.0 sits at file offset 0x8ae5c on the measured build. _collect_string_bytes reads only allocated, non-executable sections, on purpose, so this is a second, separate read: a [[string_group]] entry can set in_code = true -- today only aws_lc_fips does -- and only that group's substrings are searched for in executable sections, through one compiled byte regex built the same way symbol_locator is. A hit is windowed and extracted, not the whole section; overlapping or adjacent windows merge into one first, so a section packed with the same repeated banner does not multiply into many times its own size. The read spends its own budget, independent of the read-only pass's, and an unread or truncated code region records no error and no partial reason: the read exists only to tell a validated build from a stock one, and missing it leaves the object reading whatever its read-only evidence already gives, the same as for every group that carries no in_code flag.

Full entry