mandiant / mandiant/capa

elffile: crash with `RuntimeError: generator raised StopIteration` when a relocation table tag lacks its companion size or entry-size tag

Open
#3,171 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
6.2k
Forks
726
Avg merge
11d 11h
Merged PRs (30d)
7

Description

Description

capa aborts with RuntimeError: generator raised StopIteration on an ELF that carries a relocation-table dynamic tag without its companion size or entry-size tag, for example DT_RELA with no DT_RELAENT.

pyelftools Dynamic.get_relocation_tables (dynamic.py:234-273) gates each table on the presence of the table tag alone, then reads the companion tags with bare next() calls that have no default. There are eight of them, and every one is reachable (line numbers are pyelftools 0.33):

if list(self.iter_tags('DT_RELA')):
    result['RELA'] = RelocationTable(self.elffile,
        self.get_table_offset('DT_RELA')[1],
        next(self.iter_tags('DT_RELASZ'))['d_val'], True)   # dynamic.py:255

    relentsz = next(self.iter_tags('DT_RELAENT'))['d_val']  # dynamic.py:257
line bare next() gated on
246 DT_RELSZ DT_REL
248 DT_RELENT DT_REL
255 DT_RELASZ DT_RELA
257 DT_RELAENT DT_RELA
264 DT_RELRSZ DT_RELR
265 DT_RELRENT DT_RELR
270 DT_PLTRELSZ DT_JMPREL
271 DT_PLTREL DT_JMPREL

When any of those tags is absent the next() raises StopIteration inside capa's extract_file_import_names, which is a generator, so PEP 479 turns it into RuntimeError when the generator is next advanced. That is neither ELFError nor OverflowError, so it escapes the handler at main.py:777 and surfaces as "Unexpected exception raised".

