elffile: crash with `RuntimeError: generator raised StopIteration` when a relocation table tag lacks its companion size or entry-size tag
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.pyreports 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 atdynamic.py:202-227andDT_RELAENTatdynamic.py:213;pyproject.tomlrequires>=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 -rDprints<missing or corrupt dynamic tag: DT_RELAENT>in place of the entries and exits 0. radare2 zero-initialisesdt_relaentand guards the entry count onif (di->dt_relaent)inget_num_relocs_dynamic, which bounds the parse loop. rizin guards it on the tag lookup succeeding, inget_relocs_entry_from_dt_dynamic. Frida leavesentsizeat 0 and bails toinvalid_groupingum_elf_module_emit_relocations. Ghidra'sElfHeader.parseDynamicRelocTableletsgetDynamicValue(DT_RELAENT)throwNotFoundExceptionand 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 readsDT_RELAENT:parse_dynamic_relocationscomputessize / sizeof(REL_T)fromDT_RELASZ. goblin storesrelaentinDynamicInfobut never passes it toRelocSection::parse_inner, which sizes fromReladirectly.
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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