Skip to content

v0.7.6.1

Latest

Choose a tag to compare

@malx-labs malx-labs released this 27 Aug 11:55
d4d3e05

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_SECTION was suppressed for any file carrying an overlay. A raw-mapping guard used a bare continue that 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_BOUNDS was declared in ReasonCodes, documented in the spec, and never emitted - a resource directory outside .rsrc triggered a silent return. 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 .xdata UNWIND_INFO decode
  • ARM64 / ARM64EC / ARMNT - 8-byte records, packed vs .xdata flag, no EndAddress (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 reason key, catching the defect at authoring time across validators no fixture happens to exercise.
  • Output - every pe_structure_anomaly reason must be a declared ReasonCode, 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.reason on a pe_structure_anomaly is now always a declared reason code; the narrowing detail moved to metadata.sub_reason
  • files with an overlay may report new DATA_DIRECTORY_NOT_MAPPED_TO_SECTION findings
  • 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.