IOCX v0.7.6.1 — Exception Directory Validator, and a Silent Output Defect
This release adds deep semantic validation of the PE exception (.pdata) directory, and fixes a defect that had been silently suppressing structural findings across the whole engine.
The headline fix: reason codes were being overwritten
The heuristics emission layer built its payload as {"reason": parent_code, **validator_details}. Any validator that put a key
literally named reason in its details therefore overwrote its own parent reason code.
Eleven documented reason codes had never once appeared in output. Instead, consumers saw bare sub-reason strings - unmapped, not_power_of_two, missing_table_rva - with no way to tell which check produced them. Two different validators both emit unmapped; both surfaced identically.
The defect was invisible for a long time because the clobbered output still looked structured: a plausible string in a reason field. And analysis["structural"] is never copied into the result document, so no unclobbered view existed to compare against.
Fixed at both ends. Validators now use sub_reason; the emission layer writes the parent code last and re-keys any legacy reason payload it receives, so an unmigrated validator can no longer clobber anything.
Three checks that were dead in production
Separately, several validators read SizeOfImage from the analysis layer, which does not carry that key - it is optional-header truth. The reads returned None every time, so the guarded checks never ran:
| Check | Status before |
|---|---|
EXPORT_DIRECTORY_OUT_OF_BOUNDS |
never fired |
DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS |
never fired |
DEBUG_ENTRY_RVA_INVALID / RELOCATION_ENTRY_RVA_INVALID fallback |
never fired when a file had no section geometry |
All now source SizeOfImage from metadata["optional_header"] and thread it explicitly into the shared _directory_invariants helpers.
Two more suppression bugs
DATA_DIRECTORY_NOT_MAPPED_TO_SECTIONwas suppressed for any file carrying an overlay. A raw-mapping guard used a barecontinuethat skipped the section-mapping checks entirely, so the mere presence of an unrelated overlay silenced the finding. The guard is now scoped to the overlay check alone.RESOURCE_DIRECTORY_OUT_OF_BOUNDSwas declared inReasonCodes, documented in the spec, and never emitted - a resource directory outside.rsrctriggered a silentreturn. It now reports, and distinguishes the root case from a subdirectory whose extent overflows the section end.
New: Exception Directory Validator (§2.15)
Deep semantic validation of the x64 RUNTIME_FUNCTION table and the ARM/ARM64 .pdata record walk, backed by a new pure-struct decoder (pe_exception) that does not rely on pefile's DIRECTORY_ENTRY_EXCEPTION interpretation.
The headline heuristic is table sortedness. The loader binary-searches .pdata, so entries must ascend by BeginAddress. An out-of-order entry means a function silently loses its unwind data at runtime while every byte is present on disk - a real evasion vector that leaves the file structurally intact.
Also covered: directory alignment and stride consistency, per-entry RVA bounds, function-range validity, adjacent-range overlap, and AMD64 UNWIND_INFO semantics including version/flag validation and chained-unwind target sanity.
Architecture handling is explicit rather than assumed:
- AMD64 - 12-byte records with
.xdataUNWIND_INFOdecode - ARM64 / ARM64EC / ARMNT - 8-byte records, packed vs
.xdataflag, noEndAddress(so range and overlap checks correctly no-op) - x86 / IA-64 / unknown - reported once as unsupported; the walk is skipped rather than manufacturing false positives on a format we cannot interpret
UNWIND_INFO V3 (APX preview) is recognised but not deeply parsed: version and flags are surfaced and the chain is not followed, rather than mis-decoding a repacked payload.
14 new reason codes. See docs/specs/reason-codes.md.
Test suite
1620 → 2136 tests, coverage held at 100%.
The most important addition is tests/unit/analysis/test_reason_codes.py, which locks the emission contract at both ends:
- Source - a static scan asserts no validator uses a top-level
reasonkey, catching the defect at authoring time across validators no fixture happens to exercise. - Output - every
pe_structure_anomalyreason must be a declaredReasonCode, and a deliberately unmigrated payload is tested to prove the emission layer defends itself rather than relying on every validator having been updated.
Either test alone would have caught the original defect.
Beyond that, the sweep found and fixed a category of test that passed without asserting anything - predicates that filtered on the old key name and then asserted the result was empty, which is vacuously true once the key is gone. Several fixtures were also emitting more codes than they claimed to test.
Upgrading
This release changes output. Structural findings that were previously suppressed or mislabelled will now appear. Specifically:
metadata.reasonon ape_structure_anomalyis now always a declared reason code; the narrowing detail moved tometadata.sub_reason- files with an overlay may report new
DATA_DIRECTORY_NOT_MAPPED_TO_SECTIONfindings - malformed export, delay-load, resource and exception directories may report findings that were previously silent
Regenerate contract snapshots. Consumers keying on metadata.reason should expect the documented parent code rather than a sub-reason string - this is the behaviour the spec described all along.