This is a separate root cause from the stale DT_GNU_HASH crash (#3170), though the user-facing symptom is the same. #3170 said it would be filed separately, and described this as a DT_RELA-without-DT_RELAENT path. That was the sample I had reduced at the time; the tag counts below show the missing tag is DT_RELAENT in only a fifth of the affected corpus, so the description here supersedes it. The count of 125 quoted in #3170 is unchanged.

Steps to Reproduce

Take any x86-64 dynamically linked ELF and relabel one companion tag so it is absent while its table tag remains. DT_RELAENT (tag 9), relabelled as DT_DEBUG (tag 21), which no parser sizes anything from:

import io, struct
from pathlib import Path
from elftools.elf.elffile import ELFFile
from elftools.elf.dynamic import DynamicSegment

buf = bytearray(Path("hello").read_bytes())
seg = next(s for s in ELFFile(io.BytesIO(bytes(buf))).iter_segments()
           if isinstance(s, DynamicSegment))
off = seg["p_offset"]
for i in range(0, seg["p_filesz"], 16):
    tag, _ = struct.unpack_from("<qQ", buf, off + i)
    if tag == 9:                      # DT_RELAENT
        struct.pack_into("<q", buf, off + i, 21)  # relabel as DT_DEBUG
        break
Path("no_relaent").write_bytes(bytes(buf))
$ capa ./no_relaent
Unexpected exception raised: <class 'RuntimeError'>. ...

Substituting tag 8 (DT_RELASZ), 2 (DT_PLTRELSZ) or 20 (DT_PLTREL) for tag 9 above gives the identical RuntimeError from the same call site, so the crash is not specific to DT_RELAENT.

Expected behavior: capa skips the unparseable relocation tables and continues, the way the loop immediately below already does for corrupt relocation entries (elffile.py:127-132 catches TypeError with the comment "ELF is corrupt and the relocation table is invalid, so stop processing it").

Actual behavior: unhandled RuntimeError, exit code 1, no results at all. With -d:

Traceback (most recent call last):
  File "capa/capa/features/extractors/elffile.py", line 121, in extract_file_import_names
    relocation_tables = segment.get_relocation_tables()
  File "site-packages/elftools/elf/dynamic.py", line 257, in get_relocation_tables
    relentsz = next(self.iter_tags('DT_RELAENT'))['d_val']
StopIteration

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
  File "capa/capa/features/extractors/elffile.py", line 190, in extract_file_features
    for feature, addr in file_handler(elf=elf, buf=buf):  # type: ignore
RuntimeError: generator raised StopIteration
Versions
  • capa from a source checkout at 286e11ee, which is 497120f plus a rules-submodule sync (capa/version.py reports 9.4.0), so the same capa source #3170 was filed against
  • Python 3.12.12, pyelftools 0.33 (reproduced identically on 0.31 and 0.32, where the eight bare next() calls sit at dynamic.py:202-227 and DT_RELAENT at dynamic.py:213; pyproject.toml requires >=0.31)
  • Ubuntu 26.04 LTS x86-64, sample built with gcc 15.2.0
Additional Information

Found by the mutation fuzzing described in #3170: 3000 samples of a hello-world binary, each with one to four random bytes overwritten in one of four structured regions drawn uniformly (ELF header and head of the program header table; .gnu.hash through .rela.plt; the .dynamic tag array; the section header table), each run through extract_file_features in isolation. 125 of 3000 mutations (4.2%) hit this path and produced an unhandled RuntimeError. It was the single largest source of unhandled exceptions in the extractor: 125 of the 161 unhandled tracebacks, the other 36 being the stale DT_GNU_HASH crash.

Recording which next() raised, across those 125:

missing tag samples
DT_RELASZ 42
DT_PLTRELSZ 35
DT_RELAENT 25
DT_PLTREL 22
DT_RELRSZ 1

So five of the eight call sites fire on a corpus this small, and fixing only DT_RELAENT would leave 80% of the crashes in place. The fix wants to be at the call, not at one tag.

Suggested fix, matching the idiom already used a few lines below:

try:
    relocation_tables = segment.get_relocation_tables()
except (ELFError, ValueError, struct.error, StopIteration) as e:
    # ELF is corrupt: the relocation tags are inconsistent, for example
    # DT_RELA is present but DT_RELAENT is missing, so skip this segment.
    logger.debug("failed to parse relocation tables: %s", e)
    continue

elffile.py currently imports neither of the two names that needs, so the change also adds import struct and from elftools.common.exceptions import ELFError. Catching StopIteration at this call is safe rather than merely expedient: it never crosses the generator boundary, because get_relocation_tables is an ordinary function call and PEP 479 only rewrites a StopIteration that escapes the generator frame itself.

With that change alone, the same 3000-mutation corpus produces no RuntimeError at all: the extractor completes on 2711 samples instead of 2554, and 253 raise ELFError or OverflowError, which main.py:777 reports cleanly as corrupt via E_CORRUPT_FILE. The 36 unhandled tracebacks that remain are all the stale DT_GNU_HASH path, which is #3170's.

This is how other tools behave on the same input. None of them aborts, and they split two ways:

  • Skip the table. GNU readelf -rD prints <missing or corrupt dynamic tag: DT_RELAENT> in place of the entries and exits 0. radare2 zero-initialises dt_relaent and guards the entry count on if (di->dt_relaent) in get_num_relocs_dynamic, which bounds the parse loop. rizin guards it on the tag lookup succeeding, in get_relocs_entry_from_dt_dynamic. Frida leaves entsize at 0 and bails to invalid_group in gum_elf_module_emit_relocations. Ghidra's ElfHeader.parseDynamicRelocTable lets getDynamicValue(DT_RELAENT) throw NotFoundException and catches it with the comment "ignore - skip (required dynamic table value is missing)", on its segment-based path; when the table address falls inside a known section it sizes from the section header instead.
  • Ignore the tag and size entries at sizeof(Elf_Rela). LIEF's parser never reads DT_RELAENT: parse_dynamic_relocations computes size / sizeof(REL_T) from DT_RELASZ. goblin stores relaent in DynamicInfo but never passes it to RelocSection::parse_inner, which sizes from Rela directly.

The fix above matches the first group.

A second builtin exception escapes from the same extractor and is worth fixing at the same time.
#3170 flagged it as belonging with this report. When DT_STRTAB points outside the file and no section headers remain, symbol-name resolution in Dynamic._get_stringtable ends in a bare assert (dynamic.py:183) and extract_file_export_names raises AssertionError. On pyelftools 0.31 and 0.32 that method returns None instead and the same input raises AttributeError at dynamic.py:333. Neither is an ELFError, so both escape main.py:777 the same way. This one did not surface in the fuzz run above; I found it while investigating #3170.

There is an upstream component too: get_relocation_tables could read all eight companion tags with next(..., None) and raise ELFError when one is missing, and _get_stringtable could raise ELFError rather than asserting. That is the same class of defect as eliben/pyelftools#612, "Uncaught exceptions on fuzzed but valid ELF files", which the maintainers closed as completed via eliben/pyelftools#628; that PR touched only descriptions.py and elffile.py, and its fuzzing never reached these call sites. Both are unchanged on upstream main, at the same line numbers as 0.33. I have not filed the two above upstream for now. Either way capa needs its own handling, since pyproject.toml allows pyelftools >=0.31.

If this approach looks right to you, I am happy to open a PR with the fix and the tests. If you would rather solve it a different way, I am glad to discuss it here first and follow whichever direction you prefer.

Note on tooling

This issue was written with AI tooling (Claude Code). The fuzzing, the test samples and the text above were produced with AI assistance. I reviewed and double checked the AI's findings, and reproduced the crash myself before filing.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in capa/features/extractors/elffile.py at extract_file_import_names and the relocation-table handling around lines 121-132; the exception handling at main.py:777 shows how corrupt files are reported. Run the provided malformed-ELF reproduction, then verify that missing companion tags are skipped without RuntimeError and that extraction completes with a clean corrupt-file result.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
reverse-engineering
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.