Skip to content

v0.7.6.2

Latest

Choose a tag to compare

@malx-labs malx-labs released this 09 Sep 10:30
adfa8e6

IOCX v0.7.6.2 — Import Table Structural Validator, and VERSIONINFO projection

This release adds a deterministic structural validator for the PE import table, hoists version-info parsing out of -a full so it runs on every file with a new bounded public projection, fixes a relocation-parser crash and several silent error-tag drops, and introduces a static CI check that prevents future drops of the same kind. Test suite: 2,136 → 2,802 tests.


New: Import Table Structural Validator

The PE import table is the highest-value forensic surface in the format for behavioural triage — the resolved DLL/symbol names are what most capability heuristics are built on — and it previously had no dedicated structural validator.

  • New parser iocx/parsers/pe_imports.py: a deterministic struct-level decoder over the 20-byte IMAGE_IMPORT_DESCRIPTOR array, independent of pefile's own DIRECTORY_ENTRY_IMPORT interpretation.
  • Handles two divergences from the delay-load import parser that a naive implementation gets wrong in opposite directions:
    • OriginalFirstThunk == 0 is legal here (unlike delay-load). Older linkers emit only FirstThunk, which holds INT-style thunks on disk that the loader overwrites at load time. The parser falls back and records thunk_source: "iat_fallback" without raising an anomaly — flagging this would misreport a large fraction of legitimate binaries.
    • TimeDateStamp selects how FirstThunk must be read: unbound, new-style bound (real timestamps live in BOUND_IMPORT), or old-style bound (FirstThunk holds resolved addresses, not thunks). Old-style bound with a zero OriginalFirstThunk means there is no readable name source at all — reported as names_unrecoverable_bound_no_int, a structural fact rather than a parse failure.
  • Descriptor and thunk arrays are dual-bounded (NULL terminator and a hard cap — 4,096 descriptors, 16,384 imports/descriptor) so a malformed or hostile table can't force unbounded work.
  • New validator iocx/validators/imports.py emitting five reason codes: IMPORT_DIRECTORY_INVALID_HEADER, IMPORT_TABLE_TRUNCATED, IMPORT_DESCRIPTOR_INVALID, IMPORT_DLL_NAME_INVALID, IMPORT_ENTRY_INVALID. Placement (RVA bounds, section mapping) is deliberately left to the RVA-graph backbone, matching the pattern already used by relocations and debug — so nothing double-counts.
  • Per-entry emission capped at 32 issues/descriptor, per descriptor rather than per file, so one heavily malformed module can't silence the ones after it; the true count is always carried in invalid_entry_count.

New: version-info parsed at every level, plus a public projection

  • build_version_info_structure (renamed from build_version_info) now runs unconditionally on every file, not only under -a full.
  • A new 1 MB blob-size cap plus an RVA/size sanity guard (leaf_placement_implausible) stop an attacker-controlled Size from
    ever reaching pe.get_data — the guard runs before any read.
  • New module version_info_projection.py derives the public version_info key present in every result:
    • Version numbers rendered as dotted quads, not raw (MS, LS) pairs.
    • Control characters and bidi overrides stripped from every string (closes a filename-spoofing vector via CompanyName/etc.).
    • String tables kept separate per language block — a flat merge would let one block silently overwrite another, and a disagreement between blocks is itself a repackaging signal.
    • Default output uses a closed 8-key shortlist (CompanyName, FileDescription, FileVersion, InternalName, LegalCopyright, OriginalFilename, ProductName, ProductVersion); -a full opens the key set. StringFileInfo keys are attacker-chosen strings, so the default payload must not let a binary pick its own key names.
    • Tables/strings/translations bounded at 16/64/32 respectively, so output size never scales with a hostile input.
  • version_info is null when no RT_VERSION resource exists, and a populated tombstone (decoded: false, structural_error_count > 0) when one exists but can't be trusted — the two are always distinguishable.

CLI

  • --version now prints a MalX Labs banner (ASCII art shown only when stdout is a TTY — suppressed automatically in CI logs and pipes), plus iocx, python and pefile versions and the licence. pefile materialises the resource tree every parser walks, so its version is a genuine variable when two runs disagree on a finding.
  • Rewritten tool description and per-flag help text throughout. -a/--analyse now documents what each level actually unlocks: basic (sections, entropy), deep (+ obfuscation heuristics), full (+ structural validation, full version-info).
  • New PE Analysis argument group. -m/--min-length moved from Detector Options into Engine Options — it configures the string extractor, not a detector.
  • --list-transformers / --list-enrichers help text now consistently ends "and exit", matching --list-detectors.
  • allow_abbrev=False added to the argument parser.

