Repository navigation
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-byteIMAGE_IMPORT_DESCRIPTORarray, independent of pefile's ownDIRECTORY_ENTRY_IMPORTinterpretation. - Handles two divergences from the delay-load import parser that a naive implementation gets wrong in opposite directions:
OriginalFirstThunk == 0is legal here (unlike delay-load). Older linkers emit onlyFirstThunk, which holds INT-style thunks on disk that the loader overwrites at load time. The parser falls back and recordsthunk_source: "iat_fallback"without raising an anomaly — flagging this would misreport a large fraction of legitimate binaries.TimeDateStampselects howFirstThunkmust be read: unbound, new-style bound (real timestamps live inBOUND_IMPORT), or old-style bound (FirstThunkholds resolved addresses, not thunks). Old-style bound with a zeroOriginalFirstThunkmeans there is no readable name source at all — reported asnames_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.pyemitting 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 frombuild_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-controlledSizefrom
ever reachingpe.get_data— the guard runs before any read. - New module
version_info_projection.pyderives the publicversion_infokey 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 fullopens 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 numbers rendered as dotted quads, not raw
version_infoisnullwhen noRT_VERSIONresource 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
--versionnow prints a MalX Labs banner (ASCII art shown only when stdout is a TTY — suppressed automatically in CI logs and pipes), plusiocx,pythonandpefileversions and the licence.pefilematerialises 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/--analysenow 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-lengthmoved from Detector Options into Engine Options — it configures the string extractor, not a detector. --list-transformers/--list-enrichershelp text now consistently ends "and exit", matching--list-detectors.allow_abbrev=Falseadded to the argument parser.
Fixed
- Relocations — crash fix. A
NameErroron_RELOC_TYPE_HIGHADJwas reachable from decoding any relocation entry of any type, not onlyIMAGE_REL_BASED_HIGHADJones — a single entry in a malformed file was enough to abort the whole analysis. FullHIGHADJtwo-slot pairing is now implemented: the raw adjustment word is captured verbatim on the owning entry (adjustmentfield), and a trailing unpairedHIGHADJis 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)DataDirectoryoffset 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_asciizwas thrown away); now captured per-entry inFunctionEntry.errors.name_rvawas hard-coded toNoneregardless 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 taggedordinal_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-levelresources/resource_stringsmetadata) andbuild_resource_structure(feedsresources_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 tombstoneddirectory_entries_unavailable/entry_decode_failed→ new codeRESOURCE_DIRECTORY_ENTRY_UNREADABLE) instead of the tree silently losing entries or, in the worst case, propagating an exception out of the parser. TheRT_STRINGwalk's bareexcept: passis replaced with an explicitstring_table_walk_failedtombstone (→ new codeRESOURCE_STRING_TABLE_UNREADABLE). - Tag stability. Per-index tags such as
certificate_header_unpack_failed_at_{index}andblock_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.pystatically 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_infois now present in every result at every analysis level. Consumers that previously only inspected it under-a full
should confirm they handle it (asnull) atbasic/deeptoo.- A version-info leaf declaring
Size = 0previously reachedpe.get_dataand surfaced astoo_short; it is now refused up-front
by the placement guard and surfaces asleaf_placement_implausibleinstead. Consumers keying ontoo_shortfor that specific case should update. RESOURCE_DIRECTORY_ZERO_LENGTHis now marked reserved / not emitted (currently unreachable, sincesizeis 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.