Fixed

  • Relocations — crash fix. A NameError on _RELOC_TYPE_HIGHADJ was reachable from decoding any relocation entry of any type, not only IMAGE_REL_BASED_HIGHADJ ones — a single entry in a malformed file was enough to abort the whole analysis. Full HIGHADJ two-slot pairing is now implemented: the raw adjustment word is captured verbatim on the owning entry (adjustment field), and a trailing unpaired HIGHADJ is tombstoned (highadj_missing_adjustment) rather than mis-decoded.
  • _parse_data_directories_raw — PE32+ offset bug. 64-bit images were being read at the PE32 (96-byte) DataDirectory offset instead of the correct 112 bytes, silently misreading every data directory on a PE32+ image. Also now tolerates a missing/empty __data__ and a truncated optional header, returning what was readable instead of raising.
  • Exports. Forwarder-string decode errors were discarded (the error tag from _read_asciiz was thrown away); now captured per-entry in FunctionEntry.errors. name_rva was hard-coded to None regardless of the resolved name; now correctly sourced from the name-pointer cross-reference. Two name pointers resolving to the same EAT index previously overwrote each other silently, making one export name unreachable through the resolved-function view — now tagged ordinal_index_duplicate → EXPORT_NAME_ORDINAL_INDEX_INVALID. The forwarder regex is tightened so an over-long ordinal (Dll.#99999999999) can no longer fall through and be accepted as an ordinary symbol name.
  • Delay-load imports. DLL names and import names now get a genuine three-way split — empty / non-printable / too-long — instead of one boolean, so a consumer triaging on "not printable" is no longer shown a length violation instead.
  • Resources. Both _parse_resources (feeds the top-level resources/resource_strings metadata) and build_resource_structure (feeds resources_struct, what the validator reads) gained bounded walks and a genuine never-raises guarantee. A directory whose entry list can't be decoded, or an individual entry that can't be decoded, is now tombstoned directory_entries_unavailable / entry_decode_failed → new code RESOURCE_DIRECTORY_ENTRY_UNREADABLE) instead of the tree silently losing entries or, in the worst case, propagating an exception out of the parser. The RT_STRING walk's bare except: pass is replaced with an explicit string_table_walk_failed tombstone (→ new code RESOURCE_STRING_TABLE_UNREADABLE).
  • Tag stability. Per-index tags such as certificate_header_unpack_failed_at_{index} and block_header_unpack_failed_at_{index} are deduplicated to a stable, index-free form. These never matched any validator priority list before, because the embedded index made every occurrence unique.

New: static tag-contract CI check

A tag with no validator consumer vanishes silently today — _first_matchingreturns"unknown"` and the caller continues, so the
finding never reaches output, and the affected file simply reports clean. That failure mode isn't visible in coverage, snapshots, or inspection.

  • tests/contract/tag_contract.py statically walks every parser's AST, enumerates every tombstone tag it can emit (including f-string
    templates and tags returned from shared helper functions), and asserts each has a consumer in its paired validator's priority list. Runs in CI across every registered parser/validator pair.
  • Deliberate exemptions are declared per-tag, not per-subsystem, so a second, undeclared drop in an already-exempted parser still fails. Separate registries cover validators with no parser at all (entrypoint, sections, rva_graph, entropy — they read metadata directly rather than decoding structure) and parsers that legitimately emit no tags (pe_optional_header, pe_load_config).
  • The checker itself is extensively self-tested (tests/unit/.../test_tag_self.py) against every AST shape it needs to recognise — eight real drops were found this way during development, each costing a review cycle before this check existed.

Documentation

  • docs/specs/reason-codes.md: full new Import Anomalies section, new Resource Directory Entry Unreadable section, expanded TLS / delay-import / export / relocation / debug sub-reason tables to match current parser tag vocabularies, and HIGHADJ pairing documentation.
  • docs/specs/structural-validation-deterministic-heuristics.md: new §2.16 Imports Validator design section, and a new paragraph documenting the tag-contract check.

Behavioural notes

  • version_info is now present in every result at every analysis level. Consumers that previously only inspected it under -a full
    should confirm they handle it (as null) at basic/deep too.
  • A version-info leaf declaring Size = 0 previously reached pe.get_data and surfaced as too_short; it is now refused up-front
    by the placement guard and surfaces as leaf_placement_implausible instead. Consumers keying on too_short for that specific case should update.
  • RESOURCE_DIRECTORY_ZERO_LENGTH is now marked reserved / not emitted (currently unreachable, since size is derived from the entry count).

Full changeset: new import-table parser + validator, version-info projection module, CLI overhaul, relocation crash fix, PE32+
data-directory offset fix, resource-parser hardening, export/delay-import tag fixes, static tag-contract CI check, and ~700 new tests.