From 51f50768cd21affc55ff21858d7802350f71465b Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 24 Jun 2026 16:22:23 +0100 Subject: [PATCH 01/35] parser(resources): guard get_offset_from_rva against corrupt RVAs A malformed data entry whose RVA falls outside any section currently propagates pefile.PEFormatError out of build_resource_structure. Catch it narrowly and emit -1 as the raw_offset sentinel; the validator's existing data_raw < 0 arm already maps this to RESOURCE_DATA_OUT_OF_BOUNDS. No schema change; no new reason codes. --- iocx/parsers/pe_resources.py | 11 +++++++++-- iocx/validators/resources.py | 8 +++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/iocx/parsers/pe_resources.py b/iocx/parsers/pe_resources.py index 198c1c5..3dc014b 100644 --- a/iocx/parsers/pe_resources.py +++ b/iocx/parsers/pe_resources.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MPL-2.0 from typing import Dict, Any - +import pefile def build_resource_structure(pe) -> Dict[str, Any]: """ @@ -60,7 +60,14 @@ def build_directory(node, entry_struct=None) -> Dict[str, Any]: d = data.struct data_rva = d.OffsetToData data_size = d.Size - raw_offset = pe.get_offset_from_rva(data_rva) + + # Guarded RVA→offset: a corrupt RVA must not crash the + # parser. -1 is the sentinel the validator already treats + # as out-of-bounds via its data_raw < 0 arm. + try: + raw_offset = pe.get_offset_from_rva(data_rva) + except (pefile.PEFormatError, AttributeError): + raw_offset = -1 entries.append( { diff --git a/iocx/validators/resources.py b/iocx/validators/resources.py index 55d2678..d2d4e31 100644 --- a/iocx/validators/resources.py +++ b/iocx/validators/resources.py @@ -29,8 +29,8 @@ def validate_resources(metadata: InternalMetadata, analysis: AnalysisDict) -> Li rsrc_va = rsrc_section["virtual_address"] rsrc_vs = rsrc_section["virtual_size"] - rsrc_raw = rsrc_section["raw_address"] - rsrc_raw_size = rsrc_section["raw_size"] + rsrc_raw = rsrc_section["raw_address"] # reserved + rsrc_raw_size = rsrc_section["raw_size"] # reserved def rva_in_rsrc(rva: int, size: int = 0) -> bool: return rsrc_va <= rva and (rva + size) <= (rsrc_va + rsrc_vs) @@ -62,6 +62,8 @@ def validate_directory(dir_node: Dict[str, Any]) -> None: entries = dir_node["entries"] + # Reserved: only reachable if size is sourced from the on-disk + # IMAGE_RESOURCE_DIRECTORY header rather than derived from len(entries) # Zero-length directory if size == 0: issues.append(StructuralIssue( @@ -118,7 +120,7 @@ def validate_directory(dir_node: Dict[str, Any]) -> None: )) continue - # Raw bounds + # Raw bounds (data_raw == -1 sentinel from a guarded RVA→offset if data_raw < 0 or data_raw + data_size > file_size: issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DATA_OUT_OF_BOUNDS, From 3d5d388415772b23896b70528ea7fe763dc9b21e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Thu, 25 Jun 2026 13:53:47 +0100 Subject: [PATCH 02/35] =?UTF-8?q?parser(resources):=20enforce=20Type?= =?UTF-8?q?=E2=86=92Name=E2=86=92Language=20depth=20in=20validator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add depth parameter to validate_directory, flag depth-2 entries that use names instead of LCIDs, and flag data entries that appear outside the Language layer. New reason codes: RESOURCE_DIRECTORY_LANGUAGE_NOT_ID RESOURCE_DATA_AT_INVALID_DEPTH parser(version_info): add deterministic VS_VERSIONINFO extractor Independent module; locates the first RT_VERSION leaf under a stable (name_id, language_id) ordering and decodes VS_VERSIONINFO, VS_FIXEDFILEINFO, StringFileInfo and VarFileInfo purely from bytes. Never raises; emits tombstone tags for sub-structure failures. validator(version_info): structural validation against decoded blob Emits RESOURCE_VERSIONINFO_INVALID_{HEADER,FIXEDINFO,STRINGFILEINFO,VARFILEINFO} when the decoded blob is malformed. Absence of RT_VERSION is not treated as a defect. --- docs/specs/reason-codes.md | 17 ++ iocx/engine.py | 2 + iocx/parsers/pe_version_info.py | 322 ++++++++++++++++++++++++++++++++ iocx/reason_codes.py | 8 + iocx/schemas/internal_schema.py | 93 +++++++-- iocx/validators/__init__.py | 3 + iocx/validators/resources.py | 26 ++- iocx/validators/version_info.py | 119 ++++++++++++ 8 files changed, 572 insertions(+), 18 deletions(-) create mode 100644 iocx/parsers/pe_version_info.py create mode 100644 iocx/validators/version_info.py diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 0f8980e..45e59c4 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -113,6 +113,12 @@ | **RESOURCE_DIRECTORY_LOOP** | Recursive directory traversal detects a cycle (malformed or malicious resource tree) | Directory A → B → A | Per‑file | | **RESOURCE_DIRECTORY_ZERO_LENGTH** | A resource directory exists but has zero length or no valid entries | RVA = `0x3000`, size = `0` | Per‑file | +### Resource Hierarchy Anomalies +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **RESOURCE_DIRECTORY_LANGUAGE_NOT_ID** | A depth‑2 directory (Language layer) contains a named entry instead of an integer LCID, violating the Type → Name → Language hierarchy | Language entry keyed by "EN-US" instead of LCID 0x0409 | Per‑file +| **RESOURCE_DATA_AT_INVALID_DEPTH** | A data leaf appears at depth 0 (Type) or depth 1 (Name) instead of depth 2 (Language), skipping required hierarchy layers | Root Type directory contains a direct data leaf with no Name/Language subdirectories | Per‑file + ### **Resource Entry / Data Anomalies** | Reason Code | What Triggers It | Example Pattern | Scope | @@ -121,6 +127,17 @@ | **RESOURCE_DATA_OUT_OF_BOUNDS** | Resource data block lies outside the file or outside the `.rsrc` section | Data offset = `0x1F0000`, file size = `0x1E0000` | Per‑file | | **RESOURCE_DATA_OVERLAPS_OTHER_DATA** | Two resource data blobs overlap in raw or virtual space | Data A: `0x2000–0x2400`, Data B: `0x2300–0x2500` | Per‑file | +### Resource Version‑Info Anomalies + +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **RESOURCE_VERSIONINFO_INVALID_HEADER** | The VS_VERSIONINFO envelope is malformed: placement outside `.rsrc`, `szKey` not equal to "VS_VERSION_INFO", or `wLength` inconsistent with the buffer size | szKey = "VS_VERSION_BAD" instead of "VS_VERSION_INFO" | Per‑file +| **RESOURCE_VERSIONINFO_INVALID_FIXEDINFO** | The embedded VS_FIXEDFILEINFO has an incorrect `dwSignature` (expected `0xFEEF04BD`) or `dwStrucVersion` (expected `0x00010000`), or fails to parse | dwSignature = `0xDEADBEEF` instead of `0xFEEF04BD` | Per‑file +| **RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO** | A StringFileInfo, StringTable, or String child is malformed: invalid length field, non‑hex lang_codepage key, or truncated string entry StringTable | key = "ENGLISHX" instead of 8‑hex‑char form | Per‑file +| **RESOURCE_VERSIONINFO_INVALID_VARFILEINFO** | A VarFileInfo or Var child is malformed, or the Translation array's length is not a DWORD multiple Var. | wValueLength = 6 (not divisible by 4) for a Translation array | Per‑file + +*Note: absence of an RT_VERSION resource is not treated as a structural anomaly — many legitimate binary types (kernel drivers, MSI helpers, cross‑compiled artefacts) omit version‑info entirely.* + ### **Resource String‑Table Anomalies** | Reason Code | What Triggers It | Example Pattern | Scope | diff --git a/iocx/engine.py b/iocx/engine.py index 7c77308..a25f755 100644 --- a/iocx/engine.py +++ b/iocx/engine.py @@ -11,6 +11,7 @@ from .parsers.pe_parser import parse_pe, analyse_pe_sections, analyse_data_directories, sanitize_sections, analyse_data_directories_raw from .parsers.string_extractor import extract_strings from .parsers.pe_resources import build_resource_structure +from .parsers.pe_version_info import build_version_info from .parsers.pe_load_config import analyse_load_config from .parsers.pe_optional_header import extract_optional_header_metadata from .detectors import all_detectors @@ -162,6 +163,7 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: } self._internal_metadata["resources_struct"] = build_resource_structure(pe) + self._internal_metadata["version_info_struct"] = build_version_info(pe) self._internal_metadata["data_directories_raw"] = analyse_data_directories_raw(pe) self._internal_metadata.update(extract_optional_header_metadata(pe)) internal: InternalMetadata = self._internal_metadata diff --git a/iocx/parsers/pe_version_info.py b/iocx/parsers/pe_version_info.py new file mode 100644 index 0000000..7f93219 --- /dev/null +++ b/iocx/parsers/pe_version_info.py @@ -0,0 +1,322 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Deterministic VS_VERSIONINFO extraction. + +This module is intentionally independent of the resource-tree parser so that +version-info concerns live in their own layer with their own fixtures and +reason codes. + +Output contract: + None - no RT_VERSION resource present (not an error) + dict with keys: + rva, size - placement of the chosen VS_VERSIONINFO blob + decoded - True if the top-level header was unpackable + header_ok - szKey == "VS_VERSION_INFO" + length_consistent - wLength fits within the blob + fixed_file_info - dict or None + string_file_info - list of StringFileInfo dicts (possibly empty) + var_file_info - list of VarFileInfo dicts (possibly empty) + errors - list of per-substructure tombstone tags +""" + +from typing import Dict, Any, List, Optional, Tuple +import struct + +RT_VERSION = 16 +_VS_VERSION_INFO_KEY = "VS_VERSION_INFO" +_VS_FFI_SIGNATURE = 0xFEEF04BD +_VS_FFI_STRUCT_VERSION = 0x00010000 + + +def build_version_info(pe) -> Optional[Dict[str, Any]]: + """ + Locate and decode the first RT_VERSION leaf in the resource tree. + + Deterministic ordering: leaves are sorted by (name_id, language_id) + with non-integer keys pushed to the end, so the choice of "first" + leaf is stable across runs. + """ + if not hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"): + return None + + leaf = _find_first_version_leaf(pe.DIRECTORY_ENTRY_RESOURCE) + if leaf is None: + return None + + try: + rva = int(leaf.data.struct.OffsetToData) + size = int(leaf.data.struct.Size) + except (AttributeError, struct.error): + return { + "rva": None, "size": None, + "decoded": False, "header_ok": False, "length_consistent": False, + "fixed_file_info": None, + "string_file_info": [], "var_file_info": [], + "errors": ["leaf_struct_unpack"], + } + + try: + raw = bytes(pe.get_data(rva, size)) + except Exception: + return { + "rva": rva, "size": size, + "decoded": False, "header_ok": False, "length_consistent": False, + "fixed_file_info": None, + "string_file_info": [], "var_file_info": [], + "errors": ["read_failed"], + } + + decoded = _decode_vs_versioninfo(raw) + decoded["rva"] = rva + decoded["size"] = size + return decoded + + +# ================================================================= +# Locator +# ================================================================= + +def _find_first_version_leaf(root_dir): + leaves: List[Tuple[Any, Any, Any]] = [] + try: + for type_entry in root_dir.entries: + if getattr(type_entry, "id", None) != RT_VERSION: + continue + if not hasattr(type_entry, "directory"): + continue + for name_entry in type_entry.directory.entries: + name_key = getattr(name_entry, "id", None) + if not hasattr(name_entry, "directory"): + continue + for lang_entry in name_entry.directory.entries: + if not hasattr(lang_entry, "data"): + continue + lang_key = getattr(lang_entry, "id", None) + leaves.append((name_key, lang_key, lang_entry)) + except (AttributeError, IndexError): + return None + + if not leaves: + return None + + _SENTINEL = 1 << 31 + + def _key(t): + n, l, _ = t + return (n if isinstance(n, int) else _SENTINEL, + l if isinstance(l, int) else _SENTINEL) + + leaves.sort(key=_key) + return leaves[0][2] + + +# ================================================================= +# Decoder +# ================================================================= + +def _align4(n: int) -> int: + return (n + 3) & ~3 + + +def _u16(buf: bytes, off: int) -> int: + return struct.unpack_from(" Tuple[str, int]: + """Read a NUL-terminated UTF-16LE string. Returns (text, bytes_consumed_inc_NUL).""" + end = off + while end + 1 < len(buf): + if buf[end] == 0 and buf[end + 1] == 0: + break + end += 2 + s = buf[off:end].decode("utf-16-le", errors="replace") + return s, (end - off) + 2 + + +def _decode_vs_versioninfo(buf: bytes) -> Dict[str, Any]: + out: Dict[str, Any] = { + "decoded": False, + "header_ok": False, + "length_consistent": False, + "w_type": None, + "fixed_file_info": None, + "string_file_info": [], + "var_file_info": [], + "errors": [], + } + + if len(buf) < 6: + out["errors"].append("too_short") + return out + + try: + w_length = _u16(buf, 0) + w_value_length = _u16(buf, 2) + w_type = _u16(buf, 4) + except struct.error: + out["errors"].append("header_unpack") + return out + + out["decoded"] = True + out["w_type"] = w_type + out["length_consistent"] = 6 <= w_length <= len(buf) + + key_str, key_consumed = _read_utf16_sz(buf, 6) + out["header_ok"] = (key_str == _VS_VERSION_INFO_KEY) + + pos = _align4(6 + key_consumed) + + # ---- VS_FIXEDFILEINFO (52 bytes when present) ---- + if w_value_length >= 52 and pos + w_value_length <= len(buf): + try: + ffi = struct.unpack_from("<13I", buf, pos) + (sig, sver, fv_ms, fv_ls, pv_ms, pv_ls, + flags_mask, flags, file_os, ftype, fsubtype, + dt_ms, dt_ls) = ffi + out["fixed_file_info"] = { + "signature": sig, + "signature_ok": sig == _VS_FFI_SIGNATURE, + "struct_version": sver, + "struct_version_ok": sver == _VS_FFI_STRUCT_VERSION, + "file_version": (fv_ms, fv_ls), + "product_version": (pv_ms, pv_ls), + "file_flags_mask": flags_mask, + "file_flags": flags, + "file_os": file_os, + "file_type": ftype, + "file_subtype": fsubtype, + "file_date": (dt_ms, dt_ls), + } + except struct.error: + out["errors"].append("fixed_file_info_unpack") + elif w_value_length != 0: + out["errors"].append("fixed_file_info_truncated") + + pos = _align4(pos + w_value_length) + + end = min(w_length, len(buf)) if out["length_consistent"] else len(buf) + + # ---- Children: StringFileInfo / VarFileInfo ---- + while pos + 6 <= end: + try: + c_len = _u16(buf, pos) + _c_vlen = _u16(buf, pos + 2) + _c_type = _u16(buf, pos + 4) + except struct.error: + out["errors"].append("child_header_unpack") + break + + if c_len < 6 or pos + c_len > end: + out["errors"].append("child_length_invalid") + break + + key_str, key_consumed = _read_utf16_sz(buf, pos + 6) + body_start = _align4(pos + 6 + key_consumed) + child_end = pos + c_len + + if key_str == "StringFileInfo": + out["string_file_info"].append( + _decode_string_file_info(buf, body_start, child_end) + ) + elif key_str == "VarFileInfo": + out["var_file_info"].append( + _decode_var_file_info(buf, body_start, child_end) + ) + else: + out["errors"].append("unknown_child") + + pos = _align4(child_end) + + return out + + +def _decode_string_file_info(buf: bytes, start: int, end: int) -> Dict[str, Any]: + result: Dict[str, Any] = {"tables": [], "errors": []} + pos = start + while pos + 6 <= end: + try: + t_len = _u16(buf, pos) + except struct.error: + result["errors"].append("string_table_header") + return result + if t_len < 6 or pos + t_len > end: + result["errors"].append("string_table_length") + return result + + key_str, key_consumed = _read_utf16_sz(buf, pos + 6) + body_start = _align4(pos + 6 + key_consumed) + body_end = pos + t_len + + table: Dict[str, Any] = { + "lang_codepage": key_str, + "strings": {}, + "errors": [], + } + # StringTable key must be 8 hex chars: + if len(key_str) != 8 or any(c not in "0123456789ABCDEFabcdef" for c in key_str): + table["errors"].append("lang_codepage_key") + + sp = body_start + while sp + 6 <= body_end: + try: + s_len = _u16(buf, sp) + except struct.error: + table["errors"].append("string_header") + break + if s_len < 6 or sp + s_len > body_end: + table["errors"].append("string_length") + break + sk, sk_used = _read_utf16_sz(buf, sp + 6) + val_start = _align4(sp + 6 + sk_used) + val_end = sp + s_len + if val_end > val_start: + sv, _ = _read_utf16_sz(buf, val_start) + else: + sv = "" + # Bound stored strings to keep the dict deterministic in size + table["strings"][sk[:128]] = sv[:512] + sp = _align4(val_end) + + result["tables"].append(table) + pos = _align4(body_end) + + return result + + +def _decode_var_file_info(buf: bytes, start: int, end: int) -> Dict[str, Any]: + result: Dict[str, Any] = {"vars": [], "errors": []} + pos = start + while pos + 6 <= end: + try: + v_len = _u16(buf, pos) + v_vlen = _u16(buf, pos + 2) + except struct.error: + result["errors"].append("var_header") + return result + if v_len < 6 or pos + v_len > end: + result["errors"].append("var_length") + return result + + key_str, key_consumed = _read_utf16_sz(buf, pos + 6) + val_start = _align4(pos + 6 + key_consumed) + val_end = pos + v_len + + if v_vlen % 4 != 0: + result["errors"].append("translation_not_dword_aligned") + + translations = [] + nd = (val_end - val_start) // 4 + for i in range(nd): + try: + lang, cp = struct.unpack_from(" bool # --------------------------------------------------------- # Recursive directory validation # --------------------------------------------------------- - def validate_directory(dir_node: Dict[str, Any]) -> None: + def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: rva = dir_node["rva"] size = dir_node["size"] @@ -81,6 +81,17 @@ def validate_directory(dir_node: Dict[str, Any]) -> None: return visited_dirs.add(rva) + # --- language layer (depth 2) must use integer LCIDs --- + # Per PE spec, the Type → Name → Language tree's deepest directory + # layer is keyed by language ID. Named entries here are malformed. + if depth == 2: + for e in entries: + if e["name"] is not None and e["id"] is None: + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_DIRECTORY_LANGUAGE_NOT_ID, + details={"rva": rva, "name": e["name"]}, + )) + # Entries for entry in entries: if entry["is_directory"]: @@ -94,9 +105,19 @@ def validate_directory(dir_node: Dict[str, Any]) -> None: )) continue - validate_directory(target) + validate_directory(target, depth + 1) # <-- depth bumped continue + # --- data entries should only appear at depth 2 (Language layer) --- + # A data leaf at depth 0 or 1 means the tree shape violates the + # Type → Name → Language hierarchy. + if depth != 2: + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_DATA_AT_INVALID_DEPTH, + details={"rva": rva, "depth": depth, + "data_rva": entry["data_rva"]}, + )) + # ------------------------------ # Data entry # ------------------------------ @@ -121,6 +142,7 @@ def validate_directory(dir_node: Dict[str, Any]) -> None: continue # Raw bounds (data_raw == -1 sentinel from a guarded RVA→offset + # lookup also lands here, preserving the existing reason code). if data_raw < 0 or data_raw + data_size > file_size: issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DATA_OUT_OF_BOUNDS, diff --git a/iocx/validators/version_info.py b/iocx/validators/version_info.py new file mode 100644 index 0000000..995607f --- /dev/null +++ b/iocx/validators/version_info.py @@ -0,0 +1,119 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Validate the version-info structure produced by parser_version_info. + +Absence of RT_VERSION is NOT a structural defect — kernel drivers, MSI +custom-action DLLs and many cross-compiled binaries legitimately omit it. +We only emit structural codes when an RT_VERSION resource is present and +malformed. +""" + +from typing import List + +from iocx.reason_codes import ReasonCodes +from iocx.validators.schema import StructuralIssue +from iocx.schemas.internal_schema import InternalMetadata +from iocx.schemas.analysis import AnalysisDict +from .decorators import depends_on + + +@depends_on("internal", "analysis") +def validate_version_info(metadata: InternalMetadata, analysis: AnalysisDict) -> List[StructuralIssue]: + issues: List[StructuralIssue] = [] + + vi = metadata.get("version_info_struct") + if vi is None: + return issues # no RT_VERSION present — not a defect + + sections = analysis["sections"] + rsrc_section = next( + (s for s in sections if s["name"].lower() == ".rsrc"), + None, + ) + + # ---- Placement within .rsrc ---- + if rsrc_section is not None and vi.get("rva") is not None: + rsrc_va = rsrc_section["virtual_address"] + rsrc_vs = rsrc_section["virtual_size"] + rva = vi["rva"] + size = vi["size"] or 0 + if not (rsrc_va <= rva and rva + size <= rsrc_va + rsrc_vs): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER, + details={"reason": "placement", "rva": rva, "size": size}, + )) + + # ---- Top-level header ---- + if not vi.get("decoded"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER, + details={"reason": "undecoded", "errors": vi.get("errors", [])}, + )) + return issues # nothing further to validate + + if not vi.get("header_ok"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER, + details={"reason": "szkey_mismatch"}, + )) + if not vi.get("length_consistent"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER, + details={"reason": "length_inconsistent"}, + )) + + # ---- VS_FIXEDFILEINFO ---- + ffi = vi.get("fixed_file_info") + if ffi is None: + # Only flag if there were parse errors; some binaries legitimately + # omit VS_FIXEDFILEINFO with wValueLength == 0. + if any(e.startswith("fixed_file_info") for e in vi.get("errors", [])): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO, + details={"reason": "parse_failed", + "errors": [e for e in vi["errors"] + if e.startswith("fixed_file_info")]}, + )) + else: + if not ffi.get("signature_ok"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO, + details={"reason": "signature", + "signature": ffi.get("signature")}, + )) + if not ffi.get("struct_version_ok"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO, + details={"reason": "struct_version", + "struct_version": ffi.get("struct_version")}, + )) + + # ---- StringFileInfo ---- + for sfi in vi.get("string_file_info", []): + if sfi.get("errors"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO, + details={"errors": sfi["errors"], + "tables": len(sfi.get("tables", []))}, + )) + continue + for tbl in sfi.get("tables", []): + if tbl.get("errors"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO, + details={"errors": tbl["errors"], + "lang_codepage": tbl.get("lang_codepage")}, + )) + + # ---- VarFileInfo ---- + for vfi in vi.get("var_file_info", []): + if vfi.get("errors"): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO, + details={"errors": vfi["errors"], + "vars": len(vfi.get("vars", []))}, + )) + + return issues From f7d1b83d3aa8e2dda9ac73ced55f9998ca07820e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Thu, 25 Jun 2026 15:07:24 +0100 Subject: [PATCH 03/35] Tests: - 100% line and branch coverage on pe_version_info and validator_version_info. - 100% line coverage on the resource validator additions. - Defensive-path coverage for every except clause via monkeypatched struct.error injection and narrow-except negative tests. --- tests/unit/parsers/test_pe_parser.py | 125 ++ tests/unit/parsers/test_pe_version_info.py | 1357 +++++++++++++++++ .../validators/test_validator_resources.py | 121 ++ .../validators/test_validator_version_info.py | 645 ++++++++ 4 files changed, 2248 insertions(+) create mode 100644 tests/unit/parsers/test_pe_version_info.py create mode 100644 tests/unit/validators/test_validator_version_info.py diff --git a/tests/unit/parsers/test_pe_parser.py b/tests/unit/parsers/test_pe_parser.py index 1a2101f..b6ab634 100644 --- a/tests/unit/parsers/test_pe_parser.py +++ b/tests/unit/parsers/test_pe_parser.py @@ -361,3 +361,128 @@ class FakePE: result = _parse_data_directories_raw(FakePE()) assert result == [] # early return path + + +# ================================================================= +# Defensive: guarded get_offset_from_rva +# ================================================================= + +class TestGuardedRvaToOffset: + """ + Cover the try/except around pe.get_offset_from_rva in + build_resource_structure. A corrupt RVA must produce a -1 sentinel + in raw_offset rather than propagating the exception. + """ + + def _make_pe_with_data_leaf_raising(self, exception_to_raise: Exception): + """ + Build a minimal fake pe whose resource tree contains a single + RT_VERSION leaf, where pe.get_offset_from_rva raises the given + exception. + """ + + def _struct_with(offset: int): + return type("S", (), {"OffsetToData": offset})() + + # Leaf data entry — points to the corrupt RVA + class _FakeStruct: + OffsetToData = 0x1100 + Size = 100 + + class _FakeData: + struct = _FakeStruct() + + class _FakeLangEntry: + id = 0x0409 + data = _FakeData() + # No `directory` attribute — this is a leaf, not a subdirectory + + class _FakeLangDir: + entries = [_FakeLangEntry()] + + # Name entry — points to the language directory + class _FakeNameEntry: + id = 1 + directory = _FakeLangDir() + struct = _struct_with(0x80000020) # high bit set = "is directory" + + class _FakeNameDir: + entries = [_FakeNameEntry()] + + # Type entry — points to the name directory + class _FakeTypeEntry: + id = 16 # RT_VERSION + directory = _FakeNameDir() + struct = _struct_with(0x80000010) + + class _FakeRootDir: + entries = [_FakeTypeEntry()] + + class _FakeDataDir: + VirtualAddress = 0x1000 + + class _FakeOptHdr: + DATA_DIRECTORY = [None, None, _FakeDataDir()] + + class _FakePE: + OPTIONAL_HEADER = _FakeOptHdr() + DIRECTORY_ENTRY_RESOURCE = _FakeRootDir() + + def get_offset_from_rva(self, rva): + raise exception_to_raise + + return _FakePE() + + def test_pefile_format_error_yields_minus_one(self): + import pefile + from iocx.parsers.pe_resources import build_resource_structure + + pe = self._make_pe_with_data_leaf_raising( + pefile.PEFormatError("simulated corrupt RVA") + ) + result = build_resource_structure(pe) + + assert result is not None + # Walk down to the leaf data entry + root = result["root"] + type_dir = root["entries"][0]["directory"] + name_dir = type_dir["entries"][0]["directory"] + leaf = name_dir["entries"][0] + + assert leaf["is_directory"] is False + assert leaf["raw_offset"] == -1 + # The other fields should still be populated normally + assert leaf["data_rva"] == 0x1100 + assert leaf["data_size"] == 100 + + def test_attribute_error_yields_minus_one(self): + from iocx.parsers.pe_resources import build_resource_structure + + pe = self._make_pe_with_data_leaf_raising( + AttributeError("simulated missing attribute") + ) + result = build_resource_structure(pe) + + root = result["root"] + type_dir = root["entries"][0]["directory"] + name_dir = type_dir["entries"][0]["directory"] + leaf = name_dir["entries"][0] + + assert leaf["raw_offset"] == -1 + assert leaf["data_rva"] == 0x1100 + assert leaf["data_size"] == 100 + + def test_non_caught_exception_propagates(self): + """ + Sanity check that the except clause is narrow — a RuntimeError + (not in the tuple) should still propagate. This protects against + someone widening the except to `Exception` without thinking. + """ + import pytest as _pytest + from iocx.parsers.pe_resources import build_resource_structure + + pe = self._make_pe_with_data_leaf_raising( + RuntimeError("not a caught exception type") + ) + with _pytest.raises(RuntimeError): + build_resource_structure(pe) diff --git a/tests/unit/parsers/test_pe_version_info.py b/tests/unit/parsers/test_pe_version_info.py new file mode 100644 index 0000000..6745f9a --- /dev/null +++ b/tests/unit/parsers/test_pe_version_info.py @@ -0,0 +1,1357 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Unit tests for iocx.parsers.parser_version_info. + +Strategy: +- Decoder tests build VS_VERSIONINFO byte buffers directly via helpers. + This isolates the decoder from pefile and gives precise control over + every malformation we want to exercise. +- Locator tests use a minimal duck-typed fake-pe object so we can construct + resource trees with arbitrary RT_VERSION leaf populations without needing + real PE binaries on disk. +- Determinism tests assert byte-for-byte stable output across repeated runs + on the same input, which is the headline property of the module. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +from iocx.parsers.pe_version_info import ( + build_version_info, + _align4, + _decode_string_file_info, + _decode_var_file_info, + _decode_vs_versioninfo, + _find_first_version_leaf, + _read_utf16_sz, + _u16, + _VS_FFI_SIGNATURE, + _VS_FFI_STRUCT_VERSION, + _VS_VERSION_INFO_KEY, + RT_VERSION, +) + + +# ================================================================= +# Byte-level builders for VS_VERSIONINFO test fixtures +# ================================================================= + +def _utf16_sz(s: str) -> bytes: + """Encode a string as NUL-terminated UTF-16LE.""" + return s.encode("utf-16-le") + b"\x00\x00" + + +def _pad4(buf: bytes) -> bytes: + """Pad a buffer to a 4-byte boundary.""" + pad = (-len(buf)) & 3 + return buf + b"\x00" * pad + + +def _build_ffi( + signature: int = _VS_FFI_SIGNATURE, + struct_version: int = _VS_FFI_STRUCT_VERSION, + file_version_ms: int = 0x00010000, + file_version_ls: int = 0x00020003, + product_version_ms: int = 0x00010000, + product_version_ls: int = 0x00020003, + file_flags_mask: int = 0, + file_flags: int = 0, + file_os: int = 0x00040004, + file_type: int = 1, + file_subtype: int = 0, + file_date_ms: int = 0, + file_date_ls: int = 0, +) -> bytes: + """Build a 52-byte VS_FIXEDFILEINFO blob.""" + return struct.pack( + "<13I", + signature, struct_version, + file_version_ms, file_version_ls, + product_version_ms, product_version_ls, + file_flags_mask, file_flags, + file_os, file_type, file_subtype, + file_date_ms, file_date_ls, + ) + + +def _build_string(key: str, value: str) -> bytes: + """ + Build a single String entry: + wLength, wValueLength (chars including NUL), wType=1, szKey, [pad], Value + """ + key_bytes = _utf16_sz(key) + value_bytes = _utf16_sz(value) + value_chars = len(value_bytes) // 2 # wValueLength is in WORDs for text + header_and_key = struct.pack(" bytes: + """Build a StringTable containing the given String entries.""" + key_bytes = _utf16_sz(lang_codepage) + header_and_key = struct.pack(" bytes: + """Build a StringFileInfo child containing the given StringTables.""" + key_bytes = _utf16_sz("StringFileInfo") + header_and_key = struct.pack(" bytes: + """Build a single Var entry containing the given (lang, codepage) pairs.""" + key_bytes = _utf16_sz(key) + payload = b"".join(struct.pack(" bytes: + """Build a VarFileInfo child containing the given Vars.""" + key_bytes = _utf16_sz("VarFileInfo") + header_and_key = struct.pack(" bytes: + """Build a complete VS_VERSIONINFO blob with optional FFI and children.""" + if ffi is None: + ffi = b"" + sz_key_bytes = _utf16_sz(sz_key) + value_length = w_value_length_override if w_value_length_override is not None else len(ffi) + header_and_key = struct.pack(" bytes: + """ + Build a VarFileInfo child containing the given Vars. + + Each entry in vars_ is (var_key, [(lang, codepage), ...]). + """ + key_bytes = _utf16_sz("VarFileInfo") + header_and_key = struct.pack(" "_FakeDataEntry": + return cls(struct=_FakeDataStruct(offset_to_data, size)) + + +@dataclass +class _FakeLeafEntry: + """A language-level entry that wraps a data leaf.""" + id: Optional[int] + data: _FakeDataEntry + + def __init__(self, lang_id: Optional[int], offset_to_data: int, size: int): + self.id = lang_id + self.data = _FakeDataEntry.make(offset_to_data, size) + + +@dataclass +class _FakeDirectory: + entries: list = field(default_factory=list) + + def __init__(self, entries): + self.entries = entries + + +@dataclass +class _FakeNameEntry: + """A name-level entry whose .directory holds language entries.""" + id: Optional[int] + directory: _FakeDirectory + + def __init__(self, name_id: Optional[int], lang_entries): + self.id = name_id + self.directory = _FakeDirectory(lang_entries) + + +@dataclass +class _FakeTypeEntry: + """A type-level entry whose .directory holds name entries.""" + id: int + directory: _FakeDirectory + + def __init__(self, type_id: int, name_entries): + self.id = type_id + self.directory = _FakeDirectory(name_entries) + + +class _FakePE: + """Minimal duck-typed pe object that exposes only what the parser uses.""" + def __init__(self, root_entries, raw_data_by_rva: Dict[int, bytes], + raise_on_get_data: bool = False): + self.DIRECTORY_ENTRY_RESOURCE = _FakeDirectory(root_entries) + self._raw = raw_data_by_rva + self._raise = raise_on_get_data + + def get_data(self, rva: int, size: int) -> bytes: + if self._raise: + raise RuntimeError("simulated read failure") + if rva not in self._raw: + raise ValueError(f"no fixture data at rva {rva}") + return self._raw[rva][:size] + + +# ================================================================= +# Low-level helper tests +# ================================================================= + +class TestAlign4: + @pytest.mark.parametrize("n,expected", [ + (0, 0), (1, 4), (2, 4), (3, 4), (4, 4), + (5, 8), (7, 8), (8, 8), (9, 12), + (100, 100), (101, 104), + ]) + def test_aligns_up_to_multiple_of_4(self, n, expected): + assert _align4(n) == expected + + +class TestU16: + def test_little_endian_decode(self): + assert _u16(b"\x01\x00", 0) == 1 + assert _u16(b"\x00\x01", 0) == 256 + assert _u16(b"\xFF\xFF", 0) == 0xFFFF + + def test_offset_into_buffer(self): + buf = b"\xAA\xBB\xCC\xDD" + assert _u16(buf, 0) == 0xBBAA + assert _u16(buf, 2) == 0xDDCC + + def test_raises_struct_error_on_overrun(self): + with pytest.raises(struct.error): + _u16(b"\x01", 0) + + +class TestReadUtf16Sz: + def test_reads_simple_ascii_string(self): + buf = _utf16_sz("Hello") + s, consumed = _read_utf16_sz(buf, 0) + assert s == "Hello" + assert consumed == len(buf) + + def test_reads_empty_string(self): + buf = b"\x00\x00" + s, consumed = _read_utf16_sz(buf, 0) + assert s == "" + assert consumed == 2 + + def test_reads_at_offset(self): + prefix = b"\xFF\xFF\xFF\xFF" + buf = prefix + _utf16_sz("World") + s, consumed = _read_utf16_sz(buf, len(prefix)) + assert s == "World" + assert consumed == len(buf) - len(prefix) + + def test_reads_unicode(self): + buf = _utf16_sz("café") + s, consumed = _read_utf16_sz(buf, 0) + assert s == "café" + assert consumed == len(buf) + + def test_handles_unterminated_string_at_eob(self): + # No NUL terminator — should consume to end of buffer + buf = "VS_VERSION_INFO".encode("utf-16-le") + s, consumed = _read_utf16_sz(buf, 0) + # The function increments by 2 for the assumed NUL even if absent + assert s == "VS_VERSION_INFO" + assert consumed == len(buf) + 2 + + def test_handles_malformed_utf16_with_replacement(self): + # Odd-byte sequence that decodes with replacement chars + buf = b"\x41\x00\xFF\xD8\x00\x00" # 'A' + lone high surrogate + NUL + s, consumed = _read_utf16_sz(buf, 0) + assert "A" in s + assert "\uFFFD" in s + assert consumed == 6 + + +# ================================================================= +# Decoder tests — top-level header +# ================================================================= + +class TestDecodeHeader: + def test_empty_buffer_too_short(self): + out = _decode_vs_versioninfo(b"") + assert out["decoded"] is False + assert "too_short" in out["errors"] + + def test_5_byte_buffer_too_short(self): + out = _decode_vs_versioninfo(b"\x00" * 5) + assert out["decoded"] is False + assert "too_short" in out["errors"] + + def test_minimal_valid_header_decodes(self): + buf = _build_vs_versioninfo() + out = _decode_vs_versioninfo(buf) + assert out["decoded"] is True + assert out["header_ok"] is True + assert out["length_consistent"] is True + + def test_szkey_mismatch_flags_header_not_ok(self): + buf = _build_vs_versioninfo(sz_key="VS_VERSION_BAD") + out = _decode_vs_versioninfo(buf) + assert out["decoded"] is True + assert out["header_ok"] is False + assert out["length_consistent"] is True + + def test_wlength_too_large_flags_inconsistent(self): + buf = _build_vs_versioninfo(w_length_override=0xFFFF) + out = _decode_vs_versioninfo(buf) + assert out["length_consistent"] is False + + def test_wlength_too_small_flags_inconsistent(self): + buf = _build_vs_versioninfo(w_length_override=4) + out = _decode_vs_versioninfo(buf) + assert out["length_consistent"] is False + + def test_w_type_recorded(self): + buf = _build_vs_versioninfo(w_type=0) + out = _decode_vs_versioninfo(buf) + assert out["w_type"] == 0 + + buf = _build_vs_versioninfo(w_type=1) + out = _decode_vs_versioninfo(buf) + assert out["w_type"] == 1 + + +# ================================================================= +# Decoder tests — VS_FIXEDFILEINFO +# ================================================================= + +class TestDecodeFixedFileInfo: + def test_valid_ffi_decoded(self): + ffi = _build_ffi() + buf = _build_vs_versioninfo(ffi=ffi) + out = _decode_vs_versioninfo(buf) + + assert out["fixed_file_info"] is not None + ffi_out = out["fixed_file_info"] + assert ffi_out["signature"] == _VS_FFI_SIGNATURE + assert ffi_out["signature_ok"] is True + assert ffi_out["struct_version"] == _VS_FFI_STRUCT_VERSION + assert ffi_out["struct_version_ok"] is True + assert ffi_out["file_version"] == (0x00010000, 0x00020003) + assert ffi_out["product_version"] == (0x00010000, 0x00020003) + + def test_bad_signature_flagged(self): + ffi = _build_ffi(signature=0xDEADBEEF) + buf = _build_vs_versioninfo(ffi=ffi) + out = _decode_vs_versioninfo(buf) + assert out["fixed_file_info"]["signature"] == 0xDEADBEEF + assert out["fixed_file_info"]["signature_ok"] is False + assert out["fixed_file_info"]["struct_version_ok"] is True + + def test_bad_struct_version_flagged(self): + ffi = _build_ffi(struct_version=0x00020000) + buf = _build_vs_versioninfo(ffi=ffi) + out = _decode_vs_versioninfo(buf) + assert out["fixed_file_info"]["struct_version_ok"] is False + assert out["fixed_file_info"]["signature_ok"] is True + + def test_absent_ffi_returns_none(self): + buf = _build_vs_versioninfo(ffi=b"", w_value_length_override=0) + out = _decode_vs_versioninfo(buf) + assert out["fixed_file_info"] is None + assert "fixed_file_info_truncated" not in out["errors"] + + def test_truncated_ffi_flagged(self): + # wValueLength claims 30 (< 52) but non-zero + buf = _build_vs_versioninfo( + ffi=b"\x00" * 30, + w_value_length_override=30, + ) + out = _decode_vs_versioninfo(buf) + assert "fixed_file_info_truncated" in out["errors"] + assert out["fixed_file_info"] is None + + def test_ffi_extending_past_buffer_flagged(self): + # wValueLength claims 100 but buffer only has space for ~52 + ffi = _build_ffi() + buf = _build_vs_versioninfo(ffi=ffi, w_value_length_override=0xFF00) + out = _decode_vs_versioninfo(buf) + # Either truncated or unpack error — but FFI should not be decoded + assert out["fixed_file_info"] is None + + def test_all_ffi_fields_decoded(self): + ffi = _build_ffi( + file_flags_mask=0xFF, + file_flags=0x0F, + file_os=0x00040004, + file_type=2, + file_subtype=5, + file_date_ms=0xCAFEBABE, + file_date_ls=0xDEADBEEF, + ) + buf = _build_vs_versioninfo(ffi=ffi) + out = _decode_vs_versioninfo(buf) + ffi_out = out["fixed_file_info"] + assert ffi_out["file_flags_mask"] == 0xFF + assert ffi_out["file_flags"] == 0x0F + assert ffi_out["file_os"] == 0x00040004 + assert ffi_out["file_type"] == 2 + assert ffi_out["file_subtype"] == 5 + assert ffi_out["file_date"] == (0xCAFEBABE, 0xDEADBEEF) + + +# ================================================================= +# Decoder tests — StringFileInfo +# ================================================================= + +class TestDecodeStringFileInfo: + def test_single_string_table_decoded(self): + sfi = _build_string_file_info([ + ("040904B0", {"CompanyName": "MalX Labs", "ProductName": "iocx"}), + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + + assert len(out["string_file_info"]) == 1 + sfi_out = out["string_file_info"][0] + assert len(sfi_out["tables"]) == 1 + table = sfi_out["tables"][0] + assert table["lang_codepage"] == "040904B0" + assert table["errors"] == [] + assert table["strings"]["CompanyName"] == "MalX Labs" + assert table["strings"]["ProductName"] == "iocx" + + def test_bad_lang_codepage_key_flagged(self): + sfi = _build_string_file_info([ + ("ENGLISHX", {"CompanyName": "MalX Labs"}), + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + + table = out["string_file_info"][0]["tables"][0] + assert "lang_codepage_key" in table["errors"] + # But strings should still decode + assert table["strings"]["CompanyName"] == "MalX Labs" + + def test_lang_codepage_wrong_length_flagged(self): + sfi = _build_string_file_info([ + ("0409", {"CompanyName": "X"}), # only 4 hex chars + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + table = out["string_file_info"][0]["tables"][0] + assert "lang_codepage_key" in table["errors"] + + def test_lang_codepage_lowercase_hex_accepted(self): + sfi = _build_string_file_info([ + ("040904b0", {"CompanyName": "X"}), + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + table = out["string_file_info"][0]["tables"][0] + assert "lang_codepage_key" not in table["errors"] + + def test_multiple_string_tables_decoded(self): + sfi = _build_string_file_info([ + ("040904B0", {"CompanyName": "A"}), + ("080904B0", {"CompanyName": "B"}), + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + tables = out["string_file_info"][0]["tables"] + assert len(tables) == 2 + assert tables[0]["strings"]["CompanyName"] == "A" + assert tables[1]["strings"]["CompanyName"] == "B" + + def test_string_values_bounded_at_512_chars(self): + long_value = "X" * 1000 + sfi = _build_string_file_info([ + ("040904B0", {"Long": long_value}), + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + table = out["string_file_info"][0]["tables"][0] + assert len(table["strings"]["Long"]) == 512 + + def test_string_keys_bounded_at_128_chars(self): + long_key = "K" * 200 + sfi = _build_string_file_info([ + ("040904B0", {long_key: "value"}), + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + table = out["string_file_info"][0]["tables"][0] + stored_key = next(iter(table["strings"].keys())) + assert len(stored_key) == 128 + + def test_empty_string_value(self): + sfi = _build_string_file_info([ + ("040904B0", {"EmptyValue": ""}), + ]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), string_file_info=sfi) + out = _decode_vs_versioninfo(buf) + table = out["string_file_info"][0]["tables"][0] + assert table["strings"]["EmptyValue"] == "" + + +# ================================================================= +# Decoder tests — VarFileInfo +# ================================================================= + +class TestDecodeVarFileInfo: + def test_single_translation_decoded(self): + vfi = _build_var_file_info([("Translation", [(0x0409, 0x04B0)])]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), var_file_info=vfi) + out = _decode_vs_versioninfo(buf) + + assert len(out["var_file_info"]) == 1 + vfi_out = out["var_file_info"][0] + assert vfi_out["errors"] == [] + assert len(vfi_out["vars"]) == 1 + var = vfi_out["vars"][0] + assert var["key"] == "Translation" + assert var["translations"] == [{"lang": 0x0409, "codepage": 0x04B0}] + + def test_multiple_translations_decoded(self): + translations = [(0x0409, 0x04B0), (0x0809, 0x04B0), (0x0407, 0x04E4)] + vfi = _build_var_file_info([("Translation", translations)]) + buf = _build_vs_versioninfo(ffi=_build_ffi(), var_file_info=vfi) + out = _decode_vs_versioninfo(buf) + + var = out["var_file_info"][0]["vars"][0] + assert len(var["translations"]) == 3 + assert var["translations"][0] == {"lang": 0x0409, "codepage": 0x04B0} + assert var["translations"][2] == {"lang": 0x0407, "codepage": 0x04E4} + + def test_misaligned_translation_flagged(self): + # Build a Var manually with wValueLength=6 (not DWORD-aligned) + key_bytes = _utf16_sz("Translation") + payload = b"\x09\x04\xB0\x04\xCC\xCC" # 6 bytes + header_and_key = struct.pack("= 2: + raise struct.error("simulated string header failure") + return real_u16(buf_, off) + + monkeypatch.setattr(pvi, "_u16", fake_u16) + + result = pvi._decode_string_file_info(inner, 0, len(inner)) + + # The StringTable should have been entered (call 1 succeeded) but the + # String entry header read failed (call 2 raised). + assert len(result["tables"]) == 1 + assert "string_header" in result["tables"][0]["errors"] + + # ---- Line 269-270: string_length malformation ---- + + def test_decode_string_file_info_flags_string_length_too_small(self): + """ + Cover: s_len < 6 branch in String entry parsing. + + Build a StringTable whose body contains a String header claiming + wLength = 2, which is less than the 6-byte minimum. + """ + # StringTable header: wLength placeholder, wValueLength=0, wType=1 + # Followed by key "040904B0" NUL-terminated UTF-16LE, then a malformed + # String header with wLength = 2. + st_key = _utf16_sz("040904B0") + st_header = struct.pack(" dict: + """ + Build a minimal resources_struct dict with this shape: + Type (depth 0) + └─ Name (depth 1) + └─ Language (depth 2) ← contains a named entry (anomaly) + └─ data leaf + """ + # Data leaf at depth 3 — well-formed, in-bounds + data_leaf_entry = { + "name": None, + "id": 0x0409, + "is_directory": False, + "directory": None, + "data_rva": 0x1100, + "data_size": 100, + "raw_offset": 0x500, + } + + # Depth 2: Language directory containing a NAMED entry (the anomaly) + # plus a valid ID entry as a sibling, to prove the validator emits + # per-violation rather than per-directory. + lang_dir = { + "rva": 0x1080, + "size": 32, + "entries": [ + { + "name": "EN-US", # ← anomaly: named instead of LCID + "id": None, + "is_directory": True, + "directory": { + "rva": 0x1090, + "size": 24, + "entries": [data_leaf_entry], + }, + "data_rva": None, + "data_size": None, + "raw_offset": None, + }, + ], + } + + # Depth 1: Name directory + name_dir = { + "rva": 0x1040, + "size": 24, + "entries": [ + { + "name": None, + "id": 1, + "is_directory": True, + "directory": lang_dir, + "data_rva": None, + "data_size": None, + "raw_offset": None, + }, + ], + } + + # Depth 0: Type directory (root) + root = { + "rva": 0x1000, + "size": 24, + "entries": [ + { + "name": None, + "id": 16, # RT_VERSION + "is_directory": True, + "directory": name_dir, + "data_rva": None, + "data_size": None, + "raw_offset": None, + }, + ], + } + + return { + "root": root, + "string_tables": [], + } + + def _make_analysis(self) -> dict: + return { + "sections": [{ + "name": ".rsrc", + "virtual_address": 0x1000, + "virtual_size": 0x2000, + "raw_address": 0x400, + "raw_size": 0x2000, + }], + "file_size": 0x10000, + "overlay_offset": 0x9000, + } + + def test_named_language_entry_flagged(self): + from iocx.reason_codes import ReasonCodes + from iocx.validators.resources import validate_resources + + resources = self._make_resources_with_named_language_entry() + metadata = {"resources_struct": resources} + issues = validate_resources(metadata, self._make_analysis()) + + codes = [i["issue"] for i in issues] + assert ReasonCodes.RESOURCE_DIRECTORY_LANGUAGE_NOT_ID in codes + + # Confirm the details payload is shaped correctly + details = [ + i["details"] for i in issues + if i["issue"] == ReasonCodes.RESOURCE_DIRECTORY_LANGUAGE_NOT_ID + ] + assert len(details) == 1 + assert details[0]["name"] == "EN-US" + assert details[0]["rva"] == 0x1080 # the language directory's RVA diff --git a/tests/unit/validators/test_validator_version_info.py b/tests/unit/validators/test_validator_version_info.py new file mode 100644 index 0000000..8c1c940 --- /dev/null +++ b/tests/unit/validators/test_validator_version_info.py @@ -0,0 +1,645 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Unit tests for iocx.validators.version_info.validate_version_info. + +Strategy: +- Input is the version_info_struct dict produced by parser_version_info. + We construct dicts directly rather than running the parser, which + isolates validator logic from parser behaviour. +- The analysis dict supplies sections and overlay_offset; minimal stubs + cover everything the validator reads. +- Each test asserts on the set of REASONCODES emitted and the details + payload, since both are part of the contract. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import pytest + +from iocx.reason_codes import ReasonCodes +from iocx.validators.version_info import validate_version_info + + +# ================================================================= +# Input builders +# ================================================================= + +def _make_analysis( + rsrc_va: int = 0x1000, + rsrc_vs: int = 0x2000, + include_rsrc: bool = True, + extra_sections: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Build a minimal analysis dict with optional .rsrc section.""" + sections: List[Dict[str, Any]] = [] + if include_rsrc: + sections.append({ + "name": ".rsrc", + "virtual_address": rsrc_va, + "virtual_size": rsrc_vs, + "raw_address": 0x400, + "raw_size": rsrc_vs, + }) + if extra_sections: + sections.extend(extra_sections) + return { + "sections": sections, + "file_size": 0x10000, + "overlay_offset": 0x9000, + } + + +def _make_ffi( + signature_ok: bool = True, + struct_version_ok: bool = True, + signature: int = 0xFEEF04BD, + struct_version: int = 0x00010000, +) -> Dict[str, Any]: + """Build a fixed_file_info sub-dict.""" + return { + "signature": signature, + "signature_ok": signature_ok, + "struct_version": struct_version, + "struct_version_ok": struct_version_ok, + "file_version": (0x00010000, 0x00020003), + "product_version": (0x00010000, 0x00020003), + "file_flags_mask": 0, + "file_flags": 0, + "file_os": 0x00040004, + "file_type": 1, + "file_subtype": 0, + "file_date": (0, 0), + } + + +_NOT_PROVIDED = object() + + +def _make_vi( + *, + rva: Optional[int] = 0x1100, + size: Optional[int] = 100, + decoded: bool = True, + header_ok: bool = True, + length_consistent: bool = True, + fixed_file_info: Any = _NOT_PROVIDED, + string_file_info: Any = _NOT_PROVIDED, + var_file_info: Any = _NOT_PROVIDED, + errors: Any = _NOT_PROVIDED, + w_type: int = 0, +) -> Dict[str, Any]: + """Build a complete version_info_struct dict with sensible defaults.""" + return { + "rva": rva, + "size": size, + "decoded": decoded, + "header_ok": header_ok, + "length_consistent": length_consistent, + "w_type": w_type, + "fixed_file_info": _make_ffi() if fixed_file_info is _NOT_PROVIDED else fixed_file_info, + "string_file_info": [] if string_file_info is _NOT_PROVIDED else string_file_info, + "var_file_info": [] if var_file_info is _NOT_PROVIDED else var_file_info, + "errors": [] if errors is _NOT_PROVIDED else errors, + } + + +def _codes(issues) -> List: + """Extract REASONCODES from a list of StructuralIssue dicts.""" + return [issue["issue"] for issue in issues] + + +def _details_for(issues, code) -> List[Dict[str, Any]]: + """Return the details payloads for all issues matching a given code.""" + return [issue["details"] for issue in issues if issue["issue"] == code] + + +# ================================================================= +# Absence / clean cases +# ================================================================= + +class TestAbsence: + """Absence of RT_VERSION is never a structural defect.""" + + def test_no_version_info_struct_returns_no_issues(self): + metadata = {} # version_info_struct missing entirely + issues = validate_version_info(metadata, _make_analysis()) + assert issues == [] + + def test_explicit_none_returns_no_issues(self): + metadata = {"version_info_struct": None} + issues = validate_version_info(metadata, _make_analysis()) + assert issues == [] + + +class TestCleanBlob: + """A well-formed version_info_struct produces no issues.""" + + def test_minimal_clean_blob(self): + metadata = {"version_info_struct": _make_vi()} + issues = validate_version_info(metadata, _make_analysis()) + assert issues == [] + + def test_clean_blob_with_all_substructures(self): + sfi = [{ + "tables": [{ + "lang_codepage": "040904B0", + "strings": {"CompanyName": "MalX Labs"}, + "errors": [], + }], + "errors": [], + }] + vfi = [{ + "vars": [{ + "key": "Translation", + "translations": [{"lang": 0x0409, "codepage": 0x04B0}], + }], + "errors": [], + }] + metadata = {"version_info_struct": _make_vi( + string_file_info=sfi, + var_file_info=vfi, + )} + issues = validate_version_info(metadata, _make_analysis()) + assert issues == [] + + +# ================================================================= +# Placement validation +# ================================================================= + +class TestPlacement: + + def test_placement_inside_rsrc_no_issue(self): + vi = _make_vi(rva=0x1100, size=100) + analysis = _make_analysis(rsrc_va=0x1000, rsrc_vs=0x2000) + issues = validate_version_info({"version_info_struct": vi}, analysis) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER not in _codes(issues) + + def test_placement_rva_before_rsrc_flagged(self): + vi = _make_vi(rva=0x500, size=100) + analysis = _make_analysis(rsrc_va=0x1000, rsrc_vs=0x2000) + issues = validate_version_info({"version_info_struct": vi}, analysis) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER) + assert any(d.get("reason") == "placement" for d in details) + + def test_placement_rva_extends_past_rsrc_flagged(self): + vi = _make_vi(rva=0x2F00, size=0x200) + analysis = _make_analysis(rsrc_va=0x1000, rsrc_vs=0x2000) + issues = validate_version_info({"version_info_struct": vi}, analysis) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER) + placement_details = [d for d in details if d.get("reason") == "placement"] + assert len(placement_details) == 1 + assert placement_details[0]["rva"] == 0x2F00 + assert placement_details[0]["size"] == 0x200 + + def test_placement_no_rsrc_section_skipped(self): + """If there's no .rsrc section, placement isn't checked.""" + vi = _make_vi(rva=0x5000, size=100) + analysis = _make_analysis(include_rsrc=False) + issues = validate_version_info({"version_info_struct": vi}, analysis) + placement_details = [d for d in _details_for( + issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER + ) if d.get("reason") == "placement"] + assert placement_details == [] + + def test_placement_rva_none_skipped(self): + """If the parser couldn't determine an RVA, placement isn't checked.""" + vi = _make_vi(rva=None, size=None) + analysis = _make_analysis() + issues = validate_version_info({"version_info_struct": vi}, analysis) + placement_details = [d for d in _details_for( + issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER + ) if d.get("reason") == "placement"] + assert placement_details == [] + + def test_placement_size_none_treated_as_zero(self): + """size=None is coerced to 0 by `vi['size'] or 0`.""" + vi = _make_vi(rva=0x1100, size=None) + analysis = _make_analysis(rsrc_va=0x1000, rsrc_vs=0x2000) + issues = validate_version_info({"version_info_struct": vi}, analysis) + # 0x1100 + 0 is within .rsrc, so no placement issue + placement_details = [d for d in _details_for( + issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER + ) if d.get("reason") == "placement"] + assert placement_details == [] + + def test_placement_exact_boundary_no_issue(self): + """RVA at rsrc_va and rva+size == rsrc_va + rsrc_vs is in-bounds.""" + vi = _make_vi(rva=0x1000, size=0x2000) + analysis = _make_analysis(rsrc_va=0x1000, rsrc_vs=0x2000) + issues = validate_version_info({"version_info_struct": vi}, analysis) + placement_details = [d for d in _details_for( + issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER + ) if d.get("reason") == "placement"] + assert placement_details == [] + + +# ================================================================= +# Top-level header validation +# ================================================================= + +class TestTopLevelHeader: + + def test_undecoded_emits_invalid_header_and_returns_early(self): + """If decoded=False, no further sub-structure issues should be emitted.""" + vi = _make_vi( + decoded=False, + header_ok=False, + length_consistent=False, + fixed_file_info=None, + errors=["too_short"], + ) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + + codes = _codes(issues) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER in codes + # Should not emit FFI/SFI/VFI codes because the validator returns early + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO not in codes + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO not in codes + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO not in codes + + # Details should carry the "undecoded" reason and the parser errors + header_details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER) + undecoded = [d for d in header_details if d.get("reason") == "undecoded"] + assert len(undecoded) == 1 + assert undecoded[0]["errors"] == ["too_short"] + + def test_szkey_mismatch_emits_invalid_header(self): + vi = _make_vi(header_ok=False) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER) + szkey_details = [d for d in details if d.get("reason") == "szkey_mismatch"] + assert len(szkey_details) == 1 + + def test_length_inconsistent_emits_invalid_header(self): + vi = _make_vi(length_consistent=False) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER) + length_details = [d for d in details if d.get("reason") == "length_inconsistent"] + assert len(length_details) == 1 + + def test_both_szkey_and_length_emit_two_separate_issues(self): + vi = _make_vi(header_ok=False, length_consistent=False) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER) + reasons = [d.get("reason") for d in details] + assert "szkey_mismatch" in reasons + assert "length_inconsistent" in reasons + + def test_undecoded_with_empty_errors_list(self): + """decoded=False with no errors recorded — still emits header issue.""" + vi = _make_vi(decoded=False, errors=[]) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER) + undecoded = [d for d in details if d.get("reason") == "undecoded"] + assert undecoded[0]["errors"] == [] + + +# ================================================================= +# VS_FIXEDFILEINFO validation +# ================================================================= + +class TestFixedFileInfo: + + def test_clean_ffi_no_issue(self): + vi = _make_vi(fixed_file_info=_make_ffi()) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO not in _codes(issues) + + def test_absent_ffi_without_errors_no_issue(self): + """Legitimate omission: FFI is None and no parser errors flag it.""" + vi = _make_vi(fixed_file_info=None, errors=[]) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO not in _codes(issues) + + def test_absent_ffi_with_parse_errors_flagged(self): + vi = _make_vi( + fixed_file_info=None, + errors=["fixed_file_info_truncated"], + ) + print("VI errors:", vi.get("errors")) # debug + print("VI ffi:", vi.get("fixed_file_info")) # debug + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + print("Issues:", issues) # debug + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO) + assert len(details) == 1 + assert details[0]["reason"] == "parse_failed" + assert "fixed_file_info_truncated" in details[0]["errors"] + + def test_absent_ffi_with_non_ffi_errors_not_flagged(self): + """Parser errors unrelated to FFI shouldn't trigger an FFI issue.""" + vi = _make_vi( + fixed_file_info=None, + errors=["unknown_child", "child_length_invalid"], + ) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO not in _codes(issues) + + def test_bad_signature_flagged(self): + ffi = _make_ffi(signature_ok=False, signature=0xDEADBEEF) + vi = _make_vi(fixed_file_info=ffi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO) + sig_details = [d for d in details if d.get("reason") == "signature"] + assert len(sig_details) == 1 + assert sig_details[0]["signature"] == 0xDEADBEEF + + def test_bad_struct_version_flagged(self): + ffi = _make_ffi(struct_version_ok=False, struct_version=0x00020000) + vi = _make_vi(fixed_file_info=ffi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO) + sv_details = [d for d in details if d.get("reason") == "struct_version"] + assert len(sv_details) == 1 + assert sv_details[0]["struct_version"] == 0x00020000 + + def test_both_signature_and_struct_version_emit_two_issues(self): + ffi = _make_ffi(signature_ok=False, struct_version_ok=False) + vi = _make_vi(fixed_file_info=ffi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO) + reasons = [d.get("reason") for d in details] + assert "signature" in reasons + assert "struct_version" in reasons + + +# ================================================================= +# StringFileInfo validation +# ================================================================= + +class TestStringFileInfo: + + def test_no_string_file_info_no_issue(self): + vi = _make_vi(string_file_info=[]) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO not in _codes(issues) + + def test_clean_string_file_info_no_issue(self): + sfi = [{ + "tables": [{ + "lang_codepage": "040904B0", + "strings": {"CompanyName": "X"}, + "errors": [], + }], + "errors": [], + }] + vi = _make_vi(string_file_info=sfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO not in _codes(issues) + + def test_sfi_with_top_level_errors_flagged(self): + """A StringFileInfo with errors on the wrapper itself.""" + sfi = [{ + "tables": [], + "errors": ["string_table_header"], + }] + vi = _make_vi(string_file_info=sfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO) + assert len(details) == 1 + assert details[0]["errors"] == ["string_table_header"] + assert details[0]["tables"] == 0 + + def test_sfi_with_top_level_errors_skips_table_iteration(self): + """Continue branch: top-level errors short-circuit table iteration.""" + sfi = [{ + "tables": [ + # This table has errors but should NOT produce a second issue + # because the wrapper-level error already triggered `continue`. + {"lang_codepage": "BAD", "strings": {}, "errors": ["lang_codepage_key"]}, + ], + "errors": ["string_table_length"], + }] + vi = _make_vi(string_file_info=sfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO) + assert len(details) == 1 # only the wrapper-level error + + def test_sfi_table_with_errors_flagged(self): + """A StringTable with its own errors but a clean wrapper.""" + sfi = [{ + "tables": [{ + "lang_codepage": "ENGLISHX", + "strings": {"CompanyName": "X"}, + "errors": ["lang_codepage_key"], + }], + "errors": [], + }] + vi = _make_vi(string_file_info=sfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO) + assert len(details) == 1 + assert details[0]["errors"] == ["lang_codepage_key"] + assert details[0]["lang_codepage"] == "ENGLISHX" + + def test_sfi_multiple_tables_each_with_errors_flagged_separately(self): + sfi = [{ + "tables": [ + {"lang_codepage": "BAD1", "strings": {}, "errors": ["lang_codepage_key"]}, + {"lang_codepage": "BAD2", "strings": {}, "errors": ["string_length"]}, + ], + "errors": [], + }] + vi = _make_vi(string_file_info=sfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO) + assert len(details) == 2 + codepages = [d["lang_codepage"] for d in details] + assert "BAD1" in codepages + assert "BAD2" in codepages + + def test_sfi_table_with_no_errors_not_flagged(self): + """A clean table inside an SFI with no wrapper errors produces nothing.""" + sfi = [{ + "tables": [{ + "lang_codepage": "040904B0", + "strings": {"X": "Y"}, + "errors": [], + }], + "errors": [], + }] + vi = _make_vi(string_file_info=sfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO not in _codes(issues) + + def test_multiple_sfi_children(self): + """Multiple StringFileInfo wrappers (unusual but legal).""" + sfi = [ + {"tables": [], "errors": ["string_table_header"]}, + {"tables": [], "errors": ["string_table_length"]}, + ] + vi = _make_vi(string_file_info=sfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO) + assert len(details) == 2 + + +# ================================================================= +# VarFileInfo validation +# ================================================================= + +class TestVarFileInfo: + + def test_no_var_file_info_no_issue(self): + vi = _make_vi(var_file_info=[]) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO not in _codes(issues) + + def test_clean_var_file_info_no_issue(self): + vfi = [{ + "vars": [{ + "key": "Translation", + "translations": [{"lang": 0x0409, "codepage": 0x04B0}], + }], + "errors": [], + }] + vi = _make_vi(var_file_info=vfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO not in _codes(issues) + + def test_vfi_with_errors_flagged(self): + vfi = [{ + "vars": [{"key": "Translation", "translations": []}], + "errors": ["translation_not_dword_aligned"], + }] + vi = _make_vi(var_file_info=vfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO) + assert len(details) == 1 + assert "translation_not_dword_aligned" in details[0]["errors"] + assert details[0]["vars"] == 1 + + def test_vfi_with_empty_vars_count(self): + """vars list empty but errors present — details vars count should be 0.""" + vfi = [{"vars": [], "errors": ["var_header"]}] + vi = _make_vi(var_file_info=vfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO) + assert details[0]["vars"] == 0 + + def test_multiple_vfi_children_each_with_errors(self): + vfi = [ + {"vars": [], "errors": ["var_header"]}, + {"vars": [], "errors": ["var_length"]}, + ] + vi = _make_vi(var_file_info=vfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + details = _details_for(issues, ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO) + assert len(details) == 2 + + def test_vfi_no_errors_not_flagged(self): + """A VarFileInfo with vars but no errors produces nothing.""" + vfi = [{ + "vars": [{"key": "Translation", "translations": []}], + "errors": [], + }] + vi = _make_vi(var_file_info=vfi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO not in _codes(issues) + + +# ================================================================= +# Combination scenarios +# ================================================================= + +class TestCombinedAnomalies: + """Multiple anomalies in a single blob — each fires independently.""" + + def test_szkey_plus_bad_ffi_signature_emits_both(self): + ffi = _make_ffi(signature_ok=False, signature=0xDEADBEEF) + vi = _make_vi(header_ok=False, fixed_file_info=ffi) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + codes = _codes(issues) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER in codes + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO in codes + + def test_placement_plus_sfi_plus_vfi_all_emit(self): + sfi = [{"tables": [], "errors": ["string_table_header"]}] + vfi = [{"vars": [], "errors": ["var_header"]}] + vi = _make_vi( + rva=0x5000, # outside .rsrc + size=100, + string_file_info=sfi, + var_file_info=vfi, + ) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + codes = _codes(issues) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER in codes + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO in codes + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO in codes + + +# ================================================================= +# Output shape contract +# ================================================================= + +class TestOutputContract: + """Pin the shape of returned StructuralIssue objects.""" + + def test_returns_list(self): + result = validate_version_info({"version_info_struct": _make_vi()}, _make_analysis()) + assert isinstance(result, list) + + def test_returns_empty_list_for_clean_blob(self): + result = validate_version_info({"version_info_struct": _make_vi()}, _make_analysis()) + assert result == [] + + def test_returns_empty_list_when_vi_absent(self): + assert validate_version_info({}, _make_analysis()) == [] + assert validate_version_info({"version_info_struct": None}, _make_analysis()) == [] + + def test_each_issue_has_issue_and_details(self): + vi = _make_vi(header_ok=False) + issues = validate_version_info({"version_info_struct": vi}, _make_analysis()) + for issue in issues: + assert "issue" in issue + assert "details" in issue + assert isinstance(issue["details"], dict) + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + """Same input → same output, always.""" + + def test_repeated_validation_produces_identical_issues(self): + ffi = _make_ffi(signature_ok=False) + sfi = [{ + "tables": [{ + "lang_codepage": "BAD", + "strings": {}, + "errors": ["lang_codepage_key"], + }], + "errors": [], + }] + vfi = [{"vars": [], "errors": ["translation_not_dword_aligned"]}] + vi = _make_vi( + header_ok=False, + fixed_file_info=ffi, + string_file_info=sfi, + var_file_info=vfi, + ) + metadata = {"version_info_struct": vi} + analysis = _make_analysis() + + results = [validate_version_info(metadata, analysis) for _ in range(20)] + codes_sequence = [_codes(r) for r in results] + for seq in codes_sequence[1:]: + assert seq == codes_sequence[0] + + def test_issue_ordering_is_stable(self): + """The order in which issues are emitted should be deterministic.""" + vi = _make_vi( + header_ok=False, + length_consistent=False, + fixed_file_info=_make_ffi(signature_ok=False, struct_version_ok=False), + ) + result1 = validate_version_info({"version_info_struct": vi}, _make_analysis()) + result2 = validate_version_info({"version_info_struct": vi}, _make_analysis()) + assert _codes(result1) == _codes(result2) From 641ba5f114670267156e50a2b53185ac93e7f668 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 11:03:21 +0100 Subject: [PATCH 04/35] Changelog entry --- CHANGELOG.md | 97 +++++++++++++++++++ ...ral-validation-deterministic-heuristics.md | 28 ++++++ 2 files changed, 125 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d210f22..8faabd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,100 @@ +# **v0.7.5 — Unreleased** + +## Added + +- **Resource directory hierarchy enforcement.** The resource validator now + tracks tree depth and enforces the PE specification's Type → Name → Language + layering. Two new reason codes are emitted: + - `RESOURCE_DIRECTORY_LANGUAGE_NOT_ID` — a depth-2 (Language layer) entry + uses a name instead of an integer LCID. + - `RESOURCE_DATA_AT_INVALID_DEPTH` — a data leaf appears outside the + Language layer. +- **Deterministic VS_VERSIONINFO extraction.** New `pe_version_info` parser + module decodes the version-info envelope, VS_FIXEDFILEINFO, StringFileInfo + and VarFileInfo structures purely from bytes using `struct.unpack_from`. + Leaf selection across multiple RT_VERSION entries is deterministic, sorted + by `(name_id, language_id)`. The decoder never raises; sub-structure + failures emit tombstone tags in an `errors[]` list. +- **Version-info structural validator.** New `validator_version_info` module + maps parser output to four new reason codes: + - `RESOURCE_VERSIONINFO_INVALID_HEADER` — placement, `szKey`, or `wLength` + malformed. + - `RESOURCE_VERSIONINFO_INVALID_FIXEDINFO` — VS_FIXEDFILEINFO signature or + struct version wrong, or parse failed. + - `RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO` — StringFileInfo, + StringTable, or String malformed. + - `RESOURCE_VERSIONINFO_INVALID_VARFILEINFO` — VarFileInfo or Var malformed, + or Translation array not DWORD-aligned. + + Absence of RT_VERSION is not treated as a structural defect. +- **Precise internal metadata typing.** `InternalMetadata.resources_struct` is + now `Optional[ResourcesStruct]` with a fully-typed `ResourceEntry` shape + replacing `List[Any]`. New `VersionInfoStruct` and its sub-types + (`FixedFileInfo`, `StringFileInfo`, `StringTable`, `VarFileInfo`, + `VarEntry`, `Translation`) are declared in the schema. + +## Changed + +- **Resource parser hardens against corrupt RVAs.** `pe.get_offset_from_rva` + calls are now guarded against `pefile.PEFormatError` and `AttributeError`. + A corrupt RVA produces a `-1` sentinel in `raw_offset` rather than + propagating the exception. The validator's existing `data_raw < 0` arm + maps this to the existing `RESOURCE_DATA_OUT_OF_BOUNDS` reason code; no new + code introduced. +- **Validator dispatcher order.** `validate_version_info` is registered + between `validate_resources` and `validate_entropy`, reflecting its + position as a payload-specific validator nested under the resource tree. + +## Marked as RESERVE but consider removing in the future + +- Dead `size == 0` branch in `validate_directory` (unreachable: `size` is + derived from `len(entries)` and always ≥ 16). +- Unused `rsrc_raw` and `rsrc_raw_size` locals in the resource validator. + +## Fixed + +- Resource validator no longer silently returns when a directory's own RVA + falls outside `.rsrc`. Behaviour previously suppressed any reporting for + malformed directory placement. + +## Documentation + +- Reason-codes reference extended with two new subsections: + *Resource Hierarchy Anomalies* and *Resource Version-Info Anomalies*. +- New validator documentation section (2.10) for the version-info validator, + including an explicit determinism rationale paragraph. +- Brief clarifying note added to the resources validator section explaining + the layering between resource-tree validation and payload validators + nested beneath it. + +## Internal + +- 100% line and branch coverage on `pe_version_info`, + `validator_version_info`, and the resource validator additions. +- Defensive-path coverage for every `except` clause via monkeypatched + `struct.error` injection. +- Narrow-except negative tests confirm the parser's exception handling does + not silently swallow exceptions outside `(PEFormatError, AttributeError)`. + +## Compatibility + +- **No reason-code remapping.** Existing fixture expected outputs are + unchanged for all binaries that don't exercise the new pathways. +- **No public IOC schema changes.** Version-info data is currently exposed + only in internal metadata and CLI rendering; public IOC schema exposure + is deferred to a later release with a deliberate fixture corpus refresh. + +## Known scheduled work + +- Six single-anomaly fixtures targeting the new reason codes (specs queued; + construction to follow). +- `pefile_usage_policy.md` documenting the deterministic-subset usage pattern + (to be drafted alongside the reproducibility appendix work). +- Public IOC schema field for `version_info` (planned for a future release + with corpus refresh and schema-version bump). + +--- + # **v0.7.4.1 — Windows‑Compatible PE Detection Hotfix** IOCX v0.7.4.1 removes the `python-magic` dependency, improves PE detection accuracy, and reduces IOCX’s attack surface. diff --git a/docs/specs/structural-validation-deterministic-heuristics.md b/docs/specs/structural-validation-deterministic-heuristics.md index f395392..0076d97 100644 --- a/docs/specs/structural-validation-deterministic-heuristics.md +++ b/docs/specs/structural-validation-deterministic-heuristics.md @@ -201,6 +201,34 @@ This closes one of the most subtle structural attack surfaces in the PE format. --- +# 2.10 Version‑Info Validator +## Validates the structural integrity of the VS_VERSIONINFO blob extracted from the RT_VERSION resource. + +This validator performs: + +- Top‑level envelope validation: placement within .rsrc, szKey conformance to "VS_VERSION_INFO", and wLength consistency with the buffer. +- VS_FIXEDFILEINFO signature and struct‑version validation. +- StringFileInfo / StringTable / String hierarchy traversal, with per‑substructure length and key‑format checks. +- VarFileInfo / Var validation, including DWORD‑alignment of the Translation array. +- Deterministic leaf selection: when multiple RT_VERSION leaves exist, the parser sorts by (name_id, language_id) so the chosen blob is stable across runs. + +VS_VERSIONINFO is a recursive, length‑prefixed nested‑structure format with multiple optional children, variable‑length UTF‑16 keys, and DWORD‑alignment rules between every field. It is one of the most failure‑prone surfaces in the PE format for general‑purpose parsers — small differences in how a parser handles truncated wLength fields, malformed szKey strings, or misaligned Translation arrays produce divergent output across tools and across versions of the same tool. + +The version‑info parser is implemented as a pure struct‑level decoder with no reliance on external library interpretation: + +- Length and alignment arithmetic is performed against the raw buffer using fixed PE‑spec formulas. +- All loops are bounded by wLength, child_end, and body_end so no walk can exceed the input. +- Sub‑structure failures emit deterministic tombstone tags in an errors list rather than raising exceptions or being silently swallowed. +- Leaf selection across multiple RT_VERSION entries uses a stable sort key, not parser iteration order. + +This ensures that for any given input blob, the parser produces the same decoded dict on every run, on every platform, regardless of library version. Malformed inputs produce predictable structural errors rather than partial parses or library‑specific exceptions. + +The validator then maps these structural states to a small, well‑defined set of reason codes (`RESOURCE_VERSIONINFO_INVALID_HEADER`, `_INVALID_FIXEDINFO`, `_INVALID_STRINGFILEINFO`, `_INVALID_VARFILEINFO`), which downstream heuristics and IOC consumers can rely on as a stable contract. + +Version‑info is a high‑signal forensic surface: CompanyName, OriginalFilename, and ProductVersion are routinely impersonated in adversarial samples. Deterministic extraction is a prerequisite for treating these fields as reliable triage signals. + +--- + # **3. Deterministic Heuristics Layer** ### *Heuristics interpret structural truth — they never override it.* From fb3a092d7bc8d4c3e5db5de02a35c2bd3196e112 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 12:24:43 +0100 Subject: [PATCH 05/35] feat(pe_exports): add deterministic raw-bytes export table parser New module independent of pefile's DIRECTORY_ENTRY_EXPORT interpretation. Uses pefile only to locate the export directory and read raw bytes via pe.get_data; all structural fields are decoded directly via struct.unpack_from. Deterministic extraction of: - 40-byte IMAGE_EXPORT_DIRECTORY header - Export Address Table (EAT) - Export Name Pointer Table (ENPT) - Export Ordinal Table (EOT) - Per-function name resolution and forwarder detection by RVA range - Per-entry ASCII name string reading with bounded scan length - Forwarder string structural validation against DllName.SymbolName and DllName.#Ordinal grammar via a single conservative regex Determinism guarantees: - Bounded array reads with per-position fallback to None on truncation - All loops bounded by declared counts; no input can produce unbounded iteration - Sub-structure failures emit deterministic tombstone tags in truncations[] and per-entry errors[] rather than raising - Stable output regardless of platform or pefile version Never raises; returns None for absent export directory, or a dict shaped per the documented output contract. Refs: requirement 2 (export table refinement) --- iocx/parsers/pe_exports.py | 495 +++++++++++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 iocx/parsers/pe_exports.py diff --git a/iocx/parsers/pe_exports.py b/iocx/parsers/pe_exports.py new file mode 100644 index 0000000..b64dc2e --- /dev/null +++ b/iocx/parsers/pe_exports.py @@ -0,0 +1,495 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Deterministic structural extraction of the PE export table. + +This module is intentionally independent of pefile's DIRECTORY_ENTRY_EXPORT +interpretation. We use pefile only to: + - Locate the export directory (RVA, size) + - Resolve RVAs to file offsets + - Read raw bytes via pe.get_data + +All structural fields (Base, NumberOfFunctions, NumberOfNames, +AddressOfFunctions, AddressOfNames, AddressOfNameOrdinals) are read directly +from the raw 40-byte IMAGE_EXPORT_DIRECTORY structure. The name pointer +array, name strings, and forwarder strings are also read raw. + +Output contract: + None - no export directory present (not an error) + dict with keys (see ExportStruct in iocx.schemas.internal_schema): + rva, size - placement of the export directory + header - decoded IMAGE_EXPORT_DIRECTORY fields + functions - list[FunctionEntry] + name_pointers - list[NamePointerEntry] + truncations - list[str] - parser tombstone tags + errors - list[str] - top-level decode errors + +Each FunctionEntry has: + index - position in EAT (0..NumberOfFunctions-1) + ordinal - Base + index + address_rva - value from AddressOfFunctions entry + is_forwarder - True if address_rva is within the + export directory (per PE spec) + forwarder - raw forwarder string if applicable, + else None + forwarder_valid - structural validity of the forwarder + name - resolved name if a name pointer maps + to this index, else None + name_rva - RVA of the name string, if any + +Each NamePointerEntry has: + index - position in name pointer array + name_rva - value from AddressOfNames entry + ordinal_index - value from AddressOfNameOrdinals (the + index into AddressOfFunctions) + name - decoded string, or None on failure + name_valid - structural validity of the name + errors - per-entry decode errors +""" + +from __future__ import annotations + +import re +import struct +from typing import Any, Dict, List, Optional + +# IMAGE_DIRECTORY_ENTRY_EXPORT = 0 +_EXPORT_DIRECTORY_INDEX = 0 +_EXPORT_DIRECTORY_SIZE = 40 # IMAGE_EXPORT_DIRECTORY is 40 bytes + +# Forwarder string: ASCII printable, "DllName.SymbolName" or "DllName.#Ordinal" +# Conservative bounds: name parts up to 255 chars, total up to 512. +_FORWARDER_RE = re.compile( + r"^[\x20-\x7E]{1,255}\.(?:#\d{1,10}|[\x20-\x7E]{1,255})$" +) + +# Name string: PE spec allows ASCII; we accept any printable byte range +# typical of compilers. Length cap defends against pathological inputs. +_NAME_MAX_LEN = 1024 +_FORWARDER_MAX_LEN = 1024 + + +def build_export_structure(pe) -> Optional[Dict[str, Any]]: + """ + Locate and structurally decode the PE export table. + + Returns None if no export directory is present. Otherwise returns a + dict per the module docstring contract. Never raises; decode failures + produce tombstone entries in the `errors` and `truncations` lists. + """ + placement = _locate_export_directory(pe) + if placement is None: + return None + + rva, size = placement + errors: List[str] = [] + truncations: List[str] = [] + + # Read the 40-byte export directory header from raw bytes + try: + header_bytes = bytes(pe.get_data(rva, _EXPORT_DIRECTORY_SIZE)) + except Exception: + return _empty_result(rva, size, errors=["header_read_failed"]) + + if len(header_bytes) < _EXPORT_DIRECTORY_SIZE: + truncations.append("export_directory_header") + return _empty_result(rva, size, + errors=errors, + truncations=truncations) + + header = _decode_export_directory(header_bytes) + if header is None: + return _empty_result(rva, size, errors=["header_unpack_failed"]) + + # Compute the directory's extent for forwarder detection (PE spec: + # if an EAT entry's RVA points within the export directory, it's a + # forwarder). + dir_start = rva + dir_end = rva + size + + # ---- Read EAT (Export Address Table) ---- + eat = _read_dword_array( + pe, + header["AddressOfFunctions"], + header["NumberOfFunctions"], + truncations, + tag="eat", + ) + + # ---- Read ENPT (Export Name Pointer Table) ---- + enpt = _read_dword_array( + pe, + header["AddressOfNames"], + header["NumberOfNames"], + truncations, + tag="enpt", + ) + + # ---- Read EOT (Export Ordinal Table) ---- + eot = _read_word_array( + pe, + header["AddressOfNameOrdinals"], + header["NumberOfNames"], + truncations, + tag="eot", + ) + + # Build name pointer entries with resolved names + name_pointers, name_by_index = _build_name_pointers(pe, enpt, eot, header) + + # Build function entries, joining EAT with name resolution + functions = _build_functions( + pe, eat, header, dir_start, dir_end, name_by_index + ) + + return { + "rva": rva, + "size": size, + "header": header, + "functions": functions, + "name_pointers": name_pointers, + "truncations": truncations, + "errors": errors, + } + + +# ================================================================= +# Locator +# ================================================================= + +def _locate_export_directory(pe) -> Optional[tuple]: + """ + Return (rva, size) of the export data directory, or None if absent. + """ + try: + data_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[_EXPORT_DIRECTORY_INDEX] + rva = int(data_dir.VirtualAddress) + size = int(data_dir.Size) + except (AttributeError, IndexError, ValueError, TypeError): + return None + + if rva == 0 or size == 0: + return None + + return (rva, size) + + +def _empty_result( + rva: int, + size: int, + *, + errors: Optional[List[str]] = None, + truncations: Optional[List[str]] = None, +) -> Dict[str, Any]: + """Build a minimal result dict for early-return cases.""" + return { + "rva": rva, + "size": size, + "header": None, + "functions": [], + "name_pointers": [], + "truncations": truncations or [], + "errors": errors or [], + } + + +# ================================================================= +# Header decoder +# ================================================================= + +def _decode_export_directory(buf: bytes) -> Optional[Dict[str, int]]: + """ + Unpack the 40-byte IMAGE_EXPORT_DIRECTORY structure. + + Field order per PE spec: + DWORD Characteristics + DWORD TimeDateStamp + WORD MajorVersion + WORD MinorVersion + DWORD Name + DWORD Base + DWORD NumberOfFunctions + DWORD NumberOfNames + DWORD AddressOfFunctions + DWORD AddressOfNames + DWORD AddressOfNameOrdinals + """ + try: + unpacked = struct.unpack_from(" List[Optional[int]]: + """ + Read `count` little-endian DWORDs starting at `rva`. Returns a list of + exactly `count` elements; positions where the read failed contain None. + Appends a truncation tag if fewer bytes were available than declared. + """ + if count == 0: + return [] + if rva == 0: + truncations.append(f"{tag}_rva_zero") + return [None] * count + + byte_count = count * 4 + try: + raw = bytes(pe.get_data(rva, byte_count)) + except Exception: + truncations.append(f"{tag}_read_failed") + return [None] * count + + if len(raw) < byte_count: + truncations.append(f"{tag}_truncated") + + result: List[Optional[int]] = [] + for i in range(count): + offset = i * 4 + if offset + 4 > len(raw): + result.append(None) + else: + result.append(struct.unpack_from(" List[Optional[int]]: + """ + Read `count` little-endian WORDs starting at `rva`. Same semantics as + _read_dword_array. + """ + if count == 0: + return [] + if rva == 0: + truncations.append(f"{tag}_rva_zero") + return [None] * count + + byte_count = count * 2 + try: + raw = bytes(pe.get_data(rva, byte_count)) + except Exception: + truncations.append(f"{tag}_read_failed") + return [None] * count + + if len(raw) < byte_count: + truncations.append(f"{tag}_truncated") + + result: List[Optional[int]] = [] + for i in range(count): + offset = i * 2 + if offset + 2 > len(raw): + result.append(None) + else: + result.append(struct.unpack_from(" tuple: + """ + Build the name pointer entries and a side index mapping + EAT-index -> name, used by _build_functions to enrich function entries. + """ + entries: List[Dict[str, Any]] = [] + name_by_index: Dict[int, str] = {} + + num_names = header["NumberOfNames"] + num_funcs = header["NumberOfFunctions"] + + for i in range(num_names): + name_rva = enpt[i] if i < len(enpt) else None + ordinal_index = eot[i] if i < len(eot) else None + entry_errors: List[str] = [] + name: Optional[str] = None + name_valid = False + + if name_rva is None: + entry_errors.append("name_rva_missing") + elif name_rva == 0: + entry_errors.append("name_rva_zero") + else: + name, decode_error = _read_asciiz(pe, name_rva, _NAME_MAX_LEN) + if decode_error: + entry_errors.append(decode_error) + else: + name_valid = _is_valid_export_name(name) + if not name_valid: + entry_errors.append("name_not_printable_ascii") + + # Cross-check the ordinal index against EAT bounds + if ordinal_index is None: + entry_errors.append("ordinal_index_missing") + elif ordinal_index >= num_funcs: + entry_errors.append("ordinal_index_out_of_range") + elif name is not None and name_valid: + # Record the resolved name against its EAT index for use by + # _build_functions + name_by_index[ordinal_index] = name + + entries.append({ + "index": i, + "name_rva": name_rva, + "ordinal_index": ordinal_index, + "name": name, + "name_valid": name_valid, + "errors": entry_errors, + }) + + return entries, name_by_index + + +# ================================================================= +# Function entries +# ================================================================= + +def _build_functions( + pe, + eat: List[Optional[int]], + header: Dict[str, int], + dir_start: int, + dir_end: int, + name_by_index: Dict[int, str], +) -> List[Dict[str, Any]]: + """ + Build the function entry list from the EAT, joining with name resolution + and decoding forwarder strings where applicable. + """ + entries: List[Dict[str, Any]] = [] + base = header["Base"] + num_funcs = header["NumberOfFunctions"] + + for i in range(num_funcs): + address_rva = eat[i] if i < len(eat) else None + ordinal = base + i + + is_forwarder = False + forwarder: Optional[str] = None + forwarder_valid = False + name_rva: Optional[int] = None + + if address_rva is not None and address_rva != 0: + # PE spec: an EAT entry RVA that points within the export + # directory itself is a forwarder string pointer. + if dir_start <= address_rva < dir_end: + is_forwarder = True + forwarder, _ = _read_asciiz(pe, address_rva, _FORWARDER_MAX_LEN) + if forwarder is not None: + forwarder_valid = bool(_FORWARDER_RE.match(forwarder)) + + # Look up resolved name from name pointer table cross-reference + name = name_by_index.get(i) + if name is not None: + # The name's RVA is recorded in name_pointers, not duplicated + # here — but we expose name_rva as None to keep the shape + # consistent. + name_rva = None + + entries.append({ + "index": i, + "ordinal": ordinal, + "address_rva": address_rva, + "is_forwarder": is_forwarder, + "forwarder": forwarder, + "forwarder_valid": forwarder_valid, + "name": name, + "name_rva": name_rva, + }) + + return entries + + +# ================================================================= +# String reading +# ================================================================= + +def _read_asciiz( + pe, + rva: int, + max_len: int, +) -> tuple: + """ + Read a NUL-terminated ASCII string starting at `rva`. Returns + (string, error_tag). On success, error_tag is None. On failure, string + is None and error_tag describes the failure. + + Reads opportunistically: tries to read up to max_len bytes, accepts a + short read as the available extent, and scans for the NUL terminator + within whatever bytes came back. + """ + if rva == 0: + return None, "rva_zero" + + try: + raw = bytes(pe.get_data(rva, max_len)) + except Exception: + return None, "read_failed" + + if not raw: + return None, "empty_read" + + nul_pos = raw.find(b"\x00") + if nul_pos == -1: + # No terminator found within max_len — treat as truncated + return None, "unterminated" + + try: + s = raw[:nul_pos].decode("ascii") + except UnicodeDecodeError: + # Fallback: decode with replacement, but flag the structural defect + s = raw[:nul_pos].decode("ascii", errors="replace") + return s, "non_ascii" + + return s, None + + +def _is_valid_export_name(s: str) -> bool: + """ + PE export names should be printable ASCII identifiers. Conservative + check: printable ASCII range only, no control chars, at least one char. + """ + if not s: + return False + return all(0x20 <= ord(c) <= 0x7E for c in s) From bd5d442227433bb51e0748732962e7ec267249c6 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 12:31:09 +0100 Subject: [PATCH 06/35] feat(validators/exports): structural validation of decoded export table Maps parser_exports output to ten new reason codes: Directory anomalies: - EXPORT_DIRECTORY_INVALID_HEADER: malformed header or inconsistent declared counts vs RVAs - EXPORT_DIRECTORY_OUT_OF_BOUNDS: directory extends past SizeOfImage - EXPORT_TABLE_TRUNCATED: declared sub-table size exceeded available bytes Name pointer anomalies: - EXPORT_NAME_RVA_INVALID: name RVA missing, zero, or string unreadable - EXPORT_NAME_NOT_ASCII: name decoded but contains non-printable bytes - EXPORT_NAME_POINTER_TABLE_UNSORTED: ENPT violates PE-spec sort order - EXPORT_NAME_ORDINAL_INDEX_INVALID: EOT entry missing or >= NumberOfFunctions Function entry anomalies: - EXPORT_ORDINAL_OUT_OF_RANGE: Base + NumberOfFunctions - 1 exceeds u16 - EXPORT_FUNCTION_RVA_INVALID: address RVA exceeds SizeOfImage - EXPORT_FORWARDER_MALFORMED: forwarder unreadable or grammar violation Implementation choices: - Priority-resolved sub-reasons in details[reason] for name RVA and name encoding error classes, eliminating double-emission on entries with multiple matching parser tags - Per-entry single-issue emission discipline matching validator_version_info - Top-level decode failure short-circuits all sub-validation - Absence of export directory not treated as a defect (most EXEs have no exports) - Forwarder entries skip the function-RVA-in-image check (forwarders legitimately point inside the export directory) Registered in the dispatcher between validate_version_info and validate_entropy. Refs: requirement 2 (export table refinement) --- iocx/validators/exports.py | 361 +++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 iocx/validators/exports.py diff --git a/iocx/validators/exports.py b/iocx/validators/exports.py new file mode 100644 index 0000000..fb5b8eb --- /dev/null +++ b/iocx/validators/exports.py @@ -0,0 +1,361 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Validate the export-table structure produced by parser_exports. + +Absence of an export directory is NOT a structural defect — most EXEs +(as opposed to DLLs) legitimately have no exports. We only emit codes +when an export directory is present and structurally malformed. + +Reason codes emitted: + EXPORT_DIRECTORY_INVALID_HEADER + EXPORT_DIRECTORY_OUT_OF_BOUNDS + EXPORT_TABLE_TRUNCATED + EXPORT_NAME_RVA_INVALID + EXPORT_NAME_NOT_ASCII + EXPORT_NAME_POINTER_TABLE_UNSORTED + EXPORT_ORDINAL_OUT_OF_RANGE + EXPORT_FORWARDER_MALFORMED + EXPORT_FUNCTION_RVA_INVALID + EXPORT_NAME_ORDINAL_INDEX_INVALID +""" + +from typing import Any, Dict, List + +from iocx.reason_codes import ReasonCodes +from iocx.validators.schema import StructuralIssue +from iocx.schemas.internal_schema import InternalMetadata +from iocx.schemas.analysis import AnalysisDict +from .decorators import depends_on + + +# Deterministic priority orders for mapping parser error tags to a single +# reason. The validator emits at most one issue per malformed entry per +# pathology class; the first tag in priority order wins. +_NAME_RVA_ERROR_PRIORITY = [ + "name_rva_missing", + "name_rva_zero", + "read_failed", + "unterminated", +] + +_NAME_ENCODING_ERROR_PRIORITY = [ + "non_ascii", + "name_not_printable_ascii", +] + + +@depends_on("internal", "analysis") +def validate_exports(metadata: InternalMetadata, analysis: AnalysisDict) -> List[StructuralIssue]: + issues: List[StructuralIssue] = [] + + exp = metadata.get("export_struct") + if exp is None: + return issues + + size_of_image = analysis.get("size_of_image") + + # If the parser couldn't decode the header at all, the rest of the + # validation can't run meaningfully. + if exp.get("errors"): + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER, + details={"reason": "top_level_decode", + "errors": list(exp["errors"])}, + )) + return issues + + _validate_placement(exp, size_of_image, issues) + _validate_truncations(exp, issues) + + header = exp.get("header") + if header is None: + return issues + + _validate_header_consistency(exp, header, issues) + _validate_name_pointers(exp, header, issues) + _validate_functions(exp, header, size_of_image, issues) + _validate_name_pointer_ordering(exp, issues) + + return issues + + +# ================================================================= +# Placement +# ================================================================= + +def _validate_placement(exp, size_of_image, issues): + """ + The export directory must lie within the PE image (SizeOfImage). We + don't require it to lie in a specific section (exports can live + anywhere in the image), but it must be within the image bounds. + """ + rva = exp.get("rva") + size = exp.get("size") or 0 + + # Skip placement check if the analysis layer didn't populate + # size_of_image. This shouldn't happen in normal operation. If it + # does, an upstream bug needs investigating, not a placement issue. + if rva is None or size_of_image is None: + return + + if rva + size > size_of_image: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_DIRECTORY_OUT_OF_BOUNDS, + details={"rva": rva, "size": size, + "size_of_image": size_of_image}, + )) + + +# ================================================================= +# Truncations +# ================================================================= + +def _validate_truncations(exp: Dict[str, Any], + issues: List[StructuralIssue]) -> None: + """ + Map parser truncation tags to a single reason code with structured + details. Each tag becomes one issue so the consumer sees one issue + per truncated table rather than a single bundled report. + """ + for tag in exp.get("truncations", []) or []: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_TABLE_TRUNCATED, + details={"table": tag}, + )) + + +# ================================================================= +# Header consistency +# ================================================================= + +def _validate_header_consistency(exp: Dict[str, Any], + header: Dict[str, int], + issues: List[StructuralIssue]) -> None: + """ + Sanity-check declared counts against declared array RVAs. + + A non-zero NumberOfFunctions with AddressOfFunctions == 0 is + structurally inconsistent: the EAT is declared to exist but has no + location. Same logic for NumberOfNames / AddressOfNames / + AddressOfNameOrdinals. + + Also flag NumberOfNames > NumberOfFunctions, which is impossible + in a well-formed export table. + """ + num_funcs = header.get("NumberOfFunctions", 0) + num_names = header.get("NumberOfNames", 0) + addr_funcs = header.get("AddressOfFunctions", 0) + addr_names = header.get("AddressOfNames", 0) + addr_name_ord = header.get("AddressOfNameOrdinals", 0) + + if num_funcs > 0 and addr_funcs == 0: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER, + details={"reason": "eat_rva_zero_with_nonzero_count", + "NumberOfFunctions": num_funcs}, + )) + + if num_names > 0 and addr_names == 0: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER, + details={"reason": "enpt_rva_zero_with_nonzero_count", + "NumberOfNames": num_names}, + )) + + if num_names > 0 and addr_name_ord == 0: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER, + details={"reason": "eot_rva_zero_with_nonzero_count", + "NumberOfNames": num_names}, + )) + + if num_names > num_funcs: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER, + details={"reason": "num_names_exceeds_num_functions", + "NumberOfNames": num_names, + "NumberOfFunctions": num_funcs}, + )) + + +# ================================================================= +# Name pointer table +# ================================================================= + +def _validate_name_pointers(exp: Dict[str, Any], + header: Dict[str, int], + issues: List[StructuralIssue]) -> None: + """ + For each name pointer entry, emit at most one issue per pathology + class: + - one EXPORT_NAME_RVA_INVALID if the name RVA is unusable + - one EXPORT_NAME_NOT_ASCII if the name decoded but is non-ASCII + - one EXPORT_NAME_ORDINAL_INDEX_INVALID if the ordinal index is bad + + Priority orders in module-level constants determine which sub-reason + wins when an entry carries multiple tags in the same class. + """ + num_funcs = header.get("NumberOfFunctions", 0) + + for entry in exp.get("name_pointers", []) or []: + index = entry.get("index") + entry_errors = entry.get("errors", []) or [] + + # ---- Name RVA validation: one issue per entry, priority-resolved ---- + rva_reason = _first_matching(entry_errors, _NAME_RVA_ERROR_PRIORITY) + if rva_reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_NAME_RVA_INVALID, + details={"index": index, + "name_rva": entry.get("name_rva"), + "reason": rva_reason}, + )) + + # ---- Name encoding: one issue per entry, priority-resolved ---- + encoding_reason = _first_matching( + entry_errors, _NAME_ENCODING_ERROR_PRIORITY + ) + if encoding_reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_NAME_NOT_ASCII, + details={"index": index, + "name": entry.get("name"), + "reason": encoding_reason}, + )) + + # ---- Ordinal index bounds ---- + if "ordinal_index_missing" in entry_errors: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID, + details={"index": index, + "reason": "missing"}, + )) + elif "ordinal_index_out_of_range" in entry_errors: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID, + details={"index": index, + "ordinal_index": entry.get("ordinal_index"), + "num_functions": num_funcs, + "reason": "out_of_range"}, + )) + + +def _validate_name_pointer_ordering(exp: Dict[str, Any], + issues: List[StructuralIssue]) -> None: + """ + The PE spec requires the Export Name Pointer Table to be sorted + lexicographically by name so that GetProcAddress can use binary search. + Unsorted tables are a real malware pattern (some packers use them to + confuse static analysers that assume sorted entries). + """ + names: List[str] = [] + for entry in exp.get("name_pointers", []) or []: + name = entry.get("name") + # Skip ordering check if any name in the table is unreadable. + # Reporting "unsorted" against a partial view is misleading; the + # disorder might just be a downstream effect of the decode failures. + # The per-entry EXPORT_NAME_RVA_INVALID / EXPORT_NAME_NOT_ASCII + # codes already carry the signal that the table is malformed. + if name is None or not entry.get("name_valid"): + return + names.append(name) + + if not names: + return + + sorted_names = sorted(names) + if names != sorted_names: + # Report once per table, not per pair + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_NAME_POINTER_TABLE_UNSORTED, + details={"name_count": len(names), + "first_violation_index": _first_unsorted_index(names)}, + )) + + +def _first_unsorted_index(names: List[str]) -> int: + """Return the index of the first name that violates ascending order.""" + for i in range(1, len(names)): + if names[i] < names[i - 1]: + return i + return -1 # pragma: no cover - defensive; caller guarantees unsorted input + + +# ================================================================= +# Function entries +# ================================================================= + +def _validate_functions(exp, header, size_of_image, issues): + """ + For each function entry, check: + - ordinal range fits in u16 (single max-ordinal check covers all entries) + - address_rva, when non-zero and non-forwarder, points within image + - forwarder strings, when present, conform to the spec format + """ + base = header.get("Base", 0) + num_funcs = header.get("NumberOfFunctions", 0) + + # Ordinal range sanity: Base + (NumberOfFunctions - 1) must fit in u16. + # Some malware sets Base near 0xFFFF to push ordinals into an invalid + # range while keeping the count plausible. The per-entry ordinal check + # is redundant here. entry.ordinal is computed as Base + index by the + # parser, so any entry-level overflow is implied by the max check. + if num_funcs > 0: + max_ordinal = base + num_funcs - 1 + if max_ordinal > 0xFFFF: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_ORDINAL_OUT_OF_RANGE, + details={"reason": "max_exceeds_u16", + "base": base, + "num_functions": num_funcs, + "max_ordinal": max_ordinal}, + )) + + for entry in exp.get("functions", []) or []: + index = entry.get("index") + ordinal = entry.get("ordinal") + address_rva = entry.get("address_rva") + + if entry.get("is_forwarder"): + forwarder = entry.get("forwarder") + if forwarder is None: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_FORWARDER_MALFORMED, + details={"index": index, "ordinal": ordinal, + "reason": "unreadable"}, + )) + elif not entry.get("forwarder_valid"): + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_FORWARDER_MALFORMED, + details={"index": index, "ordinal": ordinal, + "forwarder": forwarder, "reason": "format"}, + )) + continue + + # An EAT entry of 0 means "this ordinal slot is unused" -> legal per spec + if address_rva is None or address_rva == 0: + continue + + # Otherwise the RVA must point within the image + if size_of_image is not None and address_rva >= size_of_image: + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_FUNCTION_RVA_INVALID, + details={"index": index, "ordinal": ordinal, + "address_rva": address_rva, + "size_of_image": size_of_image, + "reason": "exceeds_image"}, + )) + + +# ================================================================= +# Helpers +# ================================================================= + +def _first_matching(errors: List[str], candidates: List[str]) -> str: + """Return the first error tag from `candidates` that appears in `errors`.""" + for c in candidates: + if c in errors: + return c + return "unknown" From 4b2c27769b88912a6e371d4445d1a7219fa10c3f Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 12:33:30 +0100 Subject: [PATCH 07/35] schema(internal): add precise types for export table structures New TypedDicts in internal_schema: - ExportDirectoryHeader: the 40-byte IMAGE_EXPORT_DIRECTORY fields - ExportFunctionEntry: per-function decoded view with name and forwarder resolution - ExportNamePointerEntry: per-name-pointer view with per-entry errors list - ExportStruct: top-level export struct shape produced by parser_exports InternalMetadata gains export_struct: Optional[ExportStruct]. Mirrors the typing discipline established for VersionInfoStruct and ResourcesStruct in earlier requirement 3 work. No runtime behaviour change. --- iocx/schemas/internal_schema.py | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/iocx/schemas/internal_schema.py b/iocx/schemas/internal_schema.py index 79cb775..36cd7b7 100644 --- a/iocx/schemas/internal_schema.py +++ b/iocx/schemas/internal_schema.py @@ -102,6 +102,54 @@ class DataDirectoryRaw(TypedDict): size: int +# ------------------------ +# Exports schema +# ------------------------ + +class ExportFunctionEntry(TypedDict): + index: int + ordinal: int + address_rva: Optional[int] + is_forwarder: bool + forwarder: Optional[str] + forwarder_valid: bool + name: Optional[str] + name_rva: Optional[int] + + +class ExportNamePointerEntry(TypedDict): + index: int + name_rva: Optional[int] + ordinal_index: Optional[int] + name: Optional[str] + name_valid: bool + errors: List[str] + + +class ExportDirectoryHeader(TypedDict): + Characteristics: int + TimeDateStamp: int + MajorVersion: int + MinorVersion: int + Name: int + Base: int + NumberOfFunctions: int + NumberOfNames: int + AddressOfFunctions: int + AddressOfNames: int + AddressOfNameOrdinals: int + + +class ExportStruct(TypedDict, total=False): + rva: int + size: int + header: Optional[ExportDirectoryHeader] + functions: List[ExportFunctionEntry] + name_pointers: List[ExportNamePointerEntry] + truncations: List[str] + errors: List[str] + + # ------------------------- # Internal metadata schema # ------------------------- @@ -110,5 +158,6 @@ class InternalMetadata(TypedDict, total=False): resources_struct: Optional[ResourcesStruct] version_info_struct: Optional[VersionInfoStruct] data_directories_raw: List[DataDirectoryRaw] + export_struct: Optional[ExportStruct] optional_header_magic: int number_of_rva_and_sizes: int From 52aa2431d3930abd6f4bfcc733bb88934a92c280 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 12:36:23 +0100 Subject: [PATCH 08/35] feat(metadata): wire build_export_structure into internal metadata The export struct is built alongside resources_struct and version_info_struct in the metadata builder. The new validate_exports validator is registered in STRUCTURAL_VALIDATORS between version_info and entropy. No public IOC contract change; export_struct remains in internal metadata pending the scheduled public-contract refresh. --- iocx/engine.py | 2 ++ iocx/reason_codes.py | 16 ++++++++++++++++ iocx/validators/__init__.py | 3 +++ 3 files changed, 21 insertions(+) diff --git a/iocx/engine.py b/iocx/engine.py index a25f755..94a71ed 100644 --- a/iocx/engine.py +++ b/iocx/engine.py @@ -14,6 +14,7 @@ from .parsers.pe_version_info import build_version_info from .parsers.pe_load_config import analyse_load_config from .parsers.pe_optional_header import extract_optional_header_metadata +from .parsers.pe_exports import build_export_structure from .detectors import all_detectors from .models import Detection, PluginContext from .plugins.loader import PluginLoader @@ -164,6 +165,7 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: self._internal_metadata["resources_struct"] = build_resource_structure(pe) self._internal_metadata["version_info_struct"] = build_version_info(pe) + self._internal_metadata["export_struct"] = build_export_structure(pe) self._internal_metadata["data_directories_raw"] = analyse_data_directories_raw(pe) self._internal_metadata.update(extract_optional_header_metadata(pe)) internal: InternalMetadata = self._internal_metadata diff --git a/iocx/reason_codes.py b/iocx/reason_codes.py index a6618ed..4f6879a 100644 --- a/iocx/reason_codes.py +++ b/iocx/reason_codes.py @@ -131,6 +131,22 @@ class ReasonCodes: # SEH table issues LOAD_CONFIG_SEH_INVALID = "load_config_seh_invalid" + # --- Export directory anomalies --- + EXPORT_DIRECTORY_INVALID_HEADER = "export_directory_invalid_header" + EXPORT_DIRECTORY_OUT_OF_BOUNDS = "export_directory_out_of_bounds" + EXPORT_TABLE_TRUNCATED = "export_table_truncated" + + # --- Export name pointer anomalies --- + EXPORT_NAME_RVA_INVALID = "export_name_rva_invalid" + EXPORT_NAME_NOT_ASCII = "export_name_not_ascii" + EXPORT_NAME_POINTER_TABLE_UNSORTED = "export_name_pointer_table_unsorted" + EXPORT_NAME_ORDINAL_INDEX_INVALID = "export_name_ordinal_index_invalid" + + # --- Export function entry anomalies --- + EXPORT_ORDINAL_OUT_OF_RANGE = "export_ordinal_out_of_range" + EXPORT_FUNCTION_RVA_INVALID = "export_function_rva_invalid" + EXPORT_FORWARDER_MALFORMED = "export_forwarder_malformed" + # --- Packer heuristics (interpretation layer) --- PACKER_SECTION_NAME = "packer_section_name" PACKER_HIGH_ENTROPY_SECTION = "high_entropy_section" diff --git a/iocx/validators/__init__.py b/iocx/validators/__init__.py index b7ab6b0..749ddf6 100644 --- a/iocx/validators/__init__.py +++ b/iocx/validators/__init__.py @@ -12,6 +12,7 @@ from .signature import validate_signature from .resources import validate_resources from .version_info import validate_version_info +from .exports import validate_exports from .entropy import validate_entropy STRUCTURAL_VALIDATORS = { @@ -33,6 +34,8 @@ "resources": validate_resources, # Version-info (RT_VERSION leaf within the resource tree) "version_info": validate_version_info, + # Exports + "exports": validate_exports, # Entropy metrics (high entropy sections, overlays, uniform patterns) "entropy": validate_entropy, } From 95834743c7699a4d05c34493e25a1bfa76933677 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 13:34:34 +0100 Subject: [PATCH 09/35] test(exports): 100% coverage on parser_exports and validator_exports Parser tests (~50 cases): - Locator paths: all four exception classes, zero RVA/size, valid extraction - Header decoder: valid, too short, empty, all-zero - Array readers: dword and word, zero count, zero RVA, read failure, short read with partial data, full read - ASCII string reader: zero RVA, read failure, empty, unterminated, valid, empty terminated, non-ASCII fallback, oversized buffer - Name validation: 10 parametrised cases including control chars and Unicode - Full roundtrips: minimal valid, forwarder detection by RVA range, invalid forwarder format, name pointer error classes, truncation propagation through to per-entry None handling, unused EAT slots - Output contract: required keys, list type guarantees - Determinism: identical output across 20 runs on valid and malformed input Validator tests (~40 cases): - Absence and top-level decode short-circuit - Placement: in-bounds, exceeds image, silent fallbacks - Truncations: single, multiple, per-table emission - Header consistency: each sub-reason independently, combined failures, header-none skip - Name pointer validation: clean, every error class, priority resolution for name_rva and name encoding errors, no double-emission - Name pointer ordering: sorted, unsorted, unreadable-entry skip, empty - Function entries: clean, ordinal range, RVA bounds, zero/None RVA, missing size_of_image fallback - Forwarders: valid, unreadable, malformed format, RVA check bypass - Combined anomalies and output contract - Determinism across 20 runs All defensive code paths exercised; one # pragma: no cover applied to _first_unsorted_index's defensive return (unreachable from caller). --- tests/unit/parsers/test_pe_exports.py | 878 ++++++++++++++++++ .../unit/validators/test_validator_exports.py | 699 ++++++++++++++ 2 files changed, 1577 insertions(+) create mode 100644 tests/unit/parsers/test_pe_exports.py create mode 100644 tests/unit/validators/test_validator_exports.py diff --git a/tests/unit/parsers/test_pe_exports.py b/tests/unit/parsers/test_pe_exports.py new file mode 100644 index 0000000..ea1f40d --- /dev/null +++ b/tests/unit/parsers/test_pe_exports.py @@ -0,0 +1,878 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Unit tests for iocx.parsers.parser_exports. + +Strategy: +- Decoder tests build export directory byte buffers directly via helpers. +- Locator and entry-point tests use a minimal duck-typed fake-pe object. +- Determinism tests assert byte-for-byte stable output across repeated runs. +""" + +from __future__ import annotations + +import struct +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +from iocx.parsers.pe_exports import ( + build_export_structure, + _decode_export_directory, + _is_valid_export_name, + _locate_export_directory, + _read_asciiz, + _read_dword_array, + _read_word_array, + _EXPORT_DIRECTORY_SIZE, +) + + +# ================================================================= +# Byte-level builders +# ================================================================= + +def _build_export_directory_header( + characteristics: int = 0, + timedatestamp: int = 0, + major_version: int = 0, + minor_version: int = 0, + name_rva: int = 0, + base: int = 1, + num_functions: int = 0, + num_names: int = 0, + addr_functions: int = 0, + addr_names: int = 0, + addr_name_ordinals: int = 0, +) -> bytes: + """Build the 40-byte IMAGE_EXPORT_DIRECTORY structure.""" + return struct.pack( + " bytes: + return b"".join(struct.pack(" bytes: + return b"".join(struct.pack(" bytes: + return s.encode("ascii") + b"\x00" + + +# ================================================================= +# Fake pe object +# ================================================================= + +class _FakeDataDir: + def __init__(self, rva: int, size: int): + self.VirtualAddress = rva + self.Size = size + + +class _FakeOptHdr: + def __init__(self, export_dir: Optional[_FakeDataDir]): + # Index 0 is IMAGE_DIRECTORY_ENTRY_EXPORT + self.DATA_DIRECTORY = [export_dir] + + +class _FakePE: + """ + Minimal duck-typed pe object exposing only what the parser uses: + OPTIONAL_HEADER.DATA_DIRECTORY[0] and get_data(rva, size). + """ + def __init__( + self, + export_rva: int = 0, + export_size: int = 0, + data_by_rva: Optional[Dict[int, bytes]] = None, + raise_on_get_data: Optional[Exception] = None, + raise_for_rva: Optional[int] = None, + ): + if export_rva == 0 and export_size == 0: + self.OPTIONAL_HEADER = _FakeOptHdr(None) + else: + self.OPTIONAL_HEADER = _FakeOptHdr( + _FakeDataDir(export_rva, export_size) + ) + self._data = data_by_rva or {} + self._raise = raise_on_get_data + self._raise_for_rva = raise_for_rva + + def get_data(self, rva: int, size: int) -> bytes: + if self._raise is not None: + if self._raise_for_rva is None or self._raise_for_rva == rva: + raise self._raise + if rva not in self._data: + raise ValueError(f"no fixture data at rva {rva}") + return self._data[rva][:size] + + +# ================================================================= +# _locate_export_directory +# ================================================================= + +class TestLocator: + + def test_no_data_directory_returns_none(self): + pe = type("FakePE", (), {})() + assert _locate_export_directory(pe) is None + + def test_index_error_returns_none(self): + class _PE: + class OPTIONAL_HEADER: + DATA_DIRECTORY = [] + assert _locate_export_directory(_PE) is None + + def test_attribute_error_returns_none(self): + class _PE: + class OPTIONAL_HEADER: + pass + assert _locate_export_directory(_PE) is None + + def test_zero_rva_returns_none(self): + pe = _FakePE(export_rva=0, export_size=100) + assert _locate_export_directory(pe) is None + + def test_zero_size_returns_none(self): + pe = _FakePE(export_rva=0x1000, export_size=0) + assert _locate_export_directory(pe) is None + + def test_valid_directory_returns_rva_size(self): + pe = _FakePE(export_rva=0x1000, export_size=200) + assert _locate_export_directory(pe) == (0x1000, 200) + + def test_type_error_in_int_cast_returns_none(self): + class _BadDir: + VirtualAddress = "not an int" + Size = 100 + class _PE: + class OPTIONAL_HEADER: + DATA_DIRECTORY = [_BadDir()] + assert _locate_export_directory(_PE) is None + + +# ================================================================= +# _decode_export_directory +# ================================================================= + +class TestDecodeExportDirectory: + + def test_valid_header_decoded(self): + header = _build_export_directory_header( + characteristics=0, + timedatestamp=0xDEADBEEF, + major_version=1, + minor_version=2, + name_rva=0x1000, + base=1, + num_functions=5, + num_names=3, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + out = _decode_export_directory(header) + assert out is not None + assert out["TimeDateStamp"] == 0xDEADBEEF + assert out["MajorVersion"] == 1 + assert out["MinorVersion"] == 2 + assert out["Base"] == 1 + assert out["NumberOfFunctions"] == 5 + assert out["NumberOfNames"] == 3 + assert out["AddressOfFunctions"] == 0x1100 + assert out["AddressOfNames"] == 0x1200 + assert out["AddressOfNameOrdinals"] == 0x1300 + + def test_too_short_returns_none(self): + assert _decode_export_directory(b"\x00" * 39) is None + + def test_empty_returns_none(self): + assert _decode_export_directory(b"") is None + + def test_all_zero_header_decoded(self): + header = b"\x00" * _EXPORT_DIRECTORY_SIZE + out = _decode_export_directory(header) + assert out is not None + assert out["NumberOfFunctions"] == 0 + assert out["Base"] == 0 + + +# ================================================================= +# Array readers +# ================================================================= + +class TestReadDwordArray: + + def test_zero_count_returns_empty_list(self): + truncations = [] + result = _read_dword_array(_FakePE(), 0x1000, 0, truncations, "test") + assert result == [] + assert truncations == [] + + def test_zero_rva_flags_truncation_returns_none_list(self): + truncations = [] + result = _read_dword_array(_FakePE(), 0, 3, truncations, "test") + assert result == [None, None, None] + assert truncations == ["test_rva_zero"] + + def test_read_failure_flags_truncation_returns_none_list(self): + truncations = [] + pe = _FakePE(raise_on_get_data=RuntimeError("read failed")) + result = _read_dword_array(pe, 0x1000, 3, truncations, "test") + assert result == [None, None, None] + assert truncations == ["test_read_failed"] + + def test_short_read_flags_truncation_with_partial_data(self): + truncations = [] + # Caller wants 3 DWORDs (12 bytes) but only 6 bytes available + pe = _FakePE(data_by_rva={0x1000: b"\x01\x00\x00\x00\x02\x00"}) + result = _read_dword_array(pe, 0x1000, 3, truncations, "test") + assert result[0] == 1 + assert result[1] is None + assert result[2] is None + assert truncations == ["test_truncated"] + + def test_full_read_no_truncation(self): + truncations = [] + pe = _FakePE(data_by_rva={ + 0x1000: _pack_dwords([0x11, 0x22, 0x33]), + }) + result = _read_dword_array(pe, 0x1000, 3, truncations, "test") + assert result == [0x11, 0x22, 0x33] + assert truncations == [] + + +class TestReadWordArray: + + def test_zero_count_returns_empty(self): + truncations = [] + result = _read_word_array(_FakePE(), 0x1000, 0, truncations, "test") + assert result == [] + + def test_zero_rva_flags_truncation(self): + truncations = [] + result = _read_word_array(_FakePE(), 0, 2, truncations, "test") + assert result == [None, None] + assert truncations == ["test_rva_zero"] + + def test_full_read(self): + truncations = [] + pe = _FakePE(data_by_rva={0x1000: _pack_words([1, 2, 3])}) + result = _read_word_array(pe, 0x1000, 3, truncations, "test") + assert result == [1, 2, 3] + + def test_short_read_partial_data(self): + truncations = [] + pe = _FakePE(data_by_rva={0x1000: b"\x01\x00"}) # 1 WORD only + result = _read_word_array(pe, 0x1000, 3, truncations, "test") + assert result[0] == 1 + assert result[1] is None + assert result[2] is None + assert truncations == ["test_truncated"] + + def test_read_failure(self): + truncations = [] + pe = _FakePE(raise_on_get_data=RuntimeError("nope")) + result = _read_word_array(pe, 0x1000, 2, truncations, "test") + assert result == [None, None] + assert truncations == ["test_read_failed"] + + +# ================================================================= +# _read_asciiz +# ================================================================= + +class TestReadAsciiz: + + def test_zero_rva_returns_error(self): + s, err = _read_asciiz(_FakePE(), 0, 100) + assert s is None + assert err == "rva_zero" + + def test_read_failure_returns_error(self): + pe = _FakePE(raise_on_get_data=RuntimeError("nope")) + s, err = _read_asciiz(pe, 0x1000, 100) + assert s is None + assert err == "read_failed" + + def test_empty_read_returns_error(self): + pe = _FakePE(data_by_rva={0x1000: b""}) + s, err = _read_asciiz(pe, 0x1000, 100) + assert s is None + assert err == "empty_read" + + def test_unterminated_returns_error(self): + pe = _FakePE(data_by_rva={0x1000: b"ABCDEF"}) # no NUL + s, err = _read_asciiz(pe, 0x1000, 6) + assert s is None + assert err == "unterminated" + + def test_valid_ascii(self): + pe = _FakePE(data_by_rva={0x1000: _asciiz("hello")}) + s, err = _read_asciiz(pe, 0x1000, 100) + assert s == "hello" + assert err is None + + def test_empty_string_with_terminator(self): + pe = _FakePE(data_by_rva={0x1000: b"\x00"}) + s, err = _read_asciiz(pe, 0x1000, 100) + assert s == "" + assert err is None + + def test_non_ascii_returns_flag_with_replacement(self): + pe = _FakePE(data_by_rva={0x1000: b"caf\xc3\xa9\x00"}) # UTF-8 café + s, err = _read_asciiz(pe, 0x1000, 100) + assert err == "non_ascii" + assert s is not None + assert "?" in s or "\ufffd" in s.encode("utf-8").decode("utf-8", errors="replace") + + def test_string_within_oversized_buffer(self): + pe = _FakePE(data_by_rva={0x1000: _asciiz("X") + b"\xff" * 100}) + s, err = _read_asciiz(pe, 0x1000, 200) + assert s == "X" + assert err is None + + +# ================================================================= +# _is_valid_export_name +# ================================================================= + +class TestIsValidExportName: + + @pytest.mark.parametrize("name,expected", [ + ("CreateFileW", True), + ("_imp__foo", True), + ("@ordinal_2", True), + ("Foo Bar", True), # space is 0x20, allowed + ("!@#$%^&*()", True), # all printable + ("", False), + ("Foo\x01Bar", False), + ("Foo\x7fBar", False), + ("Foo\x00Bar", False), + ("café", False), # non-ASCII + ]) + def test_validation(self, name, expected): + assert _is_valid_export_name(name) is expected + + +# ================================================================= +# build_export_structure — full roundtrips +# ================================================================= + +class TestBuildExportStructure: + + def test_no_export_directory_returns_none(self): + pe = _FakePE(export_rva=0, export_size=0) + assert build_export_structure(pe) is None + + def test_header_read_failure_returns_empty_result(self): + pe = _FakePE( + export_rva=0x1000, + export_size=100, + raise_on_get_data=RuntimeError("simulated"), + ) + result = build_export_structure(pe) + assert result is not None + assert result["header"] is None + assert "header_read_failed" in result["errors"] + + def test_short_header_flags_truncation(self): + pe = _FakePE( + export_rva=0x1000, + export_size=100, + data_by_rva={0x1000: b"\x00" * 20}, # less than 40 bytes + ) + result = build_export_structure(pe) + assert result["header"] is None + assert "export_directory_header" in result["truncations"] + + def test_unparseable_header(self, monkeypatch): + """Force _decode_export_directory to return None even with sufficient bytes.""" + import iocx.parsers.pe_exports as pep + + pe = _FakePE( + export_rva=0x1000, + export_size=100, + data_by_rva={0x1000: b"\x00" * _EXPORT_DIRECTORY_SIZE}, + ) + monkeypatch.setattr(pep, "_decode_export_directory", lambda buf: None) + result = build_export_structure(pe) + assert result["header"] is None + assert "header_unpack_failed" in result["errors"] + + def test_minimal_valid_export_table_with_one_named_function(self): + # Layout: + # Header at 0x1000 (40 bytes) + # EAT at 0x1100 (1 dword, points to function at 0x2000) + # ENPT at 0x1200 (1 dword, points to name string) + # EOT at 0x1300 (1 word, EAT index 0) + # Name string at 0x1400 ("Foo\0") + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000]), + 0x1200: _pack_dwords([0x1400]), + 0x1300: _pack_words([0]), + 0x1400: _asciiz("Foo"), + }, + ) + result = build_export_structure(pe) + + assert result is not None + assert result["rva"] == 0x1000 + assert result["size"] == 200 + assert result["truncations"] == [] + assert result["errors"] == [] + assert len(result["functions"]) == 1 + assert len(result["name_pointers"]) == 1 + + fn = result["functions"][0] + assert fn["index"] == 0 + assert fn["ordinal"] == 1 + assert fn["address_rva"] == 0x2000 + assert fn["is_forwarder"] is False + assert fn["name"] == "Foo" + + np = result["name_pointers"][0] + assert np["index"] == 0 + assert np["name_rva"] == 0x1400 + assert np["ordinal_index"] == 0 + assert np["name"] == "Foo" + assert np["name_valid"] is True + assert np["errors"] == [] + + def test_forwarder_detected_when_address_points_within_directory(self): + # Address RVA falls within the export directory range, + # indicating a forwarder per PE spec. + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=0, + addr_functions=0x1100, + ) + forwarder_rva = 0x1050 # within [0x1000, 0x1000 + 200) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([forwarder_rva]), + forwarder_rva: _asciiz("KERNEL32.LoadLibraryA"), + }, + ) + result = build_export_structure(pe) + fn = result["functions"][0] + assert fn["is_forwarder"] is True + assert fn["forwarder"] == "KERNEL32.LoadLibraryA" + assert fn["forwarder_valid"] is True + assert fn["address_rva"] == forwarder_rva + + def test_invalid_forwarder_format_flagged(self): + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=0, + addr_functions=0x1100, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x1050]), + 0x1050: _asciiz("NoDotInThisString"), + }, + ) + result = build_export_structure(pe) + fn = result["functions"][0] + assert fn["is_forwarder"] is True + assert fn["forwarder"] == "NoDotInThisString" + assert fn["forwarder_valid"] is False + + def test_forwarder_with_ordinal_syntax_valid(self): + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=0, + addr_functions=0x1100, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x1050]), + 0x1050: _asciiz("KERNEL32.#42"), + }, + ) + result = build_export_structure(pe) + assert result["functions"][0]["forwarder_valid"] is True + + def test_name_pointer_with_zero_rva_flagged(self): + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000]), + 0x1200: _pack_dwords([0]), # name_rva = 0 + 0x1300: _pack_words([0]), + }, + ) + result = build_export_structure(pe) + np = result["name_pointers"][0] + assert "name_rva_zero" in np["errors"] + assert np["name"] is None + + def test_name_pointer_with_unterminated_string_flagged(self): + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000]), + 0x1200: _pack_dwords([0x1400]), + 0x1300: _pack_words([0]), + 0x1400: b"NoTerminator" * 100, # no NUL within max scan + }, + ) + result = build_export_structure(pe) + np = result["name_pointers"][0] + assert "unterminated" in np["errors"] + + def test_name_pointer_with_non_printable_name_flagged(self): + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000]), + 0x1200: _pack_dwords([0x1400]), + 0x1300: _pack_words([0]), + 0x1400: b"Foo\x01Bar\x00", # contains control char + }, + ) + result = build_export_structure(pe) + np = result["name_pointers"][0] + assert "name_not_printable_ascii" in np["errors"] + assert np["name_valid"] is False + + def test_ordinal_index_out_of_range_flagged(self): + header = _build_export_directory_header( + base=1, + num_functions=1, # only 1 function + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000]), + 0x1200: _pack_dwords([0x1400]), + 0x1300: _pack_words([5]), # ordinal_index=5, but only 1 function + 0x1400: _asciiz("Foo"), + }, + ) + result = build_export_structure(pe) + np = result["name_pointers"][0] + assert "ordinal_index_out_of_range" in np["errors"] + + def test_eat_truncation_propagates(self): + # Declare 5 functions but only provide bytes for 2 + header = _build_export_directory_header( + base=1, + num_functions=5, + num_names=0, + addr_functions=0x1100, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000, 0x2100]), # only 2 of 5 + }, + ) + result = build_export_structure(pe) + assert "eat_truncated" in result["truncations"] + assert len(result["functions"]) == 5 + assert result["functions"][0]["address_rva"] == 0x2000 + assert result["functions"][2]["address_rva"] is None + + def test_unused_eat_slots_legitimate(self): + # EAT entry of 0 is "this ordinal slot unused" per PE spec + header = _build_export_directory_header( + base=1, + num_functions=3, + num_names=0, + addr_functions=0x1100, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000, 0, 0x2200]), + }, + ) + result = build_export_structure(pe) + assert result["functions"][0]["address_rva"] == 0x2000 + assert result["functions"][1]["address_rva"] == 0 + assert result["functions"][2]["address_rva"] == 0x2200 + + def test_name_resolution_joins_eat_with_enpt(self): + # Function at EAT index 2 has a name; functions 0, 1 don't. + header = _build_export_directory_header( + base=1, + num_functions=3, + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000, 0x2100, 0x2200]), + 0x1200: _pack_dwords([0x1400]), + 0x1300: _pack_words([2]), # name points to EAT index 2 + 0x1400: _asciiz("NamedFunc"), + }, + ) + result = build_export_structure(pe) + assert result["functions"][0]["name"] is None + assert result["functions"][1]["name"] is None + assert result["functions"][2]["name"] == "NamedFunc" + + def test_empty_export_table(self): + # Header declares zero functions and zero names + header = _build_export_directory_header(base=1) + pe = _FakePE( + export_rva=0x1000, + export_size=100, + data_by_rva={0x1000: header}, + ) + result = build_export_structure(pe) + assert result["functions"] == [] + assert result["name_pointers"] == [] + assert result["truncations"] == [] + + def test_name_pointer_with_truncated_enpt_flags_name_rva_missing(self): + """ + Cover line 350: when ENPT is truncated, _build_name_pointers consumes + a None from enpt and tags name_rva_missing. + """ + header = _build_export_directory_header( + base=1, + num_functions=1, + num_names=3, # declared 3 names + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000]), + 0x1200: _pack_dwords([0x1400]), # only 1 of 3 DWORDs + 0x1300: _pack_words([0, 0, 0]), # EOT is full + 0x1400: _asciiz("Foo"), + }, + ) + result = build_export_structure(pe) + + # Truncation should be flagged on the ENPT + assert "enpt_truncated" in result["truncations"] + + # Entries 1 and 2 should carry name_rva_missing (their enpt[i] is None) + assert "name_rva_missing" in result["name_pointers"][1]["errors"] + assert "name_rva_missing" in result["name_pointers"][2]["errors"] + + # Entry 0 is well-formed + assert result["name_pointers"][0]["name"] == "Foo" + + def test_name_pointer_with_truncated_eot_flags_ordinal_index_missing(self): + """ + Cover line 364: when EOT is truncated, _build_name_pointers consumes + a None from eot and tags ordinal_index_missing. + """ + header = _build_export_directory_header( + base=1, + num_functions=3, + num_names=3, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva={ + 0x1000: header, + 0x1100: _pack_dwords([0x2000, 0x2100, 0x2200]), + 0x1200: _pack_dwords([0x1400, 0x1404, 0x1408]), + 0x1300: _pack_words([0]), # only 1 of 3 WORDs + 0x1400: _asciiz("Foo"), + 0x1404: _asciiz("Bar"), + 0x1408: _asciiz("Baz"), + }, + ) + result = build_export_structure(pe) + + assert "eot_truncated" in result["truncations"] + assert "ordinal_index_missing" in result["name_pointers"][1]["errors"] + assert "ordinal_index_missing" in result["name_pointers"][2]["errors"] + assert result["name_pointers"][0]["ordinal_index"] == 0 + + +# ================================================================= +# Output contract +# ================================================================= + +class TestOutputContract: + + REQUIRED_KEYS = { + "rva", "size", "header", "functions", "name_pointers", + "truncations", "errors", + } + + def test_successful_decode_has_all_required_keys(self): + header = _build_export_directory_header(base=1) + pe = _FakePE( + export_rva=0x1000, + export_size=100, + data_by_rva={0x1000: header}, + ) + result = build_export_structure(pe) + assert self.REQUIRED_KEYS.issubset(result.keys()) + + def test_lists_are_lists_even_when_empty(self): + header = _build_export_directory_header(base=1) + pe = _FakePE( + export_rva=0x1000, + export_size=100, + data_by_rva={0x1000: header}, + ) + result = build_export_structure(pe) + assert isinstance(result["functions"], list) + assert isinstance(result["name_pointers"], list) + assert isinstance(result["truncations"], list) + assert isinstance(result["errors"], list) + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + + def test_repeated_parse_produces_identical_output(self): + header = _build_export_directory_header( + base=1, + num_functions=2, + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + data = { + 0x1000: header, + 0x1100: _pack_dwords([0x2000, 0x2100]), + 0x1200: _pack_dwords([0x1400]), + 0x1300: _pack_words([0]), + 0x1400: _asciiz("Foo"), + } + + results = [] + for _ in range(20): + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva=dict(data), + ) + results.append(build_export_structure(pe)) + + for r in results[1:]: + assert r == results[0] + + def test_malformed_input_deterministic(self): + # Truncated EAT, non-ASCII name, out-of-range ordinal + header = _build_export_directory_header( + base=1, + num_functions=5, + num_names=1, + addr_functions=0x1100, + addr_names=0x1200, + addr_name_ordinals=0x1300, + ) + data = { + 0x1000: header, + 0x1100: _pack_dwords([0x2000]), # only 1 of 5 + 0x1200: _pack_dwords([0x1400]), + 0x1300: _pack_words([99]), + 0x1400: b"caf\xc3\xa9\x00", + } + + results = [] + for _ in range(20): + pe = _FakePE( + export_rva=0x1000, + export_size=200, + data_by_rva=dict(data), + ) + results.append(build_export_structure(pe)) + + for r in results[1:]: + assert r == results[0] diff --git a/tests/unit/validators/test_validator_exports.py b/tests/unit/validators/test_validator_exports.py new file mode 100644 index 0000000..4b4daa7 --- /dev/null +++ b/tests/unit/validators/test_validator_exports.py @@ -0,0 +1,699 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Unit tests for iocx.validators.exports.validate_exports. + +Strategy: +- Input is the export_struct dict produced by parser_exports. +- Build dicts directly; isolate validator logic from parser behaviour. +- Each test asserts on the set of REASONCODES emitted and the details + payload. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import pytest + +from iocx.reason_codes import ReasonCodes +from iocx.validators.exports import validate_exports + + +# ================================================================= +# Input builders +# ================================================================= + +_NOT_PROVIDED = object() + + +def _make_analysis(size_of_image: Optional[int] = 0x100000) -> Dict[str, Any]: + return {"size_of_image": size_of_image} + + +def _make_header( + base: int = 1, + num_functions: int = 0, + num_names: int = 0, + addr_functions: int = 0x1100, + addr_names: int = 0x1200, + addr_name_ordinals: int = 0x1300, + **overrides, +) -> Dict[str, int]: + h = { + "Characteristics": 0, + "TimeDateStamp": 0, + "MajorVersion": 0, + "MinorVersion": 0, + "Name": 0x1000, + "Base": base, + "NumberOfFunctions": num_functions, + "NumberOfNames": num_names, + "AddressOfFunctions": addr_functions if num_functions else 0, + "AddressOfNames": addr_names if num_names else 0, + "AddressOfNameOrdinals": addr_name_ordinals if num_names else 0, + } + h.update(overrides) + return h + + +def _make_function( + index: int = 0, + ordinal: int = 1, + address_rva: Optional[int] = 0x2000, + is_forwarder: bool = False, + forwarder: Optional[str] = None, + forwarder_valid: bool = False, + name: Optional[str] = None, + name_rva: Optional[int] = None, +) -> Dict[str, Any]: + return { + "index": index, + "ordinal": ordinal, + "address_rva": address_rva, + "is_forwarder": is_forwarder, + "forwarder": forwarder, + "forwarder_valid": forwarder_valid, + "name": name, + "name_rva": name_rva, + } + + +def _make_name_pointer( + index: int = 0, + name_rva: Optional[int] = 0x1400, + ordinal_index: Optional[int] = 0, + name: Optional[str] = "Foo", + name_valid: bool = True, + errors: Optional[List[str]] = None, +) -> Dict[str, Any]: + return { + "index": index, + "name_rva": name_rva, + "ordinal_index": ordinal_index, + "name": name, + "name_valid": name_valid, + "errors": errors or [], + } + + +def _make_exp( + rva: int = 0x1000, + size: int = 200, + header: Any = _NOT_PROVIDED, + functions: Optional[List[Dict[str, Any]]] = None, + name_pointers: Optional[List[Dict[str, Any]]] = None, + truncations: Optional[List[str]] = None, + errors: Optional[List[str]] = None, +) -> Dict[str, Any]: + return { + "rva": rva, + "size": size, + "header": _make_header() if header is _NOT_PROVIDED else header, + "functions": functions or [], + "name_pointers": name_pointers or [], + "truncations": truncations or [], + "errors": errors or [], + } + + +def _codes(issues) -> List[Any]: + return [i["issue"] for i in issues] + + +def _details_for(issues, code) -> List[Dict[str, Any]]: + return [i["details"] for i in issues if i["issue"] == code] + + +# ================================================================= +# Absence +# ================================================================= + +class TestAbsence: + + def test_no_export_struct_returns_no_issues(self): + assert validate_exports({}, _make_analysis()) == [] + + def test_explicit_none_returns_no_issues(self): + assert validate_exports({"export_struct": None}, _make_analysis()) == [] + + +# ================================================================= +# Top-level decode short-circuit +# ================================================================= + +class TestTopLevelDecodeFailure: + + def test_errors_present_emits_invalid_returns_early(self): + exp = _make_exp(errors=["header_read_failed"]) + # Add other issues that should NOT be emitted due to early return + exp["truncations"] = ["eat_truncated"] + issues = validate_exports({"export_struct": exp}, _make_analysis()) + codes = _codes(issues) + assert ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER in codes + assert ReasonCodes.EXPORT_TABLE_TRUNCATED not in codes + details = _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER) + assert details[0]["reason"] == "top_level_decode" + assert details[0]["errors"] == ["header_read_failed"] + + +# ================================================================= +# Placement +# ================================================================= + +class TestPlacement: + + def test_in_bounds_no_issue(self): + exp = _make_exp(rva=0x1000, size=200) + analysis = _make_analysis(size_of_image=0x100000) + issues = validate_exports({"export_struct": exp}, analysis) + assert ReasonCodes.EXPORT_DIRECTORY_OUT_OF_BOUNDS not in _codes(issues) + + def test_extends_past_image_flagged(self): + exp = _make_exp(rva=0xFFF00, size=0x200) + analysis = _make_analysis(size_of_image=0x100000) + issues = validate_exports({"export_struct": exp}, analysis) + details = _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_OUT_OF_BOUNDS) + assert len(details) == 1 + assert details[0]["rva"] == 0xFFF00 + + def test_silent_when_size_of_image_missing(self): + exp = _make_exp(rva=0xFFF00, size=0x200) + analysis = _make_analysis(size_of_image=None) + issues = validate_exports({"export_struct": exp}, analysis) + assert ReasonCodes.EXPORT_DIRECTORY_OUT_OF_BOUNDS not in _codes(issues) + + def test_silent_when_rva_none(self): + exp = _make_exp(rva=None, size=0) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_DIRECTORY_OUT_OF_BOUNDS not in _codes(issues) + + +# ================================================================= +# Truncations +# ================================================================= + +class TestTruncations: + + def test_no_truncations_no_issues(self): + exp = _make_exp(truncations=[]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_TABLE_TRUNCATED not in _codes(issues) + + def test_single_truncation_emits_one_issue(self): + exp = _make_exp(truncations=["eat_truncated"]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_TABLE_TRUNCATED) + assert len(details) == 1 + assert details[0]["table"] == "eat_truncated" + + def test_multiple_truncations_emit_separate_issues(self): + exp = _make_exp(truncations=["eat_truncated", "enpt_truncated", "eot_truncated"]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_TABLE_TRUNCATED) + assert len(details) == 3 + tables = [d["table"] for d in details] + assert set(tables) == {"eat_truncated", "enpt_truncated", "eot_truncated"} + + +# ================================================================= +# Header consistency +# ================================================================= + +class TestHeaderConsistency: + + def test_clean_header_no_issues(self): + header = _make_header(num_functions=2, num_names=2) + exp = _make_exp(header=header) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER not in _codes(issues) + + def test_eat_rva_zero_with_nonzero_count_flagged(self): + header = _make_header(num_functions=5, addr_functions=0) + exp = _make_exp(header=header) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER) + reasons = [d["reason"] for d in details] + assert "eat_rva_zero_with_nonzero_count" in reasons + + def test_enpt_rva_zero_with_nonzero_count_flagged(self): + header = _make_header(num_functions=5, num_names=3, addr_names=0) + exp = _make_exp(header=header) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER) + reasons = [d["reason"] for d in details] + assert "enpt_rva_zero_with_nonzero_count" in reasons + + def test_eot_rva_zero_with_nonzero_count_flagged(self): + header = _make_header(num_functions=5, num_names=3, addr_name_ordinals=0) + exp = _make_exp(header=header) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER) + reasons = [d["reason"] for d in details] + assert "eot_rva_zero_with_nonzero_count" in reasons + + def test_num_names_exceeds_num_functions_flagged(self): + header = _make_header(num_functions=2, num_names=5) + exp = _make_exp(header=header) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER) + reasons = [d["reason"] for d in details] + assert "num_names_exceeds_num_functions" in reasons + + def test_multiple_consistency_failures_emit_multiple_issues(self): + header = _make_header( + num_functions=5, + num_names=10, # > num_functions + addr_functions=0, # zero with count + ) + exp = _make_exp(header=header) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER) + assert len(details) >= 2 + + def test_header_none_skips_consistency_check(self): + exp = _make_exp(header=None, truncations=["export_directory_header"]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + # Should emit truncation but not consistency issues + assert ReasonCodes.EXPORT_TABLE_TRUNCATED in _codes(issues) + consistency = [ + d for d in _details_for(issues, ReasonCodes.EXPORT_DIRECTORY_INVALID_HEADER) + if d.get("reason") in {"eat_rva_zero_with_nonzero_count", + "enpt_rva_zero_with_nonzero_count", + "eot_rva_zero_with_nonzero_count", + "num_names_exceeds_num_functions"} + ] + assert consistency == [] + + +# ================================================================= +# Name pointer validation +# ================================================================= + +class TestNamePointers: + + def test_clean_name_pointer_no_issues(self): + np = _make_name_pointer() + exp = _make_exp( + header=_make_header(num_functions=1, num_names=1), + name_pointers=[np], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_NAME_RVA_INVALID not in _codes(issues) + assert ReasonCodes.EXPORT_NAME_NOT_ASCII not in _codes(issues) + assert ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID not in _codes(issues) + + def test_name_rva_zero_flagged(self): + np = _make_name_pointer(errors=["name_rva_zero"]) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_RVA_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "name_rva_zero" + + def test_name_rva_priority_resolution(self): + """name_rva_missing wins over read_failed when both are present.""" + np = _make_name_pointer(errors=["read_failed", "name_rva_missing"]) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_RVA_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "name_rva_missing" + + def test_unterminated_flagged_with_correct_reason(self): + np = _make_name_pointer(errors=["unterminated"]) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_RVA_INVALID) + assert details[0]["reason"] == "unterminated" + + def test_non_ascii_flagged_with_correct_reason(self): + np = _make_name_pointer(name="caf\ufffd", errors=["non_ascii"]) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_NOT_ASCII) + assert len(details) == 1 + assert details[0]["reason"] == "non_ascii" + + def test_name_not_printable_ascii_flagged(self): + np = _make_name_pointer( + name="Foo\x01Bar", + name_valid=False, + errors=["name_not_printable_ascii"], + ) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_NOT_ASCII) + assert len(details) == 1 + assert details[0]["reason"] == "name_not_printable_ascii" + + def test_name_encoding_priority_resolution(self): + """non_ascii wins over name_not_printable_ascii.""" + np = _make_name_pointer( + errors=["name_not_printable_ascii", "non_ascii"], + ) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_NOT_ASCII) + assert len(details) == 1 + assert details[0]["reason"] == "non_ascii" + + def test_ordinal_index_missing_flagged(self): + np = _make_name_pointer(errors=["ordinal_index_missing"]) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "missing" + + def test_ordinal_index_out_of_range_flagged(self): + np = _make_name_pointer( + ordinal_index=99, + errors=["ordinal_index_out_of_range"], + ) + exp = _make_exp( + header=_make_header(num_functions=5, num_names=1), + name_pointers=[np], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "out_of_range" + assert details[0]["ordinal_index"] == 99 + assert details[0]["num_functions"] == 5 + + def test_no_double_emission_per_entry(self): + """An entry with multiple RVA-class errors emits only one issue.""" + np = _make_name_pointer( + errors=["name_rva_zero", "name_rva_missing", "read_failed"], + ) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + rva_issues = [ + i for i in issues + if i["issue"] == ReasonCodes.EXPORT_NAME_RVA_INVALID + ] + assert len(rva_issues) == 1 + + +# ================================================================= +# Name pointer ordering +# ================================================================= + +class TestNamePointerOrdering: + + def test_sorted_names_no_issue(self): + nps = [ + _make_name_pointer(index=0, name="Alpha"), + _make_name_pointer(index=1, name="Beta"), + _make_name_pointer(index=2, name="Gamma"), + ] + exp = _make_exp( + header=_make_header(num_functions=3, num_names=3), + name_pointers=nps, + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_NAME_POINTER_TABLE_UNSORTED not in _codes(issues) + + def test_unsorted_names_flagged(self): + nps = [ + _make_name_pointer(index=0, name="Zeta"), + _make_name_pointer(index=1, name="Alpha"), + ] + exp = _make_exp( + header=_make_header(num_functions=2, num_names=2), + name_pointers=nps, + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_NAME_POINTER_TABLE_UNSORTED) + assert len(details) == 1 + assert details[0]["name_count"] == 2 + assert details[0]["first_violation_index"] == 1 + + def test_unreadable_name_skips_ordering_check(self): + nps = [ + _make_name_pointer(index=0, name=None, name_valid=False), + _make_name_pointer(index=1, name="Zeta"), + ] + exp = _make_exp(name_pointers=nps) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_NAME_POINTER_TABLE_UNSORTED not in _codes(issues) + + def test_empty_name_pointers_no_issue(self): + exp = _make_exp(name_pointers=[]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_NAME_POINTER_TABLE_UNSORTED not in _codes(issues) + + +# ================================================================= +# Function entries +# ================================================================= + +class TestFunctions: + + def test_clean_function_no_issues(self): + fn = _make_function(address_rva=0x2000) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_FUNCTION_RVA_INVALID not in _codes(issues) + + def test_max_ordinal_exceeds_u16_flagged(self): + header = _make_header(base=0xFFF0, num_functions=32) + exp = _make_exp(header=header, functions=[]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_ORDINAL_OUT_OF_RANGE) + assert len(details) == 1 + assert details[0]["reason"] == "max_exceeds_u16" + assert details[0]["base"] == 0xFFF0 + assert details[0]["max_ordinal"] == 0xFFF0 + 31 + + def test_max_ordinal_in_range_no_issue(self): + header = _make_header(base=1, num_functions=100) + exp = _make_exp(header=header) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_ORDINAL_OUT_OF_RANGE not in _codes(issues) + + def test_function_rva_within_image_no_issue(self): + fn = _make_function(address_rva=0x5000) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports( + {"export_struct": exp}, + _make_analysis(size_of_image=0x100000), + ) + assert ReasonCodes.EXPORT_FUNCTION_RVA_INVALID not in _codes(issues) + + def test_function_rva_beyond_image_flagged(self): + fn = _make_function(address_rva=0x200000) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports( + {"export_struct": exp}, + _make_analysis(size_of_image=0x100000), + ) + details = _details_for(issues, ReasonCodes.EXPORT_FUNCTION_RVA_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "exceeds_image" + assert details[0]["address_rva"] == 0x200000 + + def test_zero_rva_function_skipped(self): + """An EAT entry of 0 is 'unused slot' — not flagged.""" + fn = _make_function(address_rva=0) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_FUNCTION_RVA_INVALID not in _codes(issues) + + def test_none_rva_function_skipped(self): + fn = _make_function(address_rva=None) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_FUNCTION_RVA_INVALID not in _codes(issues) + + def test_function_rva_check_silent_when_size_of_image_missing(self): + fn = _make_function(address_rva=0x200000) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports( + {"export_struct": exp}, + _make_analysis(size_of_image=None), + ) + assert ReasonCodes.EXPORT_FUNCTION_RVA_INVALID not in _codes(issues) + + +# ================================================================= +# Forwarders +# ================================================================= + +class TestForwarders: + + def test_valid_forwarder_no_issue(self): + fn = _make_function( + address_rva=0x1050, + is_forwarder=True, + forwarder="KERNEL32.LoadLibraryA", + forwarder_valid=True, + ) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_FORWARDER_MALFORMED not in _codes(issues) + + def test_unreadable_forwarder_flagged(self): + fn = _make_function( + address_rva=0x1050, + is_forwarder=True, + forwarder=None, + forwarder_valid=False, + ) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_FORWARDER_MALFORMED) + assert len(details) == 1 + assert details[0]["reason"] == "unreadable" + + def test_malformed_forwarder_format_flagged(self): + fn = _make_function( + address_rva=0x1050, + is_forwarder=True, + forwarder="NoDotInThisString", + forwarder_valid=False, + ) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + details = _details_for(issues, ReasonCodes.EXPORT_FORWARDER_MALFORMED) + assert len(details) == 1 + assert details[0]["reason"] == "format" + assert details[0]["forwarder"] == "NoDotInThisString" + + def test_forwarder_skips_function_rva_check(self): + """A forwarder's address RVA points into the export directory; it + should not also trigger EXPORT_FUNCTION_RVA_INVALID.""" + fn = _make_function( + address_rva=0x1050, + is_forwarder=True, + forwarder="KERNEL32.X", + forwarder_valid=True, + ) + exp = _make_exp( + header=_make_header(num_functions=1), + functions=[fn], + ) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + assert ReasonCodes.EXPORT_FUNCTION_RVA_INVALID not in _codes(issues) + + +# ================================================================= +# Combined scenarios +# ================================================================= + +class TestCombinedAnomalies: + + def test_multiple_pathology_classes_emit_independently(self): + # Bad placement, truncated EAT, bad name pointer + exp = _make_exp( + rva=0xFFF00, + size=0x200, + truncations=["eat_truncated"], + name_pointers=[ + _make_name_pointer(errors=["name_rva_zero"]), + ], + ) + analysis = _make_analysis(size_of_image=0x100000) + issues = validate_exports({"export_struct": exp}, analysis) + codes = set(_codes(issues)) + assert ReasonCodes.EXPORT_DIRECTORY_OUT_OF_BOUNDS in codes + assert ReasonCodes.EXPORT_TABLE_TRUNCATED in codes + assert ReasonCodes.EXPORT_NAME_RVA_INVALID in codes + + +# ================================================================= +# Output contract +# ================================================================= + +class TestOutputContract: + + def test_returns_list(self): + result = validate_exports({"export_struct": _make_exp()}, _make_analysis()) + assert isinstance(result, list) + + def test_clean_exports_return_empty_list(self): + result = validate_exports({"export_struct": _make_exp()}, _make_analysis()) + assert result == [] + + def test_each_issue_has_issue_and_details(self): + np = _make_name_pointer(errors=["name_rva_zero"]) + exp = _make_exp(name_pointers=[np]) + issues = validate_exports({"export_struct": exp}, _make_analysis()) + for issue in issues: + assert "issue" in issue + assert "details" in issue + assert isinstance(issue["details"], dict) + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + + def test_repeated_validation_produces_identical_issues(self): + np = _make_name_pointer( + name=None, + name_valid=False, + errors=["read_failed", "name_rva_missing"], + ) + fn = _make_function( + is_forwarder=True, + forwarder="BAD", + forwarder_valid=False, + ) + exp = _make_exp( + truncations=["eat_truncated", "eot_truncated"], + name_pointers=[np], + functions=[fn], + ) + metadata = {"export_struct": exp} + analysis = _make_analysis() + + results = [validate_exports(metadata, analysis) for _ in range(20)] + for r in results[1:]: + assert r == results[0] + + def test_priority_resolution_deterministic(self): + np = _make_name_pointer( + errors=["read_failed", "name_rva_zero", "name_rva_missing", "unterminated"], + ) + exp = _make_exp(name_pointers=[np]) + results = [ + validate_exports({"export_struct": exp}, _make_analysis()) + for _ in range(20) + ] + for r in results[1:]: + assert r == results[0] + # Confirm priority winner + details = _details_for(results[0], ReasonCodes.EXPORT_NAME_RVA_INVALID) + assert details[0]["reason"] == "name_rva_missing" From 946e3ac70c87eeed937912cb0bb949378b7acabc Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 13:46:49 +0100 Subject: [PATCH 10/35] docs(reason_codes): document requirement 2 export reason codes - Extend the reason-codes reference with a new top-level section EXPORT ANOMALIES, structured into three subsections: Directory, Name Pointer, and Function Entry. - Add a dedicated EXPORT SUB-REASONS section documenting the details[reason] taxonomy for each code that carries one. The sub-reason list is treated as part of the public contract. - Add validator documentation section 2.6 for the exports validator, including an explicit determinism rationale paragraph matching the section 2.11 pattern. No code changes. --- docs/specs/reason-codes.md | 93 +++++++++++++++++++ ...ral-validation-deterministic-heuristics.md | 23 +++++ 2 files changed, 116 insertions(+) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 45e59c4..52e55cd 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -175,6 +175,99 @@ --- +## EXPORT ANOMALIES + +### Export Directory Anomalies +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **EXPORT_DIRECTORY_INVALID_HEADER** | The 40‑byte IMAGE_EXPORT_DIRECTORY header could not be decoded, or its declared counts and array RVAs are mutually inconsistent (e.g., NumberOfFunctions > 0 but AddressOfFunctions == 0, or NumberOfNames > NumberOfFunctions) | NumberOfNames = 50, NumberOfFunctions = 20 | Per‑file +| **EXPORT_DIRECTORY_OUT_OF_BOUNDS** | The export directory's declared (rva, size) extends past SizeOfImage | Directory RVA = 0x1F0000, size = 0x1000, SizeOfImage = 0x1F0500 | Per‑file +| **EXPORT_TABLE_TRUNCATED** | One of the export sub‑tables (EAT, ENPT, EOT) declares more entries than the file physically contains, or pe.get_data failed to read the declared extent | NumberOfFunctions = 1000, EAT physical extent only covers 50 entries | Per‑file + +### Export Name Pointer Anomalies + +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **EXPORT_NAME_RVA_INVALID** | A name pointer entry's RVA is zero, missing, or points to a string that could not be read or was unterminated within the maximum scan length | Name RVA = 0x0, or RVA points to bytes with no NUL terminator within 1024 bytes | Per‑entry +| **EXPORT_NAME_NOT_ASCII** | A name string decoded successfully but contains non‑printable bytes or characters outside the printable ASCII range (0x20–0x7E) | Name = "Foo\x01Bar", or name decoded with Unicode replacement characters | Per‑entry +| **EXPORT_NAME_POINTER_TABLE_UNSORTED** | The Export Name Pointer Table is not sorted lexicographically by name, violating the PE spec requirement that enables binary search by GetProcAddress | Names in order: ["Zeta", "Alpha", "Mu"] | Per‑file +| **EXPORT_NAME_ORDINAL_INDEX_INVALID** | An EOT entry is missing, or its value is greater than or equal to NumberOfFunctions (i.e., it points outside the EAT) | EOT entry = 500, NumberOfFunctions = 100 | Per‑entry + +### Export Function Entry Anomalies + +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **EXPORT_ORDINAL_OUT_OF_RANGE** | The maximum computed ordinal (Base + NumberOfFunctions - 1) exceeds the 16‑bit range. Per PE spec, ordinals must fit in a WORD | Base = 0xFFF0, NumberOfFunctions = 32, max ordinal = 0x1000F | Per‑file +| **EXPORT_FUNCTION_RVA_INVALID** | A function entry's address RVA is non‑zero, is not a forwarder, and points outside the PE image (>= SizeOfImage) | Address RVA = 0x2000000, SizeOfImage = 0x400000 | Per‑entry +| **EXPORT_FORWARDER_MALFORMED** | A function entry's RVA points within the export directory (indicating a forwarder) but the resulting string is unreadable, contains non‑printable bytes, or does not match the spec format DllName.SymbolName or DllName.#Ordinal | Forwarder string = "KERNEL32\x01LoadLibraryA", or "InvalidForwarderNoDot" | Per‑entry + +## EXPORT SUB‑REASONS + +Several export reason codes carry a reason field in their details payload that narrows the pathology. The full taxonomy: + +### EXPORT_DIRECTORY_INVALID_HEADER + +| Sub‑reason | Meaning | +|------------|---------| +| top_level_decode | The 40‑byte header could not be unpacked from the file bytes | +| eat_rva_zero_with_nonzero_count | NumberOfFunctions > 0 but AddressOfFunctions == 0 | +| enpt_rva_zero_with_nonzero_count | NumberOfNames > 0 but AddressOfNames == 0 | +| eot_rva_zero_with_nonzero_count | NumberOfNames > 0 but AddressOfNameOrdinals == 0 | +| num_names_exceeds_num_functions | NumberOfNames > NumberOfFunctions (impossible in a well‑formed table) | + +### EXPORT_TABLE_TRUNCATED + +The table field (not reason) identifies the affected sub‑table: + +| table value | Meaning +| export_directory_header | The 40‑byte header itself was short | +| eat_truncated, enpt_truncated, eot_truncated | A sub‑table's declared size exceeded available file bytes | +| eat_read_failed, enpt_read_failed, eot_read_failed | pe.get_data raised when reading the sub‑table | +| eat_rva_zero, enpt_rva_zero, eot_rva_zero | RVA was zero despite a non‑zero declared count | + +### EXPORT_NAME_RVA_INVALID + +Priority‑resolved; the first matching tag wins: + +| Sub‑reason | Meaning | +|------------|---------| +| name_rva_missing | Parser did not capture the entry's name RVA | +| name_rva_zero | RVA was explicitly zero | +| read_failed | pe.get_data raised when reading the name string | +| unterminated | No NUL terminator found within the maximum scan length | + +### EXPORT_NAME_NOT_ASCII + +Priority‑resolved: + +| Sub‑reason | Meaning | +| non_ascii | Decode produced Unicode replacement characters | +| name_not_printable_ascii | Decoded successfully but contains bytes outside 0x20–0x7E | + +### EXPORT_NAME_ORDINAL_INDEX_INVALID + +| Sub‑reason | Meaning | +| missing | Parser could not read the EOT entry | +| out_of_range | Ordinal index >= NumberOfFunctions | + +### EXPORT_ORDINAL_OUT_OF_RANGE + +| Sub‑reason | Meaning | +| max_exceeds_u16 | Base + NumberOfFunctions − 1 > 0xFFFF | + +### EXPORT_FORWARDER_MALFORMED + +| Sub‑reason | Meaning | +| unreadable | RVA points into export directory but string could not be decoded | +| format | Decoded fine but does not match DllName.SymbolName or DllName.#Ordinal | + +### EXPORT_FUNCTION_RVA_INVALID + +| Sub‑reason | Meaning | +| exceeds_image | Address RVA >= SizeOfImage | + +--- + ## **PACKER HEURISTICS (Interpretation Layer)** | Reason Code | What Triggers It | Example Pattern | Scope | diff --git a/docs/specs/structural-validation-deterministic-heuristics.md b/docs/specs/structural-validation-deterministic-heuristics.md index 0076d97..0b1757f 100644 --- a/docs/specs/structural-validation-deterministic-heuristics.md +++ b/docs/specs/structural-validation-deterministic-heuristics.md @@ -229,6 +229,29 @@ Version‑info is a high‑signal forensic surface: CompanyName, OriginalFilenam --- +# 2.11 Exports Validator +## Validates the structural integrity of the PE export table extracted by parser_exports. + +This validator performs: + +- Top‑level decode failure detection and short‑circuit +- Export directory placement within SizeOfImage +- Truncation reporting across EAT, ENPT, and EOT sub‑tables +- Header consistency checks (declared counts vs declared RVAs, name count vs function count) +- Per‑entry name pointer validation: RVA, encoding, ordinal index bounds +- Export Name Pointer Table sort order (PE spec requires lexicographic ordering for binary search) +- Per‑entry function validation: ordinal range, address RVA bounds, forwarder string format + +Absence of an export directory is not treated as a structural defect — most EXE files legitimately have no exports. + +The export table is the second‑most parser‑sensitive surface in the PE format after VS_VERSIONINFO. Three properties make general‑purpose export parsers prone to divergent output: the EAT can contain a mix of function RVAs and forwarder string pointers distinguished only by whether the RVA falls inside the export directory; the ENPT is required to be sorted but malformed binaries routinely violate this; and the EOT cross‑references the EAT by index, creating a join that breaks silently if either side is truncated. + +The exports parser reads all four critical tables (header, EAT, ENPT, EOT) directly from raw bytes via struct.unpack_from, with bounded array reads and per‑position fallback to None when bytes are missing. The validator's per‑entry checks treat the parser's tombstone tags as a stable contract — each tag maps to a deterministic reason code and sub‑reason. Priority lists govern which sub‑reason wins when an entry carries multiple malformations. + +This ensures that for any given malformed export table, the validator produces the same set of reason codes on every run, regardless of platform or pefile version. Forwarder strings are validated against the PE spec's DllName.SymbolName and DllName.#Ordinal grammar via a single conservative regex, not by attempting runtime resolution. + +--- + # **3. Deterministic Heuristics Layer** ### *Heuristics interpret structural truth — they never override it.* From 6f06869661f23e9b63de23fb0c6be8df0496c80f Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 13:55:39 +0100 Subject: [PATCH 11/35] Update invalid_optional_header fixture snapshots: export anomalies now triggering on 2 contract fixtures. --- .../invalid_optional_header.full.json | 12 ++++++++++++ .../invalid_optional_header.pe32.full.json | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json index c91b032..5668e7a 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json @@ -164,6 +164,18 @@ "size": 512, "size_of_image": 512 } + }, + { + "value": "pe_structure_anomaly", + "start": 0, + "end": 0, + "category": "pe_heuristic", + "metadata": { + "reason": "top_level_decode", + "errors": [ + "header_read_failed" + ] + } } ] } diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json index 894b77d..c7e5519 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json @@ -222,6 +222,16 @@ "size": 512, "size_of_image": 512 } + }, + { + "value": "pe_structure_anomaly", + "start": 0, + "end": 0, + "category": "pe_heuristic", + "metadata": { + "reason": "export_table_truncated", + "table": "export_directory_header" + } } ] } From 0864eb01b5e4be06be51b6b46c356c66361ea0f5 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 15:13:37 +0100 Subject: [PATCH 12/35] CHANGELOG entry for exports requirement work --- CHANGELOG.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8faabd2..1c62924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,45 @@ replacing `List[Any]`. New `VersionInfoStruct` and its sub-types (`FixedFileInfo`, `StringFileInfo`, `StringTable`, `VarFileInfo`, `VarEntry`, `Translation`) are declared in the schema. +- **Deterministic export table extraction.** New `pe_exports` module + decodes the 40-byte `IMAGE_EXPORT_DIRECTORY` header, the Export Address + Table, Export Name Pointer Table, and Export Ordinal Table purely from + bytes using `struct.unpack_from`. Forwarder detection follows the PE + spec rule (address RVA falls within the export directory range). + Name and forwarder string reads are bounded; the decoder never raises; + sub-structure failures emit tombstone tags in `truncations[]` and + per-entry `errors[]`. +- **Export table structural validator.** New `exports` module + maps parser output to ten new reason codes: + - `EXPORT_DIRECTORY_INVALID_HEADER` — header malformed or declared + counts inconsistent with declared array RVAs. + - `EXPORT_DIRECTORY_OUT_OF_BOUNDS` — directory extends past + `SizeOfImage`. + - `EXPORT_TABLE_TRUNCATED` — declared sub-table size exceeds available + bytes (per-table emission via `details["table"]`). + - `EXPORT_NAME_RVA_INVALID` — name pointer RVA unusable + (priority-resolved sub-reasons: `name_rva_missing`, `name_rva_zero`, + `read_failed`, `unterminated`). + - `EXPORT_NAME_NOT_ASCII` — name decoded but contains non-printable + bytes (priority-resolved sub-reasons: `non_ascii`, + `name_not_printable_ascii`). + - `EXPORT_NAME_POINTER_TABLE_UNSORTED` — ENPT violates the PE-spec + requirement that names be sorted lexicographically for binary search. + - `EXPORT_NAME_ORDINAL_INDEX_INVALID` — EOT entry missing or points + outside the EAT. + - `EXPORT_ORDINAL_OUT_OF_RANGE` — `Base + NumberOfFunctions - 1` + exceeds 16-bit range. + - `EXPORT_FUNCTION_RVA_INVALID` — function address RVA exceeds + `SizeOfImage`. + - `EXPORT_FORWARDER_MALFORMED` — forwarder string unreadable or + violates `DllName.SymbolName` / `DllName.#Ordinal` grammar. + + Absence of an export directory is not treated as a structural defect; + most EXEs legitimately have no exports. +- **Precise export-table typing.** New TypedDicts for `ExportStruct`, + `ExportDirectoryHeader`, `ExportFunctionEntry`, and + `ExportNamePointerEntry`. `InternalMetadata.export_struct` is typed as + `Optional[ExportStruct]`. ## Changed @@ -44,6 +83,9 @@ - **Validator dispatcher order.** `validate_version_info` is registered between `validate_resources` and `validate_entropy`, reflecting its position as a payload-specific validator nested under the resource tree. +- **Validator dispatcher order.** `validate_exports` is registered + between `validate_version_info` and `validate_entropy`, completing the + structural validator chain ahead of the entropy/derived layer. ## Marked as RESERVE but consider removing in the future @@ -66,6 +108,12 @@ - Brief clarifying note added to the resources validator section explaining the layering between resource-tree validation and payload validators nested beneath it. +- Reason-codes reference extended with a new top-level *Export Anomalies* + section in three subsections (Directory, Name Pointer, Function Entry) + and a dedicated *Export Sub-Reasons* taxonomy section documenting the + `details["reason"]` contract for each code that carries one. +- New validator documentation section 2.11 for the exports validator with + explicit determinism rationale. ## Internal @@ -75,6 +123,13 @@ `struct.error` injection. - Narrow-except negative tests confirm the parser's exception handling does not silently swallow exceptions outside `(PEFormatError, AttributeError)`. +- 100% line and branch coverage on `pe_exports` and + `exports` validator. +- Defensive-path coverage via monkeypatched `struct.error` injection + and narrow-except negative tests. +- One `# pragma: no cover` applied to a defensive return in the + validator's `_first_unsorted_index` helper, documented inline as + unreachable from the validator's call site. ## Compatibility @@ -83,15 +138,21 @@ - **No public IOC schema changes.** Version-info data is currently exposed only in internal metadata and CLI rendering; public IOC schema exposure is deferred to a later release with a deliberate fixture corpus refresh. +- Invalid optional header fixtures JSON contracts updated: addition of export anomalies to heuristic output. ## Known scheduled work -- Six single-anomaly fixtures targeting the new reason codes (specs queued; +- Six single-anomaly fixtures targeting the new resource directory reason codes (specs queued; construction to follow). - `pefile_usage_policy.md` documenting the deterministic-subset usage pattern (to be drafted alongside the reproducibility appendix work). - Public IOC schema field for `version_info` (planned for a future release with corpus refresh and schema-version bump). +- Single-anomaly fixtures targeting the new export reason codes (specs + drafted; construction to follow). Includes one negative-control + fixture (`exp_forwarder_to_ordinal_valid`) demonstrating that the + validator correctly accepts the spec-valid `#Ordinal` forwarder + syntax without false positive. --- From e8b1a77034ea7d4b4540a776daa7cd9e2c6b9b98 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 15:21:17 +0100 Subject: [PATCH 13/35] CHANGELOG entry for resource public schema updates --- CHANGELOG.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c62924..9dcf223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,33 @@ `ExportDirectoryHeader`, `ExportFunctionEntry`, and `ExportNamePointerEntry`. `InternalMetadata.export_struct` is typed as `Optional[ExportStruct]`. +- **Enriched resource metadata in CLI output.** The `_parse_resources` + function now exposes a structured `ResourceEntry` for every resource + in the PE, covering all four fields called out in requirement 4 + (type, size, language and codepage, entropy) plus the schema-declared + `name`, `rva`, and `raw_offset` fields. Output is deterministic and + JSON-safe. +- **Per-entry structured error reporting.** Resources whose data bytes + cannot be read are no longer silently dropped from the output. Instead, + the entry is emitted with an `errors` list populated with tombstone + tags describing the failure: + - `size_invalid` — declared size is zero or negative. + - `rva_invalid` — data RVA is negative. + - `data_out_of_bounds` — RVA + size exceeds the memory-mapped image. + - `raw_offset_invalid` — `get_offset_from_rva` failed to resolve the + file offset. + + This makes the resource count visible to consumers even when individual + entries cannot be fully decoded. +- **Codepage field on resource entries.** The `CodePage` field from the + PE resource data structure is now captured as `codepage` in the + output, typed as `Optional[int]` (null when zero or absent). +- **Deterministic resource ordering.** The output list is sorted by + `(type, language, rva)` to ensure snapshot stability across runs. +- **Per-resource Shannon entropy.** Computed over the resource's data + bytes, rounded to 4 decimal places for snapshot stability. Entries + with unreadable data produce `entropy: null` and the corresponding + error tombstone. ## Changed @@ -86,6 +113,25 @@ - **Validator dispatcher order.** `validate_exports` is registered between `validate_version_info` and `validate_entropy`, completing the structural validator chain ahead of the entropy/derived layer. +- **`ResourceEntry` schema expanded.** Fields are now `total=False` + Optional to accommodate per-entry computation failures. New fields: + `codepage`, `errors`. Existing fields retain their meanings; `name`, + `rva`, and `raw_offset` are now populated where they were previously + declared but absent from output. +- **`_decode_langid` returns `None` for undecodable LANGIDs.** + Previously returned the magic string `"unknown"`, which conflated + several distinct states (not provided, structurally invalid, primary + language unmapped). The new behaviour returns `None` from every + "cannot decode" path, allowing consumers to distinguish cleanly. + The early-return guard `if langid < 0x0400` was removed; it was + rejecting valid neutral-sublang LANGIDs (e.g., LANGID `0x0001` + decodes correctly as `"ar"` for Arabic). +- **Resource entropy now computed over the correct byte range.** + Previously sliced `get_memory_mapped_image()` with the raw file + offset; now correctly uses the RVA. This was a regression introduced + during the requirement 4 work and caught before snapshot stamping — + entropy values now match the previous release's behaviour for all + existing fixtures. ## Marked as RESERVE but consider removing in the future @@ -114,6 +160,12 @@ `details["reason"]` contract for each code that carries one. - New validator documentation section 2.11 for the exports validator with explicit determinism rationale. +- Resource metadata documentation extended to describe the new + `ResourceEntry` shape, the `errors` field semantics, and the + `codepage` field's `null`-on-absent convention. +- The `_decode_langid` semantics are documented inline: primary + language and sublang decomposition, fallback to default region, + fallback to primary-language-only, fallback to `None`. ## Internal @@ -130,6 +182,11 @@ - One `# pragma: no cover` applied to a defensive return in the validator's `_first_unsorted_index` helper, documented inline as unreachable from the validator's call site. +- Memory-mapped image slicing uses the RVA (not the raw file offset), + which is the correct index into `get_memory_mapped_image()` output. +- Float precision pinned at 4 decimal places for entropy, matching the + precision convention used in other entropy-bearing fields elsewhere + in IOCX. ## Compatibility @@ -139,6 +196,21 @@ only in internal metadata and CLI rendering; public IOC schema exposure is deferred to a later release with a deliberate fixture corpus refresh. - Invalid optional header fixtures JSON contracts updated: addition of export anomalies to heuristic output. +- Resource fixture snapshots refreshed to reflect new field shape and `language_name` semantics. +- **Resource output shape is additive but reformatted.** Consumers + reading the `resources` field will see new keys (`codepage`, + `errors`, `name`, `rva`, `raw_offset`) and may see additional + entries that previously didn't appear (those with errors). Existing + per-entry field meanings are unchanged. +- **`language_name` no longer returns the string `"unknown"`.** + Consumers checking `language_name == "unknown"` will need to update + to `language_name is None`. This is a deliberate semantic correction + rather than a passive breaking change — the previous value was a + magic-string sentinel that conflated several states. +- **Snapshot refresh required for any fixture with resources.** The + `language_name` return change and the new fields will produce diffs + in expected outputs. Refresh is mechanical via the existing fixture + regeneration tooling. ## Known scheduled work @@ -153,6 +225,17 @@ fixture (`exp_forwarder_to_ordinal_valid`) demonstrating that the validator correctly accepts the spec-valid `#Ordinal` forwarder syntax without false positive. +- Resource fixtures targeting the new `errors` field paths + (`size_invalid`, `rva_invalid`, `data_out_of_bounds`, + `raw_offset_invalid`). Currently the corpus exercises only the + clean path; single-anomaly fixtures for each error tag would round + out coverage. +- `SUBLANG` table refinement. The current implementation models + sublang values as language-independent, which is incorrect for + multilingual edge cases (sublang `0x02` means UK English with + primary English, but Swiss German with primary German). A flat + LCID → BCP-47 mapping is the structural fix; deferred as a separate + ticket since the current behaviour is correct for the common case. --- From 06d8478fd771d0b6d7824bc5149f4cfc4112ed5f Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 15:32:06 +0100 Subject: [PATCH 14/35] feat(pe_parser): enrich resource metadata extraction Extends _parse_resources to expose the full ResourceEntry shape declared in the public schema, with structured error reporting for entries that cannot be fully decoded. Output additions per entry: - name: the resource's named identifier (e.g. MAINICON), previously declared in the schema but not populated. - codepage: from data_entry.struct.CodePage, typed as Optional[int] (null when zero or absent). - rva: data RVA, previously declared but not populated. - raw_offset: file offset via get_offset_from_rva, with guarded exception handling matching the pattern established in build_resource_structure. - errors: tombstone list of per-entry computation failures: * size_invalid (declared size <= 0) * rva_invalid (negative RVA) * data_out_of_bounds (RVA + size exceeds memory-mapped image) * raw_offset_invalid (RVA-to-offset resolution failed) Behavioural changes: - Resources with invalid data are no longer silently dropped; they are emitted with errors populated and the affected fields set to None. This makes the resource count visible to consumers regardless of per-entry health. - Output list is sorted by (type, language, rva) for snapshot stability. - Entropy is rounded to 4 decimal places for cross-platform float determinism. Bug fix included: - Entropy is now computed by slicing get_memory_mapped_image() with the RVA (correct) rather than the raw file offset (incorrect). The memory-mapped image is indexed by RVA per pefile's contract; using the raw offset sliced the wrong byte range and produced incorrect entropy values for every resource. Caught before snapshot stamping. No schema changes in this commit; ResourceEntry schema update lands separately. Refs: requirement 4 (resource metadata enrichment) --- iocx/parsers/pe_parser.py | 73 ++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/iocx/parsers/pe_parser.py b/iocx/parsers/pe_parser.py index c1e8c5d..a5370c5 100644 --- a/iocx/parsers/pe_parser.py +++ b/iocx/parsers/pe_parser.py @@ -7,7 +7,7 @@ import struct from .string_extractor import extract_strings_from_bytes from ..analysis.obfuscation import _shannon_entropy -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from .language_map import PRIMARY_LANG, SUBLANG, DEFAULT_REGION # --------------------------------------------------------------------------- @@ -120,20 +120,17 @@ def _entropy(data: bytes | None) -> float: return ent -def _decode_langid(langid: int) -> str: - """Return a human-readable locale string from a Windows LANGID.""" +def _decode_langid(langid) -> Optional[str]: + """Return a BCP-47-like locale string from a Windows LANGID, or None if undecodable.""" if not isinstance(langid, int): - return "unknown" - - if langid < 0x0400: - return "unknown" + return None - primary = langid & 0x3FF # low 10 bits - sublang = (langid >> 10) & 0x3F # high bits + primary = langid & 0x3FF # low 10 bits + sublang = (langid >> 10) & 0x3F # high 6 bits lang = PRIMARY_LANG.get(primary) if not lang: - return "unknown" + return None region = SUBLANG.get(sublang) if region: @@ -143,7 +140,7 @@ def _decode_langid(langid: int) -> str: if default_region: return f"{lang}-{default_region}" - # If no region known, return just the language + # Primary language known but no region info — return language only return lang @@ -352,7 +349,6 @@ def _parse_header(pe, opt): "characteristics": getattr(fh, "Characteristics", 0) if fh else 0, } - def _parse_resources(pe): resources: list[dict[str, Any]] = [] resource_strings: list[str] = [] @@ -372,40 +368,69 @@ def _parse_resources(pe): for entry in getattr(pe.DIRECTORY_ENTRY_RESOURCE, "entries", []): type_id = getattr(entry, "id", None) - type_name = pefile.RESOURCE_TYPE.get(type_id, str(type_id)) + type_name = pefile.RESOURCE_TYPE.get(type_id, f"RT_UNKNOWN_{type_id}") if not hasattr(entry, "directory"): continue for res in getattr(entry.directory, "entries", []): + # Capture the resource's named identifier if present + res_name = str(res.name) if getattr(res, "name", None) is not None else None lang = getattr(res, "id", None) + if not hasattr(res, "directory"): continue if not getattr(res.directory, "entries", []): continue data_entry = res.directory.entries[0].data - size = data_entry.struct.Size - if size <= 0: - continue + ds = data_entry.struct + size = ds.Size + rva = ds.OffsetToData + codepage = getattr(ds, "CodePage", 0) or None - offset = data_entry.struct.OffsetToData - if offset < 0 or offset + size > len(mm): - continue + # Guarded RVA→offset + try: + raw_offset = pe.get_offset_from_rva(rva) + except (pefile.PEFormatError, AttributeError): + raw_offset = None + + # Per-entry error tombstones; emit the entry either way so + # invalid resources remain visible to consumers. + errors: list[str] = [] + entropy_val: float | None = None - blob = mm[offset:offset + size] - ent = _entropy(blob) + if size <= 0: + errors.append("size_invalid") + elif rva < 0: + errors.append("rva_invalid") + elif rva + size > len(mm): + errors.append("data_out_of_bounds") + else: + blob = mm[rva:rva + size] # slice with RVA, not raw_offset + entropy_val = round(_entropy(blob), 4) resources.append({ "type": type_name, + "name": res_name, "language": lang, "language_name": _decode_langid(lang), - "size": size, - "entropy": ent, + "codepage": codepage, + "size": size if size > 0 else None, + "entropy": entropy_val, + "rva": rva, + "raw_offset": raw_offset, + "errors": errors or None, }) - return resources, resource_strings + # Deterministic ordering for snapshot stability + resources.sort(key=lambda r: ( + r["type"], + r["language"] if r["language"] is not None else -1, + r["rva"] if r["rva"] is not None else -1, + )) + return resources, resource_strings def _parse_data_directories(pe): dirs: list[dict[str, Any]] = [] From 5efad854b45d5efe5437dadff3d59db4a4d108d7 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 15:38:52 +0100 Subject: [PATCH 15/35] schema(public): expand ResourceEntry and correct LANGID decoding Schema changes: - ResourceEntry is now total=False with all fields Optional, accommodating per-entry computation failures. - New fields: codepage (Optional[int]), errors (Optional[List[str]]). - Existing fields retain their meanings; the schema now matches what the parser produces. LANGID decoder corrections in _decode_langid: - Removed the early-return guard, which was rejecting valid neutral-sublang LANGIDs (e.g., LANGID 0x0001 now correctly decodes as 'ar' for Arabic with sublang neutral). - All 'cannot decode' paths now return None instead of the magic string 'unknown'. The previous behaviour conflated several distinct states (not provided, structurally invalid, primary language unmapped) into one ambiguous string sentinel that was fragile for consumers. Compatibility: - Consumers checking need to update to . This is a deliberate semantic correction rather than a passive breaking change. - Output shape gains keys ( Usage: cpi code_page_file [-c] [-L] [-l] [-a|nnn] -c: input file is a single codepage -L: print header info (you don't want to see this) -l or no option: list all codepages contained in the file -a: extract all codepages from the file nnn (3 digits): extract codepage nnn from the file Example: cpi ega.cpi 850 will create a file 850.cp containing the requested codepage., ) and may include entries that previously didn't appear (those with errors). Existing per-entry field meanings are unchanged. Deferred follow-up (not in this commit): - SUBLANG table refinement. The current implementation models sublang values as language-independent, which is incorrect for multilingual edge cases (sublang 0x02 means UK English with primary English, but Swiss German with primary German). A flat LCID -> BCP-47 mapping is the structural fix; tracked as a separate ticket since current behaviour is correct for the common case. Snapshot refresh required for fixtures with resources. Refs: requirement 4 (resource metadata enrichment)schema(public): expand ResourceEntry and correct LANGID decoding Schema changes: - ResourceEntry is now total=False with all fields Optional, accommodating per-entry computation failures. - New fields: codepage (Optional[int]), errors (Optional[List[str]]). - Existing fields retain their meanings; the schema now matches what the parser produces. LANGID decoder corrections in _decode_langid: - Removed the early-return guard, which was rejecting valid neutral-sublang LANGIDs (e.g., LANGID 0x0001 now correctly decodes as 'ar' for Arabic with sublang neutral). - All 'cannot decode' paths now return None instead of the magic string 'unknown'. The previous behaviour conflated several distinct states (not provided, structurally invalid, primary language unmapped) into one ambiguous string sentinel that was fragile for consumers. Compatibility: - Consumers checking need to update to . This is a deliberate semantic correction rather than a passive breaking change. - Output shape gains keys ( Usage: cpi code_page_file [-c] [-L] [-l] [-a|nnn] -c: input file is a single codepage -L: print header info (you don't want to see this) -l or no option: list all codepages contained in the file -a: extract all codepages from the file nnn (3 digits): extract codepage nnn from the file Example: cpi ega.cpi 850 will create a file 850.cp containing the requested codepage., ) and may include entries that previously didn't appear (those with errors). Existing per-entry field meanings are unchanged. Deferred follow-up (not in this commit): - SUBLANG table refinement. The current implementation models sublang values as language-independent, which is incorrect for multilingual edge cases (sublang 0x02 means UK English with primary English, but Swiss German with primary German). A flat LCID -> BCP-47 mapping is the structural fix; tracked as a separate ticket since current behaviour is correct for the common case. Snapshot refresh required for fixtures with resources. Refs: requirement 4 (resource metadata enrichment) --- iocx/schemas/public_metadata.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/iocx/schemas/public_metadata.py b/iocx/schemas/public_metadata.py index 8669915..1a2d2ab 100644 --- a/iocx/schemas/public_metadata.py +++ b/iocx/schemas/public_metadata.py @@ -47,14 +47,17 @@ class ExportEntry(TypedDict): forwarder: Optional[str] -class ResourceEntry(TypedDict): +class ResourceEntry(TypedDict, total=False): type: str name: Optional[str] - language: Optional[str] - size: int - entropy: float - rva: int - raw_offset: int + language: Optional[int] + language_name: Optional[str] + codepage: Optional[int] + size: Optional[int] + entropy: Optional[float] + rva: Optional[int] + raw_offset: Optional[int] + errors: Optional[List[str]] class PublicMetadata(TypedDict, total=False): From 913d71fea453ce7b6b8f60ed58756001199ae706 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 15:53:15 +0100 Subject: [PATCH 16/35] Refresh fixture snapshots to reflect new field shape for resources --- .../crypto_entropy_payload.full.json | 15 ++++++++++----- .../franken_url_domain_ip.full.json | 15 ++++++++++----- .../layer3_adversarial/malformed_domain.full.json | 15 ++++++++++----- .../layer3_adversarial/malformed_ip.full.json | 15 ++++++++++----- .../layer3_adversarial/malformed_url.full.json | 15 ++++++++++----- .../string_obfuscation_tricks.full.json | 15 ++++++++++----- 6 files changed, 60 insertions(+), 30 deletions(-) diff --git a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json index fd3b73c..da06ea0 100644 --- a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json +++ b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json @@ -35,10 +35,15 @@ "resources": [ { "type": "RT_MANIFEST", + "name": null, "language": 1, - "language_name": "unknown", + "language_name": "ar", + "codepage": null, "size": 381, - "entropy": 4.9116145157351045 + "entropy": 4.9116, + "rva": 24672, + "raw_offset": 10336, + "errors": null } ], "resource_strings": [ @@ -590,9 +595,9 @@ "types": [ "RT_MANIFEST" ], - "entropy_min": 4.9116145157351045, - "entropy_max": 4.9116145157351045, - "entropy_avg": 4.9116145157351045 + "entropy_min": 4.9116, + "entropy_max": 4.9116, + "entropy_avg": 4.9116 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json index 0ae7387..3f6096d 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json @@ -66,10 +66,15 @@ "resources": [ { "type": "RT_MANIFEST", + "name": null, "language": 1, - "language_name": "unknown", + "language_name": "ar", + "codepage": null, "size": 381, - "entropy": 4.9116145157351045 + "entropy": 4.9116, + "rva": 28768, + "raw_offset": 11360, + "errors": null } ], "resource_strings": [ @@ -627,9 +632,9 @@ "types": [ "RT_MANIFEST" ], - "entropy_min": 4.9116145157351045, - "entropy_max": 4.9116145157351045, - "entropy_avg": 4.9116145157351045 + "entropy_min": 4.9116, + "entropy_max": 4.9116, + "entropy_avg": 4.9116 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json index 0e915d4..cf85045 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json @@ -44,10 +44,15 @@ "resources": [ { "type": "RT_MANIFEST", + "name": null, "language": 1, - "language_name": "unknown", + "language_name": "ar", + "codepage": null, "size": 381, - "entropy": 4.9116145157351045 + "entropy": 4.9116, + "rva": 24672, + "raw_offset": 10336, + "errors": null } ], "resource_strings": [ @@ -605,9 +610,9 @@ "types": [ "RT_MANIFEST" ], - "entropy_min": 4.9116145157351045, - "entropy_max": 4.9116145157351045, - "entropy_avg": 4.9116145157351045 + "entropy_min": 4.9116, + "entropy_max": 4.9116, + "entropy_avg": 4.9116 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json index ed1d5d2..b5fc2a8 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json @@ -50,10 +50,15 @@ "resources": [ { "type": "RT_MANIFEST", + "name": null, "language": 1, - "language_name": "unknown", + "language_name": "ar", + "codepage": null, "size": 381, - "entropy": 4.9116145157351045 + "entropy": 4.9116, + "rva": 24672, + "raw_offset": 10336, + "errors": null } ], "resource_strings": [ @@ -611,9 +616,9 @@ "types": [ "RT_MANIFEST" ], - "entropy_min": 4.9116145157351045, - "entropy_max": 4.9116145157351045, - "entropy_avg": 4.9116145157351045 + "entropy_min": 4.9116, + "entropy_max": 4.9116, + "entropy_avg": 4.9116 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json index ebd267e..b7d802b 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json @@ -48,10 +48,15 @@ "resources": [ { "type": "RT_MANIFEST", + "name": null, "language": 1, - "language_name": "unknown", + "language_name": "ar", + "codepage": null, "size": 381, - "entropy": 4.9116145157351045 + "entropy": 4.9116, + "rva": 24672, + "raw_offset": 10336, + "errors": null } ], "resource_strings": [ @@ -609,9 +614,9 @@ "types": [ "RT_MANIFEST" ], - "entropy_min": 4.9116145157351045, - "entropy_max": 4.9116145157351045, - "entropy_avg": 4.9116145157351045 + "entropy_min": 4.9116, + "entropy_max": 4.9116, + "entropy_avg": 4.9116 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json index 2de5188..957c692 100644 --- a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json +++ b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json @@ -41,10 +41,15 @@ "resources": [ { "type": "RT_MANIFEST", + "name": null, "language": 1, - "language_name": "unknown", + "language_name": "ar", + "codepage": null, "size": 381, - "entropy": 4.9116145157351045 + "entropy": 4.9116, + "rva": 24672, + "raw_offset": 10336, + "errors": null } ], "resource_strings": [ @@ -602,9 +607,9 @@ "types": [ "RT_MANIFEST" ], - "entropy_min": 4.9116145157351045, - "entropy_max": 4.9116145157351045, - "entropy_avg": 4.9116145157351045 + "entropy_min": 4.9116, + "entropy_max": 4.9116, + "entropy_avg": 4.9116 } } ], From cbec6362d8694557577053d3ce8b851f537295b3 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 16:00:23 +0100 Subject: [PATCH 17/35] Fix unit tests to mee the new resource language contract --- tests/unit/parsers/test_pe_parser_extended.py | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/tests/unit/parsers/test_pe_parser_extended.py b/tests/unit/parsers/test_pe_parser_extended.py index 7dab37a..55af9f4 100644 --- a/tests/unit/parsers/test_pe_parser_extended.py +++ b/tests/unit/parsers/test_pe_parser_extended.py @@ -214,7 +214,13 @@ def test_resource_zero_size(monkeypatch): monkeypatch.setattr("iocx.parsers.pe_parser.pefile.PE", lambda *a, **k: pe) _, metadata = parse_pe("dummy.exe") - assert metadata["resources"] == [] + # Invalid resources are no longer dropped; they're emitted with errors populated. + assert len(metadata["resources"]) == 1 + entry = metadata["resources"][0] + assert entry["errors"] == ["size_invalid"] + assert entry["size"] is None + assert entry["entropy"] is None + assert entry["language"] == 1033 def test_resource_out_of_bounds(monkeypatch): @@ -224,7 +230,10 @@ def test_resource_out_of_bounds(monkeypatch): monkeypatch.setattr("iocx.parsers.pe_parser.pefile.PE", lambda *a, **k: pe) _, metadata = parse_pe("dummy.exe") - assert metadata["resources"] == [] + assert len(metadata["resources"]) == 1 + entry = metadata["resources"][0] + assert entry["errors"] == ["data_out_of_bounds"] + assert entry["entropy"] is None def test_resource_missing_directory_on_type(monkeypatch): @@ -266,7 +275,10 @@ def test_resource_negative_offset(monkeypatch): monkeypatch.setattr("iocx.parsers.pe_parser.pefile.PE", lambda *a, **k: pe) _, metadata = parse_pe("dummy.exe") - assert metadata["resources"] == [] + assert len(metadata["resources"]) == 1 + entry = metadata["resources"][0] + assert entry["errors"] == ["rva_invalid"] + assert entry["entropy"] is None def test_resource_mixed_valid_and_invalid(monkeypatch): @@ -981,14 +993,13 @@ def get_memory_mapped_image(self): from iocx.parsers.pe_parser import _decode_langid def test_decode_langid_non_int(): - assert _decode_langid("409") == "unknown" - assert _decode_langid(None) == "unknown" + # Non-integer input cannot be decoded; returns None. + assert _decode_langid("409") is None def test_decode_langid_too_small(): - # < 0x0400 should always be unknown - assert _decode_langid(0x0000) == "unknown" - assert _decode_langid(0x003F) == "unknown" + # LANGID 0 is LANG_NEUTRAL with no primary mapping; returns None. + assert _decode_langid(0x0000) is None def test_decode_langid_valid_with_default_region(): @@ -1002,8 +1013,10 @@ def test_decode_langid_valid_without_region(): def test_decode_langid_unknown_primary(): - # Primary language 0x999 is not in PRIMARY_LANG - assert _decode_langid(0x0999) == "unknown" + # Primary language 0x199 is not in PRIMARY_LANG; returns None. + # Note: previous test used 0x0999 which decomposed to primary=0x199. + # The new decoder reaches this state cleanly without the < 0x0400 guard. + assert _decode_langid(0x0999) is None def test_decode_langid_region_branch(): From 38bfa1b6f87ab1f6c256cb2891011bf889688822 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Fri, 26 Jun 2026 16:02:49 +0100 Subject: [PATCH 18/35] Add two new parser unit tests: 1> pins the bug-fix behaviour whereby LANGID was incorrectly mapped as unknown, 2> regression guard for the common case to prove the broader changes didn't break anything obvious --- tests/unit/parsers/test_pe_parser_extended.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/parsers/test_pe_parser_extended.py b/tests/unit/parsers/test_pe_parser_extended.py index 55af9f4..c7190d3 100644 --- a/tests/unit/parsers/test_pe_parser_extended.py +++ b/tests/unit/parsers/test_pe_parser_extended.py @@ -1022,3 +1022,15 @@ def test_decode_langid_unknown_primary(): def test_decode_langid_region_branch(): # 0x0809 = English (United Kingdom) → explicit SUBLANG region assert _decode_langid(0x0809) == "en-GB" + + +def test_decode_langid_arabic_neutral(): + # LANGID 1 (Arabic, sublang neutral) was previously rejected by the + # < 0x0400 guard. The new decoder correctly returns the primary + # language name. + assert _decode_langid(0x0001) == "ar" + + +def test_decode_langid_en_us(): + # Sanity: en-US still decodes correctly (regression guard). + assert _decode_langid(0x0409) == "en-US" From ff6fa35980f3e2aed7778053d5461576788f87c4 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 29 Jun 2026 12:32:05 +0100 Subject: [PATCH 19/35] feat(pe_parser): enrich Optional Header metadata extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends _parse_optional_header and _parse_header to expose security-relevant fields previously not captured in IOCX's public metadata, completing requirement 1 (Optional Header Metadata Enrichment). Optional Header additions: - dll_characteristics (raw int) - dll_characteristics_flags (decoded flag names, sorted by bit position for snapshot stability) - dll_characteristics_unknown_bits (hex string for any bits outside the known mask, or null) - win32_version_value (raw int, deprecated DWORD per PE spec, with Reserved1 field guard) - loader_flags (raw int, reserved DWORD per PE spec) - stack_reserve_size, stack_commit_size - heap_reserve_size, heap_commit_size Header additions: - subsystem_name (decoded string from IMAGE_SUBSYSTEM_* table, or null for unknown subsystem values) New constants module iocx.parsers.pe_constants: - SUBSYSTEM_NAMES: IMAGE_SUBSYSTEM_* lookup table (15 entries covering the well-known subsystems) - DLL_CHARACTERISTICS_FLAGS: IMAGE_DLLCHARACTERISTICS_* bit→name table (11 entries covering all defined bits) - DLL_CHARACTERISTICS_KNOWN_MASK: derived OR of all known bits, used to detect unknown bits per binary Implementation choices: - New fields use None as the missing-field default, distinct from 0 which indicates the binary really has that value. This is a deliberate divergence from the existing fields' default-to-zero convention; the semantic split between 'missing' and 'zero' is meaningful for security fields (DLL characteristics 0 means 'no security features enabled,' which is itself a signal). Existing fields retain default-to-zero for backward compatibility. - DLL characteristics decoding emits flag names in bit-position order, with any unknown bits exposed separately as a hex string. Consumers get a stable list plus complete information about non-decoded bits. - Subsystem name resolution follows the same pattern as the language / language_name pair added in requirement 4: raw value plus optional decoded string. Conservative invalid-field handling: each field is extracted independently via getattr with an appropriate default. Failure to extract one field does not affect others, satisfying the requirement's 'conservative handling of invalid fields' clause. No validator changes; no new reason codes. The validate_optional_header validator handles structural sanity checks (e.g., SizeOfImage consistency) and is unaffected by this enrichment. Refs: requirement 1 (Optional Header Metadata Enrichment) --- iocx/parsers/pe_constants.py | 52 +++++++++++++++++++++++++++++++++ iocx/parsers/pe_parser.py | 47 +++++++++++++++++++++++++++-- iocx/schemas/public_metadata.py | 11 +++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 iocx/parsers/pe_constants.py diff --git a/iocx/parsers/pe_constants.py b/iocx/parsers/pe_constants.py new file mode 100644 index 0000000..53fdb07 --- /dev/null +++ b/iocx/parsers/pe_constants.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Constant lookup tables for Optional Header field decoding. +Sources: Microsoft PE format specification, winnt.h. +""" + +# IMAGE_SUBSYSTEM_* values per PE spec +SUBSYSTEM_NAMES = { + 0: "UNKNOWN", + 1: "NATIVE", + 2: "WINDOWS_GUI", + 3: "WINDOWS_CUI", + 5: "OS2_CUI", + 7: "POSIX_CUI", + 8: "NATIVE_WINDOWS", + 9: "WINDOWS_CE_GUI", + 10: "EFI_APPLICATION", + 11: "EFI_BOOT_SERVICE_DRIVER", + 12: "EFI_RUNTIME_DRIVER", + 13: "EFI_ROM", + 14: "XBOX", + 16: "WINDOWS_BOOT_APPLICATION", +} + +# IMAGE_DLLCHARACTERISTICS_* bit flags +DLL_CHARACTERISTICS_FLAGS = { + 0x0020: "HIGH_ENTROPY_VA", + 0x0040: "DYNAMIC_BASE", + 0x0080: "FORCE_INTEGRITY", + 0x0100: "NX_COMPAT", + 0x0200: "NO_ISOLATION", + 0x0400: "NO_SEH", + 0x0800: "NO_BIND", + 0x1000: "APPCONTAINER", + 0x2000: "WDM_DRIVER", + 0x4000: "GUARD_CF", + 0x8000: "TERMINAL_SERVER_AWARE", +} + +# Moved from extended layer +MACHINE_NAMES = { + 0x014c: "x86", + 0x8664: "AMD64", + 0x0200: "IA64", +} + +# Mask covering all known DLL characteristics bits +DLL_CHARACTERISTICS_KNOWN_MASK = 0 +for _bit in DLL_CHARACTERISTICS_FLAGS: + DLL_CHARACTERISTICS_KNOWN_MASK |= _bit diff --git a/iocx/parsers/pe_parser.py b/iocx/parsers/pe_parser.py index a5370c5..f7387a4 100644 --- a/iocx/parsers/pe_parser.py +++ b/iocx/parsers/pe_parser.py @@ -9,6 +9,12 @@ from ..analysis.obfuscation import _shannon_entropy from typing import List, Dict, Any, Optional from .language_map import PRIMARY_LANG, SUBLANG, DEFAULT_REGION +from .pe_constants import ( + DLL_CHARACTERISTICS_FLAGS, + DLL_CHARACTERISTICS_KNOWN_MASK, + SUBSYSTEM_NAMES, + MACHINE_NAMES +) # --------------------------------------------------------------------------- # Low-level helpers @@ -147,6 +153,13 @@ def _decode_langid(langid) -> Optional[str]: # --------------------------------------------------------------------------- # Parsing helpers # --------------------------------------------------------------------------- +def _safe_attr(obj, *names, default=None): + for name in names: + value = getattr(obj, name, None) + if value is not None: + return value + return default + def _parse_imports(pe): imports: list[str] = [] @@ -321,6 +334,20 @@ def _parse_optional_header(pe): if not opt: return opt, {} + # ---- DLL characteristics decoding ---- + dll_chars = getattr(opt, "DllCharacteristics", None) + if dll_chars is None: + dll_chars_flags = None + dll_chars_unknown = None + else: + dll_chars_flags = [ + DLL_CHARACTERISTICS_FLAGS[bit] + for bit in sorted(DLL_CHARACTERISTICS_FLAGS.keys()) + if dll_chars & bit + ] + unknown = dll_chars & ~DLL_CHARACTERISTICS_KNOWN_MASK + dll_chars_unknown = f"0x{unknown:04X}" if unknown else None + optional_header = { "section_alignment": getattr(opt, "SectionAlignment", 0), "file_alignment": getattr(opt, "FileAlignment", 0), @@ -332,6 +359,15 @@ def _parse_optional_header(pe): f"{getattr(opt, 'MinorOperatingSystemVersion', 0)}", "subsystem_version": f"{getattr(opt, 'MajorSubsystemVersion', 0)}." f"{getattr(opt, 'MinorSubsystemVersion', 0)}", + "dll_characteristics": dll_chars, + "dll_characteristics_flags": dll_chars_flags, + "dll_characteristics_unknown_bits": dll_chars_unknown, + "win32_version_value": _safe_attr(opt, "Win32VersionValue", "Reserved1"), + "loader_flags": getattr(opt, "LoaderFlags", None), + "stack_reserve_size": getattr(opt, "SizeOfStackReserve", None), + "stack_commit_size": getattr(opt, "SizeOfStackCommit", None), + "heap_reserve_size": getattr(opt, "SizeOfHeapReserve", None), + "heap_commit_size": getattr(opt, "SizeOfHeapCommit", None), } return opt, optional_header @@ -340,12 +376,19 @@ def _parse_optional_header(pe): def _parse_header(pe, opt): fh = getattr(pe, "FILE_HEADER", None) + subsystem = getattr(opt, "Subsystem", 0) if opt else 0 + subsystem_name = SUBSYSTEM_NAMES.get(subsystem) + machine = getattr(fh, "Machine", 0) if fh else 0 + machine_name = MACHINE_NAMES.get(machine) + return { "entry_point": getattr(opt, "AddressOfEntryPoint", 0) if opt else 0, "image_base": getattr(opt, "ImageBase", 0) if opt else 0, - "subsystem": getattr(opt, "Subsystem", 0) if opt else 0, + "subsystem": subsystem, + "subsystem_name": subsystem_name, "timestamp": getattr(fh, "TimeDateStamp", 0) if fh else 0, - "machine": getattr(fh, "Machine", 0) if fh else 0, + "machine": machine, + "machine_name": machine_name, "characteristics": getattr(fh, "Characteristics", 0) if fh else 0, } diff --git a/iocx/schemas/public_metadata.py b/iocx/schemas/public_metadata.py index 1a2d2ab..2b85291 100644 --- a/iocx/schemas/public_metadata.py +++ b/iocx/schemas/public_metadata.py @@ -14,8 +14,10 @@ class HeaderInfo(TypedDict, total=False): entry_point: Optional[int] image_base: Optional[int] subsystem: Optional[int] + subsystem_name: Optional[str] timestamp: Optional[int] machine: Optional[int] + machine_name: Optional[str] characteristics: Optional[int] @@ -27,6 +29,15 @@ class OptionalHeaderInfo(TypedDict, total=False): linker_version: Optional[str] os_version: Optional[str] subsystem_version: Optional[str] + dll_characteristics: Optional[int] + dll_characteristics_flags: Optional[List[str]] + dll_characteristics_unknown_bits: Optional[str] + win32_version_value: Optional[int] + loader_flags: Optional[int] + stack_reserve_size: Optional[int] + stack_commit_size: Optional[int] + heap_reserve_size: Optional[int] + heap_commit_size: Optional[int] class RichHeaderInfo(TypedDict, total=False): From 7b87a8a17b0811bdd4c7647be0549bb37f2c3a47 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 29 Jun 2026 12:34:28 +0100 Subject: [PATCH 20/35] Extended metadata analyser refactored to remove duplicate decoding. Machine name decoding moved to the parser layer. Resource entropy statistics tolerate per-entry errors. --- iocx/analysis/extended.py | 124 +++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 56 deletions(-) diff --git a/iocx/analysis/extended.py b/iocx/analysis/extended.py index e5e127e..ef66053 100644 --- a/iocx/analysis/extended.py +++ b/iocx/analysis/extended.py @@ -1,36 +1,31 @@ # Copyright (c) 2026 MalX Labs and contributors # SPDX-License-Identifier: MPL-2.0 +""" +Project public metadata into Detection-shaped output for downstream consumers. + +This module does NOT compute new information about the PE; it restructures +the already-extracted public metadata into the Detection format expected by +the CLI and detection consumer API. + +The primary additions made here are: +- Derived statistics (counts, entropy ranges) +- Sorted/grouped views (DLLs grouped by name) +- Shape conversion from metadata blocks to Detection records + +Anything that decodes raw PE structure (e.g., subsystem names, machine +types, DLL characteristics) belongs in the parser layer (iocx.parsers), +not here. If you find yourself adding decoding logic here, consider whether +it should be in pe_parser / pe_constants instead. +""" + from dataclasses import asdict from iocx.models import Detection -# Optional: translate machine + subsystem for readability -_MACHINE_MAP = { - 0x014c: "x86", - 0x8664: "AMD64", - 0x0200: "IA64", -} - -_SUBSYSTEM_MAP = { - 1: "Native", - 2: "Windows GUI", - 3: "Windows CUI", - 5: "OS/2 CUI", - 7: "POSIX CUI", - 9: "Windows CE GUI", - 10: "EFI Application", - 11: "EFI Boot Service Driver", - 12: "EFI Runtime Driver", - 14: "EFI ROM", - 16: "Xbox", -} def analyse_extended(pe, metadata, strings): detections = [] - # - # Summary block - # import_details = metadata.get("import_details", []) delayed_imports = metadata.get("delayed_imports", []) bound_imports = metadata.get("bound_imports", []) @@ -39,6 +34,9 @@ def analyse_extended(pe, metadata, strings): tls = metadata.get("tls") signatures = metadata.get("signatures", []) + # + # Summary block — derived statistics on the metadata lists + # detections.append( Detection( category="pe_metadata", @@ -59,21 +57,17 @@ def analyse_extended(pe, metadata, strings): ) # - # Grouped imports + # Grouped imports — group by DLL with sorted function lists # grouped = {} for imp in import_details: dll = imp["dll"] func = imp["function"] ordinal = imp["ordinal"] - - # Represent ordinal-only imports as "#123" if func is None and ordinal is not None: func = f"#{ordinal}" - grouped.setdefault(dll, []).append(func) - # Sort DLLs and functions for stable output for dll in sorted(grouped.keys(), key=str.lower): funcs = sorted(grouped[dll], key=lambda x: (x.startswith("#"), x.lower())) detections.append( @@ -87,7 +81,9 @@ def analyse_extended(pe, metadata, strings): ) # - # Delayed imports + # Delayed imports — same grouping pattern as imports + # Note: full structural validation of delay-load tables is deferred + # to a future requirement. # if delayed_imports: grouped_delayed = {} @@ -112,7 +108,7 @@ def analyse_extended(pe, metadata, strings): ) # - # Bound imports + # Bound imports — sorted by DLL name # if bound_imports: detections.append( @@ -122,17 +118,22 @@ def analyse_extended(pe, metadata, strings): start=0, end=0, metadata={ - "entries": sorted(bound_imports, key=lambda x: x["dll"].lower() if x["dll"] else "") + "entries": sorted( + bound_imports, + key=lambda x: x["dll"].lower() if x["dll"] else "", + ) }, ) ) # # Exports summary + # Note: this is a metadata view. Structural validity of the export + # table is reported separately via the validator's reason codes + # (EXPORT_DIRECTORY_INVALID_HEADER, EXPORT_NAME_RVA_INVALID, etc.). # export_names = [e["name"] for e in exports if e.get("name")] forwarded = [e for e in exports if e.get("forwarder")] - detections.append( Detection( category="pe_metadata", @@ -149,6 +150,8 @@ def analyse_extended(pe, metadata, strings): # # TLS directory + # Note: depends on the TLS parsing work currently deferred. Current + # output reflects whatever the existing TLS extraction produces. # if tls: detections.append( @@ -162,28 +165,24 @@ def analyse_extended(pe, metadata, strings): ) # - # Header (with human-friendly translations) + # Header — verbatim pass-through. The parser layer now provides + # subsystem_name and (after the machine decoding move) machine_name. # header = metadata.get("header", {}) - machine = header.get("machine") or 0 - subsystem = header.get("subsystem") or 0 - - header_pretty = dict(header) - header_pretty["machine_human"] = _MACHINE_MAP.get(machine, f"0x{machine:04x}") - header_pretty["subsystem_human"] = _SUBSYSTEM_MAP.get(subsystem, subsystem) - - detections.append( - Detection( - category="pe_metadata", - value="header", - start=0, - end=0, - metadata=header_pretty, + if header: + detections.append( + Detection( + category="pe_metadata", + value="header", + start=0, + end=0, + metadata=header, + ) ) - ) # - # Optional Header + # Optional Header — verbatim pass-through. The parser layer provides + # decoded DLL characteristics flags and sizing data. # optional_header = metadata.get("optional_header") if optional_header: @@ -198,7 +197,7 @@ def analyse_extended(pe, metadata, strings): ) # - # Rich Header + # Rich Header — verbatim pass-through. # rich_header = metadata.get("rich_header") if rich_header: @@ -213,7 +212,7 @@ def analyse_extended(pe, metadata, strings): ) # - # Digital Signature + # Digital Signature — verbatim pass-through with a presence flag. # if signatures: detections.append( @@ -230,11 +229,26 @@ def analyse_extended(pe, metadata, strings): ) # - # Resource summary + # Resource summary — derived statistics on the resources list # if resources: types = sorted({r["type"] for r in resources}) - entropies = [r["entropy"] for r in resources] + # Resources with computation errors have entropy = None; + # exclude them from statistics so a single failure doesn't + # poison the aggregate values. + valid_entropies = [r["entropy"] for r in resources if r["entropy"] is not None] + if valid_entropies: + entropy_stats = { + "entropy_min": min(valid_entropies), + "entropy_max": max(valid_entropies), + "entropy_avg": sum(valid_entropies) / len(valid_entropies), + } + else: + entropy_stats = { + "entropy_min": None, + "entropy_max": None, + "entropy_avg": None, + } detections.append( Detection( category="pe_metadata", @@ -244,9 +258,7 @@ def analyse_extended(pe, metadata, strings): metadata={ "count": len(resources), "types": types, - "entropy_min": min(entropies), - "entropy_max": max(entropies), - "entropy_avg": sum(entropies) / len(entropies), + **entropy_stats, }, ) ) From 3dbe39dcf58dde3aa4a8cb9f78e3589861ae4cfd Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 29 Jun 2026 15:32:42 +0100 Subject: [PATCH 21/35] Expand MACHINE_NAMES to cover the full PE spec --- iocx/parsers/pe_constants.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/iocx/parsers/pe_constants.py b/iocx/parsers/pe_constants.py index 53fdb07..0972f44 100644 --- a/iocx/parsers/pe_constants.py +++ b/iocx/parsers/pe_constants.py @@ -39,11 +39,38 @@ 0x8000: "TERMINAL_SERVER_AWARE", } -# Moved from extended layer +# Moved from extended layer - expanded to cover the full PE spec MACHINE_NAMES = { - 0x014c: "x86", - 0x8664: "AMD64", + 0x0000: "UNKNOWN", + 0x014C: "I386", + 0x0162: "R3000", + 0x0166: "R4000", + 0x0168: "R10000", + 0x0169: "WCEMIPSV2", + 0x0184: "ALPHA", + 0x01A2: "SH3", + 0x01A3: "SH3DSP", + 0x01A6: "SH4", + 0x01A8: "SH5", + 0x01C0: "ARM", + 0x01C2: "THUMB", + 0x01C4: "ARMNT", + 0x01D3: "AM33", + 0x01F0: "POWERPC", + 0x01F1: "POWERPCFP", 0x0200: "IA64", + 0x0266: "MIPS16", + 0x0366: "MIPSFPU", + 0x0466: "MIPSFPU16", + 0x0EBC: "EBC", + 0x5032: "RISCV32", + 0x5064: "RISCV64", + 0x5128: "RISCV128", + 0x6232: "LOONGARCH32", + 0x6264: "LOONGARCH64", + 0x8664: "AMD64", + 0xAA64: "ARM64", + 0xC0EE: "CEE", } # Mask covering all known DLL characteristics bits From 8795b386200e8fde0d644eda68cdbed4d84b7dbb Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 29 Jun 2026 16:09:29 +0100 Subject: [PATCH 22/35] Refresh snapshots to capture new optional_header / header fields --- .../layer1_core/clean_iocx_demo.core.json | 627 +++++++++--------- .../layer2_edge/load_config_clang.full.json | 30 +- .../load_config_cookie_valid.full.json | 30 +- .../load_config_full_msvc.full.json | 30 +- .../load_config_large_padded.full.json | 30 +- .../load_config_minimal_mingw.full.json | 30 +- .../load_config_seh_table.full.json | 30 +- .../broken_rva_addresses.full.json | 30 +- .../corrupted_data_directories.full.json | 30 +- .../crypto_entropy_payload.full.json | 36 +- .../directory_raw_mismatch.full.json | 30 +- .../directory_zero_size_nonzero_rva.full.json | 30 +- .../fixture_000_entrypoint_zero.full.json | 30 +- .../fixture_001_entrypoint_negative.full.json | 30 +- ...ixture_002_entrypoint_in_headers.full.json | 30 +- ..._entrypoint_gap_between_sections.full.json | 30 +- ..._004_entrypoint_non_exec_section.full.json | 30 +- .../fixture_005_entrypoint_rsrc.full.json | 30 +- ...xture_006_entrypoint_discardable.full.json | 30 +- ...7_entrypoint_zero_length_section.full.json | 30 +- ...8_entrypoint_beyond_virtual_size.full.json | 30 +- ...ixture_009_entrypoint_in_overlay.full.json | 30 +- .../fixture_010_sections_rwx.full.json | 30 +- ...xture_011_sections_code_not_exec.full.json | 30 +- ...e_012_sections_codelike_not_exec.full.json | 30 +- ...ture_013_sections_non_ascii_name.full.json | 30 +- .../fixture_014_sections_empty_name.full.json | 30 +- ...re_015_sections_impossible_flags.full.json | 30 +- ...ture_016_sections_raw_misaligned.full.json | 30 +- ...ure_017_sections_overlap_headers.full.json | 30 +- ...fixture_018_sections_zero_length.full.json | 30 +- ...fixture_019_sections_raw_overlap.full.json | 30 +- ...ure_020_sections_virtual_overlap.full.json | 30 +- ...re_021_sections_out_of_order_raw.full.json | 30 +- ...22_sections_out_of_order_virtual.full.json | 30 +- ...ure_023_sections_negative_fields.full.json | 30 +- ..._024_opt_size_of_image_too_small.full.json | 30 +- ...5_opt_size_of_headers_misaligned.full.json | 30 +- ...26_opt_size_of_headers_too_small.full.json | 30 +- ...27_opt_section_alignment_invalid.full.json | 30 +- ...e_028_opt_file_alignment_invalid.full.json | 30 +- ...re_029_opt_size_fields_too_small.full.json | 30 +- ...re_030_opt_image_base_misaligned.full.json | 30 +- ...fixture_031_opt_num_dirs_invalid.full.json | 30 +- ...xture_032_opt_num_dirs_too_small.full.json | 30 +- ...033_opt_size_of_image_misaligned.full.json | 30 +- .../fixture_034_ddir_negative_rva.full.json | 30 +- .../fixture_035_ddir_negative_size.full.json | 30 +- .../fixture_036_ddir_zero_zero.full.json | 30 +- ...e_037_ddir_zero_rva_nonzero_size.full.json | 30 +- ...e_038_ddir_zero_size_nonzero_rva.full.json | 30 +- .../fixture_039_ddir_in_headers.full.json | 30 +- .../fixture_040_ddir_out_of_range.full.json | 30 +- .../fixture_041_ddir_raw_mismatch.full.json | 30 +- .../fixture_042_ddir_in_overlay.full.json | 30 +- .../fixture_043_ddir_not_mapped.full.json | 30 +- .../fixture_044_ddir_spans_sections.full.json | 30 +- .../fixture_045_ddir_overlap.full.json | 30 +- .../franken_malformed_pe.full.json | 32 +- .../franken_malformed_pe.pe32.full.json | 30 +- .../franken_url_domain_ip.full.json | 36 +- .../heuristic_rich.full.json | 38 +- .../invalid_optional_header.full.json | 30 +- .../invalid_optional_header.pe32.full.json | 30 +- .../invalid_section_alignment.full.json | 30 +- .../load_config_cookie_too_small.full.json | 30 +- ...nfig_malformed_cookie_in_overlay.full.json | 30 +- ..._config_malformed_cookie_invalid.full.json | 30 +- ..._malformed_guard_cf_inconsistent.full.json | 30 +- ...oad_config_malformed_seh_invalid.full.json | 30 +- ...g_malformed_size_exceeds_section.full.json | 30 +- ..._config_malformed_size_too_small.full.json | 30 +- .../load_config_malformed_truncated.full.json | 30 +- .../load_config_rva_negative.full.json | 30 +- .../load_config_rva_zero.full.json | 30 +- ...fig_zero_size_but_fields_present.full.json | 30 +- ...oad_config_zero_size_invalid_rva.full.json | 30 +- .../load_config_zero_size_valid_rva.full.json | 30 +- .../malformed_domain.full.json | 36 +- .../malformed_import_table.full.json | 30 +- .../layer3_adversarial/malformed_ip.full.json | 38 +- .../malformed_url.full.json | 36 +- .../overlapping_sections.full.json | 30 +- .../packed_lookalike.full.json | 30 +- .../string_obfuscation_tricks.full.json | 36 +- .../truncated_rich_header.full.json | 32 +- .../upx_name_only.full.json | 30 +- 87 files changed, 2517 insertions(+), 740 deletions(-) diff --git a/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json b/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json index e0217fc..b626dab 100644 --- a/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json +++ b/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json @@ -1,308 +1,323 @@ { - "file": "tests/contract/fixtures/layer1_core/clean_iocx_demo.core.exe", - "type": "PE", - "iocs": { - "urls": [], - "domains": [], - "ips": [], - "hashes": [], - "emails": [], - "filepaths": [ - "C:\\Users\\Public\\Documents\\iocx_demo.exe" - ], - "base64": [], - "crypto.btc": [], - "crypto.eth": [] - }, - "metadata": { - "file_type": "PE", - "imports": [ - "KERNEL32.dll", - "msvcrt.dll" - ], - "sections": [ - ".text", - ".data", - ".rdata", - ".pdata", - ".xdata", - ".bss", - ".idata", - ".CRT", - ".tls", - ".reloc" - ], - "resources": [], - "resource_strings": [], - "import_details": [ - { - "dll": "KERNEL32.dll", - "function": "DeleteCriticalSection", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "EnterCriticalSection", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "GetLastError", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "GetSystemTime", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "InitializeCriticalSection", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "IsDBCSLeadByteEx", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "LeaveCriticalSection", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "MultiByteToWideChar", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "SetUnhandledExceptionFilter", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "Sleep", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "TlsGetValue", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "VirtualProtect", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "VirtualQuery", - "ordinal": null - }, - { - "dll": "KERNEL32.dll", - "function": "WideCharToMultiByte", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "__C_specific_handler", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "___lc_codepage_func", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "___mb_cur_max_func", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "__getmainargs", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "__initenv", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "__iob_func", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "__set_app_type", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "__setusermatherr", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_amsg_exit", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_cexit", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_commode", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_errno", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_fmode", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_initterm", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_lock", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_onexit", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "_unlock", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "abort", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "calloc", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "exit", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "fprintf", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "fputc", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "free", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "fwrite", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "localeconv", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "malloc", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "memcpy", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "memset", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "signal", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "strerror", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "strlen", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "strncmp", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "vfprintf", - "ordinal": null - }, - { - "dll": "msvcrt.dll", - "function": "wcslen", - "ordinal": null - } - ], - "delayed_imports": [], - "bound_imports": [], - "exports": [], - "tls": { - "start_address": 5368770560, - "end_address": 5368770568, - "callbacks": 5368766520 - }, - "header": { - "entry_point": 5136, - "image_base": 5368709120, - "subsystem": 3, - "timestamp": 1776246181, - "machine": 34404, - "characteristics": 558 - }, - "optional_header": { - "section_alignment": 4096, - "file_alignment": 512, - "size_of_image": 69632, - "size_of_headers": 1024, - "linker_version": "2.41", - "os_version": "4.0", - "subsystem_version": "5.2" - }, - "rich_header": null, - "signatures": [], - "has_signature": false - } + "file": "tests/contract/fixtures/layer1_core/clean_iocx_demo.core.exe", + "type": "PE", + "iocs": { + "urls": [], + "domains": [], + "ips": [], + "hashes": [], + "emails": [], + "filepaths": [ + "C:\\Users\\Public\\Documents\\iocx_demo.exe" + ], + "base64": [], + "crypto.btc": [], + "crypto.eth": [] + }, + "metadata": { + "file_type": "PE", + "imports": [ + "KERNEL32.dll", + "msvcrt.dll" + ], + "sections": [ + ".text", + ".data", + ".rdata", + ".pdata", + ".xdata", + ".bss", + ".idata", + ".CRT", + ".tls", + ".reloc" + ], + "resources": [], + "resource_strings": [], + "import_details": [ + { + "dll": "KERNEL32.dll", + "function": "DeleteCriticalSection", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "EnterCriticalSection", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "GetLastError", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "GetSystemTime", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "InitializeCriticalSection", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "IsDBCSLeadByteEx", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "LeaveCriticalSection", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "MultiByteToWideChar", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "SetUnhandledExceptionFilter", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "Sleep", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "TlsGetValue", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "VirtualProtect", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "VirtualQuery", + "ordinal": null + }, + { + "dll": "KERNEL32.dll", + "function": "WideCharToMultiByte", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "__C_specific_handler", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "___lc_codepage_func", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "___mb_cur_max_func", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "__getmainargs", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "__initenv", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "__iob_func", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "__set_app_type", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "__setusermatherr", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_amsg_exit", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_cexit", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_commode", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_errno", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_fmode", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_initterm", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_lock", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_onexit", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "_unlock", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "abort", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "calloc", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "exit", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "fprintf", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "fputc", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "free", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "fwrite", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "localeconv", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "malloc", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "memcpy", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "memset", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "signal", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "strerror", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "strlen", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "strncmp", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "vfprintf", + "ordinal": null + }, + { + "dll": "msvcrt.dll", + "function": "wcslen", + "ordinal": null + } + ], + "delayed_imports": [], + "bound_imports": [], + "exports": [], + "tls": { + "start_address": 5368770560, + "end_address": 5368770568, + "callbacks": 5368766520 + }, + "header": { + "entry_point": 5136, + "image_base": 5368709120, + "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", + "timestamp": 1776246181, + "machine": 34404, + "machine_name": "AMD64", + "characteristics": 558 + }, + "optional_header": { + "section_alignment": 4096, + "file_alignment": 512, + "size_of_image": 69632, + "size_of_headers": 1024, + "linker_version": "2.41", + "os_version": "4.0", + "subsystem_version": "5.2", + "dll_characteristics": 352, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "DYNAMIC_BASE", + "NX_COMPAT" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 2097152, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 + }, + "rich_header": null, + "signatures": [], + "has_signature": false + } } diff --git a/tests/contract/snapshots/layer2_edge/load_config_clang.full.json b/tests/contract/snapshots/layer2_edge/load_config_clang.full.json index d1b0b92..b0ca6b8 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_clang.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_clang.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json b/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json index cc30e57..725727e 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json b/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json index 0977fec..5bbd33c 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json b/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json index a1dc1d9..3b25e88 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json b/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json index 89f2630..e5b2cda 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json b/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json index 8f46d2f..adbc2eb 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json index 9e93e75..fab3a1c 100644 --- a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json +++ b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json b/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json index 3ee05c1..603309f 100644 --- a/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json +++ b/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json @@ -29,8 +29,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -40,7 +42,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -94,11 +105,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -113,7 +124,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json index da06ea0..204ca37 100644 --- a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json +++ b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json @@ -273,8 +273,10 @@ "entry_point": 4732, "image_base": 5368709120, "subsystem": 2, + "subsystem_name": "WINDOWS_GUI", "timestamp": 1776422599, "machine": 34404, + "machine_name": "AMD64", "characteristics": 35 }, "optional_header": { @@ -284,7 +286,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 }, "rich_header": { "key": "291fb053", @@ -529,11 +543,11 @@ "entry_point": 4732, "image_base": 5368709120, "subsystem": 2, + "subsystem_name": "WINDOWS_GUI", "timestamp": 1776422599, "machine": 34404, - "characteristics": 35, - "machine_human": "AMD64", - "subsystem_human": "Windows GUI" + "machine_name": "AMD64", + "characteristics": 35 } }, { @@ -548,7 +562,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json b/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json index fc9257b..74b2bc7 100644 --- a/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json +++ b/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json @@ -29,8 +29,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -40,7 +42,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -94,11 +105,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -113,7 +124,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json index c8d54e7..745952a 100644 --- a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json index b5651d9..3d17ebf 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json index d45bfb9..7e6447e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json @@ -31,8 +31,10 @@ "entry_point": 4294967295, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 4294967295, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json index 21f0032..a5a4d8a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json @@ -31,8 +31,10 @@ "entry_point": 512, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 512, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json index 8368964..e92bf5a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json @@ -31,8 +31,10 @@ "entry_point": 7936, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 7936, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json index bfab447..579fed2 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json @@ -31,8 +31,10 @@ "entry_point": 8208, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 8208, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json index 300390d..8071d3a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json @@ -31,8 +31,10 @@ "entry_point": 12320, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 12320, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json index e3e8470..8e20fe3 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json @@ -31,8 +31,10 @@ "entry_point": 4112, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 4112, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json index d6e6cc4..082e5a4 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json @@ -31,8 +31,10 @@ "entry_point": 4096, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 4096, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json index 79ca3c6..ab38a91 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json @@ -31,8 +31,10 @@ "entry_point": 6144, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 6144, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json index 648199f..fdd57b6 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json @@ -31,8 +31,10 @@ "entry_point": 20480, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 20480, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json index aa79e7a..f79d137 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -121,11 +132,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -140,7 +151,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json index 23f80c5..668cf6a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json index 4622dee..1dd4f49 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json index 98fb971..fb8cb1b 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json index a56cd50..08fbf90 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json index ce9b45f..77355f3 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -121,11 +132,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -140,7 +151,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json index 4c3d0d3..c98efee 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json index a757fd0..d959f00 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json index 9a3a933..2b655bc 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json index ba9d367..7b5c66d 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json index 0c22266..209fd57 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -129,11 +140,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -148,7 +159,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json index 71ad112..690da5b 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json index d8547cf..c93ec7c 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json index ff74596..4d94344 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json @@ -27,8 +27,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -38,7 +40,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -84,11 +95,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -103,7 +114,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json index 6ca33b4..6eea2ca 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json index abe9cc2..bd48465 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json index 5d36dd4..595f1fb 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json index abf03a8..a41d54e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json index dd80696..d9cb6f4 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1536, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1536, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json index 91c4de0..22b3fc8 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json index 91a28bb..fc3cdb0 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4198964, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4198964, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json index 88e8d88..ba2eccd 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json index d9271d2..068e3a3 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json index 35c6bd1..6240b9e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json index 7fcb1ba..ecf8645 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json index ccfc603..0dafe30 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json index 3490e58..959304c 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json index 6b2fa63..02e8f93 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json index 004a6cb..4f2fc11 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json index 4d7fc6c..d2202fe 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json index 4a46e73..3a3086e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json index ce5083f..a0d769a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json index b1cbca2..a0b304b 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json index d44eb49..399ed1c 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json index da68eb0..9281212 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json index 89a2b7d..dce5414 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json @@ -31,8 +31,10 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 258 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -110,11 +121,11 @@ "entry_point": 0, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 258, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 258 } }, { @@ -129,7 +140,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json index a3e6522..ea0ba88 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json @@ -32,8 +32,10 @@ "entry_point": 12288, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -43,7 +45,16 @@ "size_of_headers": 512, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -137,11 +148,11 @@ "entry_point": 12288, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -156,7 +167,16 @@ "size_of_headers": 512, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], @@ -232,7 +252,6 @@ "section_b": ".rdata" } }, - { "value": "pe_structure_anomaly", "start": 0, @@ -244,7 +263,6 @@ "max_section_end": 11776 } }, - { "value": "pe_structure_anomaly", "start": 0, diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json index 40ea7bc..176c9ef 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json @@ -32,8 +32,10 @@ "entry_point": 12288, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 2 }, "optional_header": { @@ -43,7 +45,16 @@ "size_of_headers": 512, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -137,11 +148,11 @@ "entry_point": 12288, "image_base": 4194304, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 2, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 2 } }, { @@ -156,7 +167,16 @@ "size_of_headers": 512, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json index 3f6096d..0116f28 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json @@ -309,8 +309,10 @@ "entry_point": 5404, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777288054, "machine": 34404, + "machine_name": "AMD64", "characteristics": 35 }, "optional_header": { @@ -320,7 +322,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 }, "rich_header": { "key": "291fb073", @@ -566,11 +580,11 @@ "entry_point": 5404, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777288054, "machine": 34404, - "characteristics": 35, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 35 } }, { @@ -585,7 +599,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json b/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json index 5096817..b5f0997 100644 --- a/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json +++ b/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json @@ -327,8 +327,10 @@ "entry_point": 5088, "image_base": 5368709120, "subsystem": 2, + "subsystem_name": "WINDOWS_GUI", "timestamp": 1776351178, "machine": 34404, + "machine_name": "AMD64", "characteristics": 38 }, "optional_header": { @@ -338,7 +340,20 @@ "size_of_headers": 1536, "linker_version": "2.41", "os_version": "4.0", - "subsystem_version": "5.2" + "subsystem_version": "5.2", + "dll_characteristics": 352, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "DYNAMIC_BASE", + "NX_COMPAT" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 2097152, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 }, "rich_header": null, "signatures": [], @@ -633,11 +648,11 @@ "entry_point": 5088, "image_base": 5368709120, "subsystem": 2, + "subsystem_name": "WINDOWS_GUI", "timestamp": 1776351178, "machine": 34404, - "characteristics": 38, - "machine_human": "AMD64", - "subsystem_human": "Windows GUI" + "machine_name": "AMD64", + "characteristics": 38 } }, { @@ -652,7 +667,20 @@ "size_of_headers": 1536, "linker_version": "2.41", "os_version": "4.0", - "subsystem_version": "5.2" + "subsystem_version": "5.2", + "dll_characteristics": 352, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "DYNAMIC_BASE", + "NX_COMPAT" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 2097152, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json index 5668e7a..0e4caa2 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json @@ -27,8 +27,10 @@ "entry_point": 2415919104, "image_base": 74565, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -38,7 +40,16 @@ "size_of_headers": 2048, "linker_version": "0.0", "os_version": "10.0", - "subsystem_version": "99.99" + "subsystem_version": "99.99", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -84,11 +95,11 @@ "entry_point": 2415919104, "image_base": 74565, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -103,7 +114,16 @@ "size_of_headers": 2048, "linker_version": "0.0", "os_version": "10.0", - "subsystem_version": "99.99" + "subsystem_version": "99.99", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json index c7e5519..99f66c6 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json @@ -29,8 +29,10 @@ "entry_point": 2415919104, "image_base": 74565, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, + "machine_name": "I386", "characteristics": 2 }, "optional_header": { @@ -40,7 +42,16 @@ "size_of_headers": 2048, "linker_version": "0.0", "os_version": "10.0", - "subsystem_version": "99.99" + "subsystem_version": "99.99", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -94,11 +105,11 @@ "entry_point": 2415919104, "image_base": 74565, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 332, - "characteristics": 2, - "machine_human": "x86", - "subsystem_human": "Windows CUI" + "machine_name": "I386", + "characteristics": 2 } }, { @@ -113,7 +124,16 @@ "size_of_headers": 2048, "linker_version": "0.0", "os_version": "10.0", - "subsystem_version": "99.99" + "subsystem_version": "99.99", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json index fb4ac34..d2997a3 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json @@ -29,8 +29,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -40,7 +42,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -94,11 +105,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -113,7 +124,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json index 420220d..9597a2c 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json index e315af8..7333839 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json index 52a1b47..0cbad97 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json index b60b325..91e0a97 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json index 650bfad..f41c478 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json index 92e6523..e843293 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json index 522eac2..e3093c4 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json index 3cec05d..0b1d2ca 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json index c8899a4..fb776cf 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json index 86b4cf2..b2b7cd3 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json index f2c781c..9b46425 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json index 9039922..09f061e 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json index bfd6533..f1ebb87 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 34 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -102,11 +113,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 34, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 34 } }, { @@ -121,7 +132,16 @@ "size_of_headers": 1024, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json index cf85045..4f175ff 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json @@ -287,8 +287,10 @@ "entry_point": 4932, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777298904, "machine": 34404, + "machine_name": "AMD64", "characteristics": 35 }, "optional_header": { @@ -298,7 +300,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 }, "rich_header": { "key": "291fb073", @@ -544,11 +558,11 @@ "entry_point": 4932, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777298904, "machine": 34404, - "characteristics": 35, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 35 } }, { @@ -563,7 +577,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json index f393720..b00f5e2 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json @@ -29,8 +29,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -40,7 +42,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -94,11 +105,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -113,7 +124,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json index b5fc2a8..8376f85 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json @@ -293,8 +293,10 @@ "entry_point": 5032, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777299340, "machine": 34404, + "machine_name": "AMD64", "characteristics": 35 }, "optional_header": { @@ -304,7 +306,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 }, "rich_header": { "key": "291fb073", @@ -550,11 +564,11 @@ "entry_point": 5032, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777299340, "machine": 34404, - "characteristics": 35, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 35 } }, { @@ -569,7 +583,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 } }, { @@ -656,7 +682,7 @@ "function": "QueryPerformanceCounter" } }, - { + { "value": "pe_structure_anomaly", "start": 0, "end": 0, diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json index b7d802b..1fb0a08 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json @@ -291,8 +291,10 @@ "entry_point": 4904, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777300501, "machine": 34404, + "machine_name": "AMD64", "characteristics": 35 }, "optional_header": { @@ -302,7 +304,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 }, "rich_header": { "key": "291fb073", @@ -548,11 +562,11 @@ "entry_point": 4904, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 1777300501, "machine": 34404, - "characteristics": 35, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 35 } }, { @@ -567,7 +581,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json b/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json index c4fdf90..fea2f64 100644 --- a/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json @@ -30,8 +30,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -41,7 +43,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -121,11 +132,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -140,7 +151,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json b/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json index 4652727..6d07915 100644 --- a/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json +++ b/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json @@ -33,8 +33,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -44,7 +46,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -142,11 +153,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -161,7 +172,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], diff --git a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json index 957c692..fb53e75 100644 --- a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json +++ b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json @@ -284,8 +284,10 @@ "entry_point": 4804, "image_base": 5368709120, "subsystem": 2, + "subsystem_name": "WINDOWS_GUI", "timestamp": 1776422601, "machine": 34404, + "machine_name": "AMD64", "characteristics": 35 }, "optional_header": { @@ -295,7 +297,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 }, "rich_header": { "key": "291fb073", @@ -541,11 +555,11 @@ "entry_point": 4804, "image_base": 5368709120, "subsystem": 2, + "subsystem_name": "WINDOWS_GUI", "timestamp": 1776422601, "machine": 34404, - "characteristics": 35, - "machine_human": "AMD64", - "subsystem_human": "Windows GUI" + "machine_name": "AMD64", + "characteristics": 35 } }, { @@ -560,7 +574,19 @@ "size_of_headers": 1024, "linker_version": "14.44", "os_version": "6.0", - "subsystem_version": "6.0" + "subsystem_version": "6.0", + "dll_characteristics": 32800, + "dll_characteristics_flags": [ + "HIGH_ENTROPY_VA", + "TERMINAL_SERVER_AWARE" + ], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 1048576, + "stack_commit_size": 4096, + "heap_reserve_size": 1048576, + "heap_commit_size": 4096 } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json b/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json index d32a714..e7d3f86 100644 --- a/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json @@ -29,8 +29,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -40,7 +42,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -94,11 +105,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -113,12 +124,21 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], "heuristics": [ - { + { "value": "pe_structure_anomaly", "start": 0, "end": 0, diff --git a/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json b/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json index d96ebf1..821c000 100644 --- a/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json +++ b/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json @@ -31,8 +31,10 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, + "machine_name": "AMD64", "characteristics": 2 }, "optional_header": { @@ -42,7 +44,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 }, "rich_header": null, "signatures": [], @@ -129,11 +140,11 @@ "entry_point": 4096, "image_base": 5368709120, "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, "machine": 34404, - "characteristics": 2, - "machine_human": "AMD64", - "subsystem_human": "Windows CUI" + "machine_name": "AMD64", + "characteristics": 2 } }, { @@ -148,7 +159,16 @@ "size_of_headers": 512, "linker_version": "0.0", "os_version": "0.0", - "subsystem_version": "0.0" + "subsystem_version": "0.0", + "dll_characteristics": 0, + "dll_characteristics_flags": [], + "dll_characteristics_unknown_bits": null, + "win32_version_value": 0, + "loader_flags": 0, + "stack_reserve_size": 0, + "stack_commit_size": 0, + "heap_reserve_size": 0, + "heap_commit_size": 0 } } ], From 7419a771170eb13b2e361d7823146692204ee1f2 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 29 Jun 2026 16:24:09 +0100 Subject: [PATCH 23/35] Fix extended metadata header test to meet new contract behaviour --- tests/unit/analysis/test_extended.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit/analysis/test_extended.py b/tests/unit/analysis/test_extended.py index d0a1f37..b13f508 100644 --- a/tests/unit/analysis/test_extended.py +++ b/tests/unit/analysis/test_extended.py @@ -118,7 +118,9 @@ def test_header_human_fields(): metadata = { "header": { "machine": 0x8664, # AMD64 - "subsystem": 3, # Windows CUI + "machine_name": "AMD64", + "subsystem": 3, # WINDOWS_CUI + "subsystem_name": "WINDOWS_CUI", "timestamp": 0, } } @@ -126,8 +128,8 @@ def test_header_human_fields(): result = analyse_extended(None, metadata, []) header = extract(result, "header")["metadata"] - assert header["machine_human"] == "AMD64" - assert header["subsystem_human"] == "Windows CUI" + assert header["machine_name"] == "AMD64" + assert header["subsystem_name"] == "WINDOWS_CUI" def test_optional_header_included(): From a83acda097656bfa17f349b93e0a5e62b5bc45f1 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 29 Jun 2026 16:32:34 +0100 Subject: [PATCH 24/35] Unit tests for the resource entropy guard refactor --- .../test_extended_resource_entropy.py | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 tests/unit/analysis/test_extended_resource_entropy.py diff --git a/tests/unit/analysis/test_extended_resource_entropy.py b/tests/unit/analysis/test_extended_resource_entropy.py new file mode 100644 index 0000000..9e74fe6 --- /dev/null +++ b/tests/unit/analysis/test_extended_resource_entropy.py @@ -0,0 +1,289 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Covers the entropy_min / entropy_max / entropy_avg computation introduced +during the extended.py refactor, which handles per-resource error tombstones +(entropy: None) without raising on min() / max() of an empty sequence. +""" + +from __future__ import annotations +from typing import Any, Dict, List, Optional +import pytest +from iocx.analysis.extended import analyse_extended + + +# ================================================================= +# Test helpers +# ================================================================= + +def _resource( + type_: str = "RT_RCDATA", + name: Optional[str] = None, + language: Optional[int] = 1033, + language_name: Optional[str] = "en-US", + codepage: Optional[int] = None, + size: Optional[int] = 100, + entropy: Optional[float] = 4.5, + rva: int = 0x1000, + raw_offset: Optional[int] = 0x400, + errors: Optional[List[str]] = None, +) -> Dict[str, Any]: + """Build a resource entry dict matching the public ResourceEntry shape.""" + return { + "type": type_, + "name": name, + "language": language, + "language_name": language_name, + "codepage": codepage, + "size": size, + "entropy": entropy, + "rva": rva, + "raw_offset": raw_offset, + "errors": errors, + } + + +def _metadata_with_resources(resources: List[Dict[str, Any]]) -> Dict[str, Any]: + """Build a minimal metadata dict containing only the resources field.""" + return { + "import_details": [], + "delayed_imports": [], + "bound_imports": [], + "exports": [], + "resources": resources, + "tls": None, + "signatures": [], + "header": {}, + "optional_header": None, + "rich_header": None, + } + + +def _resource_summary(detections: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Extract the resource-summary detection from the analyser output.""" + for d in detections: + if d["value"] == "resources": + return d["metadata"] + return None + + +# ================================================================= +# Happy path: all entropies present +# ================================================================= + +class TestAllEntropiesPresent: + + def test_single_resource_min_max_avg_equal(self): + resources = [_resource(entropy=4.5)] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary is not None + assert summary["count"] == 1 + assert summary["entropy_min"] == 4.5 + assert summary["entropy_max"] == 4.5 + assert summary["entropy_avg"] == 4.5 + + def test_multiple_resources_computes_stats(self): + resources = [ + _resource(type_="RT_ICON", entropy=2.0, rva=0x1000), + _resource(type_="RT_STRING", entropy=4.0, rva=0x2000), + _resource(type_="RT_VERSION", entropy=6.0, rva=0x3000), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary["count"] == 3 + assert summary["entropy_min"] == 2.0 + assert summary["entropy_max"] == 6.0 + assert summary["entropy_avg"] == 4.0 + + def test_entropy_avg_handles_floating_point_precision(self): + # Three values whose average is a repeating decimal + resources = [ + _resource(entropy=1.0, rva=0x1000), + _resource(entropy=2.0, rva=0x2000), + _resource(entropy=2.0, rva=0x3000), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + # 5.0 / 3 == 1.6666... + assert summary["entropy_avg"] == pytest.approx(5.0 / 3) + + +# ================================================================= +# Mixed: some entries have entropy None due to per-entry errors +# ================================================================= + +class TestMixedValidAndNoneEntropies: + + def test_excludes_none_entries_from_aggregates(self): + resources = [ + _resource(type_="RT_ICON", entropy=3.0, rva=0x1000), + _resource( + type_="RT_STRING", + entropy=None, + size=None, + rva=0x2000, + errors=["size_invalid"], + ), + _resource(type_="RT_VERSION", entropy=5.0, rva=0x3000), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + + # count reflects total resources (including the error one) + assert summary["count"] == 3 + + # entropy stats compute over only the valid two + assert summary["entropy_min"] == 3.0 + assert summary["entropy_max"] == 5.0 + assert summary["entropy_avg"] == 4.0 + + def test_single_valid_entropy_among_errors(self): + resources = [ + _resource(entropy=None, rva=0x1000, errors=["data_out_of_bounds"]), + _resource(entropy=7.5, rva=0x2000), + _resource(entropy=None, rva=0x3000, errors=["rva_invalid"]), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary["count"] == 3 + assert summary["entropy_min"] == 7.5 + assert summary["entropy_max"] == 7.5 + assert summary["entropy_avg"] == 7.5 + + def test_types_field_includes_error_entries(self): + """The types list is built over all resources, not just valid-entropy ones.""" + resources = [ + _resource(type_="RT_ICON", entropy=3.0, rva=0x1000), + _resource(type_="RT_CUSTOM", entropy=None, rva=0x2000, errors=["size_invalid"]), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert "RT_ICON" in summary["types"] + assert "RT_CUSTOM" in summary["types"] + + +# ================================================================= +# All entries have entropy None: aggregates must be None +# ================================================================= + +class TestAllEntropiesNone: + + def test_all_errored_resources_produce_none_stats(self): + resources = [ + _resource(entropy=None, rva=0x1000, errors=["size_invalid"]), + _resource(entropy=None, rva=0x2000, errors=["data_out_of_bounds"]), + _resource(entropy=None, rva=0x3000, errors=["rva_invalid"]), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary["count"] == 3 + assert summary["entropy_min"] is None + assert summary["entropy_max"] is None + assert summary["entropy_avg"] is None + + def test_single_errored_resource_produces_none_stats(self): + resources = [ + _resource(entropy=None, rva=0x1000, errors=["size_invalid"]), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary["count"] == 1 + assert summary["entropy_min"] is None + assert summary["entropy_max"] is None + assert summary["entropy_avg"] is None + + def test_no_raise_on_all_none_entropies(self): + """ + Regression guard: previous implementation would have raised + ValueError on min() / max() of an empty list. + """ + resources = [_resource(entropy=None, errors=["size_invalid"])] + # Must not raise + detections = analyse_extended(None, _metadata_with_resources(resources), []) + assert _resource_summary(detections) is not None + + +# ================================================================= +# Empty resources list: summary block is omitted +# ================================================================= + +class TestEmptyResources: + + def test_empty_resources_produces_no_summary(self): + """ + When the resources list is empty, the resource summary detection + is not emitted at all (per the `if resources:` guard). + """ + detections = analyse_extended(None, _metadata_with_resources([]), []) + assert _resource_summary(detections) is None + + +# ================================================================= +# Edge cases: zero and extreme entropy values +# ================================================================= + +class TestEntropyEdgeValues: + + def test_zero_entropy_included_in_stats(self): + """ + Entropy of exactly 0.0 (all-zero resource bytes) is a valid value, + not a missing-value sentinel. Must be included in aggregates. + """ + resources = [ + _resource(entropy=0.0, rva=0x1000), + _resource(entropy=4.0, rva=0x2000), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary["entropy_min"] == 0.0 + assert summary["entropy_max"] == 4.0 + assert summary["entropy_avg"] == 2.0 + + def test_max_entropy_included_in_stats(self): + """ + Entropy at the Shannon upper bound (8.0 for byte data) is valid. + """ + resources = [ + _resource(entropy=8.0, rva=0x1000), + _resource(entropy=4.0, rva=0x2000), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary["entropy_min"] == 4.0 + assert summary["entropy_max"] == 8.0 + assert summary["entropy_avg"] == 6.0 + + def test_all_zero_entropies(self): + """All-zero entropies still produce sensible stats.""" + resources = [ + _resource(entropy=0.0, rva=0x1000), + _resource(entropy=0.0, rva=0x2000), + ] + detections = analyse_extended(None, _metadata_with_resources(resources), []) + summary = _resource_summary(detections) + assert summary["entropy_min"] == 0.0 + assert summary["entropy_max"] == 0.0 + assert summary["entropy_avg"] == 0.0 + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + + def test_repeated_invocation_produces_identical_stats(self): + resources = [ + _resource(entropy=3.1, rva=0x1000), + _resource(entropy=None, rva=0x2000, errors=["size_invalid"]), + _resource(entropy=5.7, rva=0x3000), + _resource(entropy=2.4, rva=0x4000), + ] + metadata = _metadata_with_resources(resources) + + results = [analyse_extended(None, metadata, []) for _ in range(20)] + summaries = [_resource_summary(r) for r in results] + for s in summaries[1:]: + assert s == summaries[0] From 88f3eef7dbc24071e56f283e014db47f6c1b0bd0 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 29 Jun 2026 17:14:47 +0100 Subject: [PATCH 25/35] Add unit tests for optional_header / header parsing, and analysis.extended new behaviour. Coverage at 100% over 1275 tests --- tests/unit/analysis/test_extended.py | 226 +++++++ .../test_pe_optional_header_extended.py | 640 ++++++++++++++++++ 2 files changed, 866 insertions(+) create mode 100644 tests/unit/parsers/test_pe_optional_header_extended.py diff --git a/tests/unit/analysis/test_extended.py b/tests/unit/analysis/test_extended.py index b13f508..e544e58 100644 --- a/tests/unit/analysis/test_extended.py +++ b/tests/unit/analysis/test_extended.py @@ -184,3 +184,229 @@ def test_empty_metadata_produces_minimal_output(): assert summary["resource_count"] == 0 assert summary["has_tls"] is False assert summary["has_signature"] is False + + +class TestAnalyseExtendedHeaderPassthrough: + + def test_header_detection_matches_input_header_verbatim(self): + """Post-refactor, the extended header block is a verbatim pass-through + of the parser's header dict. No decoding, no enrichment.""" + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": { + "entry_point": 0x1000, + "image_base": 0x140000000, + "subsystem": 3, + "subsystem_name": "WINDOWS_CUI", + "timestamp": 1700000000, + "machine": 0x8664, + "machine_name": "AMD64", + "characteristics": 0x22, + }, + "optional_header": None, + "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + header_detection = next(d for d in detections if d["value"] == "header") + # The detection's metadata should equal the input header exactly + assert header_detection["metadata"] == metadata["header"] + + def test_no_subsystem_human_field_added(self): + """Post-refactor, the legacy subsystem_human field is removed.""" + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {"subsystem": 3, "subsystem_name": "WINDOWS_CUI"}, + "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + header_detection = next(d for d in detections if d["value"] == "header") + assert "subsystem_human" not in header_detection["metadata"] + + def test_no_machine_human_field_added(self): + """Post-refactor, the legacy machine_human field is removed.""" + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {"machine": 0x8664, "machine_name": "AMD64"}, + "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + header_detection = next(d for d in detections if d["value"] == "header") + assert "machine_human" not in header_detection["metadata"] + +class TestAnalyseExtendedSummary: + + def test_summary_counts_correct(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [ + {"dll": "a.dll", "function": "foo", "ordinal": None}, + {"dll": "a.dll", "function": "bar", "ordinal": None}, + {"dll": "b.dll", "function": "baz", "ordinal": None}, + ], + "delayed_imports": [ + {"dll": "c.dll", "function": "qux", "ordinal": None}, + ], + "bound_imports": [], + "exports": [{"name": "foo", "ordinal": 1, "forwarder": None}, + {"name": "bar", "ordinal": 2, "forwarder": None}], + "resources": [{ + "type": "RT_ICON", + "name": None, + "language": 1033, + "language_name": "en-US", + "codepage": None, + "size": 100, + "entropy": 4.5, + "rva": 0x1000, + "raw_offset": 0x400, + "errors": None, + }], + "tls": {"start_address": 1, "end_address": 2, "callbacks": None}, + "signatures": [{"signer": "test"}], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + summary = next(d for d in detections if d["value"] == "summary")["metadata"] + + assert summary["dll_count"] == 2 + assert summary["import_count"] == 3 + assert summary["delayed_import_count"] == 1 + assert summary["bound_import_count"] == 0 + assert summary["export_count"] == 2 + assert summary["resource_count"] == 1 + assert summary["has_tls"] is True + assert summary["has_signature"] is True + + def test_summary_with_no_data(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + summary = next(d for d in detections if d["value"] == "summary")["metadata"] + + assert summary["dll_count"] == 0 + assert summary["import_count"] == 0 + assert summary["has_tls"] is False + assert summary["has_signature"] is False + +class TestAnalyseExtendedImports: + + def test_imports_grouped_by_dll(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [ + {"dll": "a.dll", "function": "foo", "ordinal": None}, + {"dll": "b.dll", "function": "bar", "ordinal": None}, + {"dll": "a.dll", "function": "baz", "ordinal": None}, + ], + "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + imports = [d for d in detections if d["value"] == "imports"] + + # Two DLLs in alphabetical order + assert len(imports) == 2 + assert imports[0]["metadata"]["dll"] == "a.dll" + assert imports[1]["metadata"]["dll"] == "b.dll" + # Functions sorted alphabetically within each DLL + assert imports[0]["metadata"]["functions"] == ["baz", "foo"] + + def test_ordinal_only_imports_displayed_as_hash(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [ + {"dll": "a.dll", "function": None, "ordinal": 42}, + ], + "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + imports = next(d for d in detections if d["value"] == "imports") + assert imports["metadata"]["functions"] == ["#42"] + + def test_imports_with_mixed_name_and_ordinal_sorted(self): + """Named functions first, then ordinals.""" + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [ + {"dll": "a.dll", "function": None, "ordinal": 5}, + {"dll": "a.dll", "function": "Bar", "ordinal": None}, + {"dll": "a.dll", "function": "Alpha", "ordinal": None}, + ], + "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + imports = next(d for d in detections if d["value"] == "imports") + assert imports["metadata"]["functions"] == ["Alpha", "Bar", "#5"] + +class TestAnalyseExtendedConditionalSections: + + def test_no_delayed_imports_section_when_empty(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + delayed = [d for d in detections if d["value"] == "delayed_imports"] + assert delayed == [] + + def test_no_tls_section_when_none(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + tls = [d for d in detections if d["value"] == "tls_directory"] + assert tls == [] + + def test_no_signature_section_when_empty(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + sig = [d for d in detections if d["value"] == "signature"] + assert sig == [] + + def test_no_rich_header_section_when_none(self): + from iocx.analysis.extended import analyse_extended + + metadata = { + "import_details": [], "delayed_imports": [], "bound_imports": [], + "exports": [], "resources": [], "tls": None, "signatures": [], + "header": {}, "optional_header": None, "rich_header": None, + } + detections = analyse_extended(None, metadata, []) + rich = [d for d in detections if d["value"] == "rich_header"] + assert rich == [] diff --git a/tests/unit/parsers/test_pe_optional_header_extended.py b/tests/unit/parsers/test_pe_optional_header_extended.py new file mode 100644 index 0000000..8b490a9 --- /dev/null +++ b/tests/unit/parsers/test_pe_optional_header_extended.py @@ -0,0 +1,640 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Tests the _parse_optional_header and _parse_header routines plus the +constants module. Uses minimal duck-typed pe / OPTIONAL_HEADER / FILE_HEADER +objects to isolate parser logic from pefile's actual struct behaviour. + +Coverage targets: +- Constants module: tables and derived mask +- DLL characteristics decoding (empty, single, multi, unknown bits) +- Subsystem name resolution (known, unknown, edge values) +- Field extraction (existing fields preserved, new fields populated) +- Missing-field handling (None for new fields, 0 for existing) +- Determinism (repeated parse produces identical output) +- JSON-safety (output round-trips through json.dumps) +- Output contract (key set matches schema) +""" + +from __future__ import annotations +import json +import pytest +from typing import Any, Dict, List, Optional +from iocx.parsers.pe_constants import ( + DLL_CHARACTERISTICS_FLAGS, + DLL_CHARACTERISTICS_KNOWN_MASK, + SUBSYSTEM_NAMES, +) +from iocx.parsers.pe_parser import _parse_header, _parse_optional_header + + +# ================================================================= +# Test doubles +# ================================================================= + +class _FakeOH: + """ + Minimal duck-typed OPTIONAL_HEADER. Any field that's set on the instance + will be returned by getattr; any field that's not set returns the + getattr default (which is what _parse_optional_header relies on). + """ + def __init__(self, **fields): + for k, v in fields.items(): + setattr(self, k, v) + + +class _FakeFH: + """Minimal duck-typed FILE_HEADER.""" + def __init__(self, **fields): + for k, v in fields.items(): + setattr(self, k, v) + + +class _FakePE: + """Minimal duck-typed pe object.""" + def __init__(self, optional_header=None, file_header=None): + if optional_header is not None: + self.OPTIONAL_HEADER = optional_header + if file_header is not None: + self.FILE_HEADER = file_header + + +# Common builder for a fully-populated OH covering all extracted fields +def _full_oh(**overrides) -> _FakeOH: + defaults = dict( + SectionAlignment=4096, + FileAlignment=512, + SizeOfImage=24576, + SizeOfHeaders=1024, + MajorLinkerVersion=14, + MinorLinkerVersion=42, + MajorOperatingSystemVersion=6, + MinorOperatingSystemVersion=0, + MajorSubsystemVersion=6, + MinorSubsystemVersion=0, + Subsystem=3, + DllCharacteristics=0x4160, + Win32VersionValue=0, + LoaderFlags=0, + SizeOfStackReserve=0x100000, + SizeOfStackCommit=0x1000, + SizeOfHeapReserve=0x100000, + SizeOfHeapCommit=0x1000, + AddressOfEntryPoint=0x1000, + ImageBase=0x140000000, + ) + defaults.update(overrides) + return _FakeOH(**defaults) + + +def _full_fh(**overrides) -> _FakeFH: + defaults = dict( + TimeDateStamp=1700000000, + Machine=0x8664, + Characteristics=0x22, + ) + defaults.update(overrides) + return _FakeFH(**defaults) + + +# ================================================================= +# Constants module +# ================================================================= + +class TestConstants: + + def test_subsystem_names_contains_well_known_values(self): + assert SUBSYSTEM_NAMES[3] == "WINDOWS_CUI" + assert SUBSYSTEM_NAMES[2] == "WINDOWS_GUI" + assert SUBSYSTEM_NAMES[1] == "NATIVE" + assert SUBSYSTEM_NAMES[10] == "EFI_APPLICATION" + + def test_dll_characteristics_flags_contains_well_known_bits(self): + assert DLL_CHARACTERISTICS_FLAGS[0x0040] == "DYNAMIC_BASE" + assert DLL_CHARACTERISTICS_FLAGS[0x0100] == "NX_COMPAT" + assert DLL_CHARACTERISTICS_FLAGS[0x4000] == "GUARD_CF" + assert DLL_CHARACTERISTICS_FLAGS[0x8000] == "TERMINAL_SERVER_AWARE" + + def test_subsystem_table_covers_pe_spec(self): + """The SUBSYSTEM_NAMES table covers the well-known IMAGE_SUBSYSTEM_* values.""" + required_subsystems = {0, 1, 2, 3, 5, 7, 8, 9, 10, 11, 12, 13, 14, 16} + assert required_subsystems.issubset(set(SUBSYSTEM_NAMES.keys())) + + def test_known_mask_covers_all_listed_bits(self): + expected_mask = 0 + for bit in DLL_CHARACTERISTICS_FLAGS: + expected_mask |= bit + assert DLL_CHARACTERISTICS_KNOWN_MASK == expected_mask + + def test_known_mask_is_subset_of_u16(self): + # All defined DLL characteristics fit in a u16 + assert DLL_CHARACTERISTICS_KNOWN_MASK <= 0xFFFF + + def test_subsystem_names_has_no_duplicate_values(self): + values = list(SUBSYSTEM_NAMES.values()) + assert len(values) == len(set(values)) + + def test_dll_characteristics_flags_has_no_duplicate_names(self): + names = list(DLL_CHARACTERISTICS_FLAGS.values()) + assert len(names) == len(set(names)) + + +# ================================================================= +# _parse_optional_header — top-level behaviour +# ================================================================= + +class TestParseOptionalHeaderTopLevel: + + def test_returns_empty_dict_when_optional_header_missing(self): + pe = _FakePE() + opt, out = _parse_optional_header(pe) + assert opt is None + assert out == {} + + def test_returns_optional_header_object_unchanged(self): + oh = _full_oh() + pe = _FakePE(optional_header=oh) + opt, out = _parse_optional_header(pe) + assert opt is oh + + def test_returns_dict_with_expected_keys(self): + oh = _full_oh() + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + expected_keys = { + # Existing fields + "section_alignment", "file_alignment", "size_of_image", + "size_of_headers", "linker_version", "os_version", + "subsystem_version", + # New: DLL characteristics + "dll_characteristics", "dll_characteristics_flags", + "dll_characteristics_unknown_bits", + # New: deprecated/reserved DWORDs + "win32_version_value", "loader_flags", + # New: stack/heap sizing + "stack_reserve_size", "stack_commit_size", + "heap_reserve_size", "heap_commit_size", + } + assert set(out.keys()) == expected_keys + + +# ================================================================= +# Existing fields — backward compatibility +# ================================================================= + +class TestExistingFieldsBackwardCompatible: + + def test_existing_fields_match_full_oh(self): + oh = _full_oh() + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["section_alignment"] == 4096 + assert out["file_alignment"] == 512 + assert out["size_of_image"] == 24576 + assert out["size_of_headers"] == 1024 + assert out["linker_version"] == "14.42" + assert out["os_version"] == "6.0" + assert out["subsystem_version"] == "6.0" + + def test_existing_fields_default_to_zero_when_missing(self): + # The existing parser uses 0 as the getattr default; preserved. + oh = _FakeOH() # no fields set + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["section_alignment"] == 0 + assert out["file_alignment"] == 0 + assert out["size_of_image"] == 0 + assert out["size_of_headers"] == 0 + assert out["linker_version"] == "0.0" + assert out["os_version"] == "0.0" + assert out["subsystem_version"] == "0.0" + + +# ================================================================= +# New fields — extraction +# ================================================================= + +class TestNewFieldsExtraction: + + def test_dll_characteristics_raw_value(self): + oh = _full_oh(DllCharacteristics=0x4140) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics"] == 0x4140 + + def test_dll_characteristics_decoded_flags(self): + # 0x4140 = DYNAMIC_BASE (0x0040) | NX_COMPAT (0x0100) | GUARD_CF (0x4000) + oh = _full_oh(DllCharacteristics=0x4140) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics_flags"] == [ + "DYNAMIC_BASE", "NX_COMPAT", "GUARD_CF" + ] + + def test_dll_characteristics_empty_when_zero(self): + oh = _full_oh(DllCharacteristics=0) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics"] == 0 + assert out["dll_characteristics_flags"] == [] + assert out["dll_characteristics_unknown_bits"] is None + + def test_dll_characteristics_unknown_bits(self): + # 0x10000 is outside the known mask + oh = _full_oh(DllCharacteristics=0x10040) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics_flags"] == ["DYNAMIC_BASE"] + assert out["dll_characteristics_unknown_bits"] == "0x10000" + + def test_dll_characteristics_all_known_bits_no_unknown(self): + # Every known bit set should produce no unknown_bits report + oh = _full_oh(DllCharacteristics=DLL_CHARACTERISTICS_KNOWN_MASK) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert len(out["dll_characteristics_flags"]) == len(DLL_CHARACTERISTICS_FLAGS) + assert out["dll_characteristics_unknown_bits"] is None + + def test_dll_characteristics_only_unknown_bits(self): + oh = _full_oh(DllCharacteristics=0x10000) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics_flags"] == [] + assert out["dll_characteristics_unknown_bits"] == "0x10000" + + def test_dll_characteristics_missing_returns_none(self): + oh = _FakeOH() # DllCharacteristics not set + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics"] is None + assert out["dll_characteristics_flags"] is None + assert out["dll_characteristics_unknown_bits"] is None + + def test_win32_version_value_extracted(self): + oh = _full_oh(Win32VersionValue=0x1234) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["win32_version_value"] == 0x1234 + + def test_win32_version_value_missing_returns_none(self): + oh = _FakeOH() + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["win32_version_value"] is None + + def test_loader_flags_extracted(self): + oh = _full_oh(LoaderFlags=0xCAFE) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["loader_flags"] == 0xCAFE + + def test_loader_flags_missing_returns_none(self): + oh = _FakeOH() + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["loader_flags"] is None + + def test_stack_reserve_size_extracted(self): + oh = _full_oh(SizeOfStackReserve=0x200000) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["stack_reserve_size"] == 0x200000 + + def test_stack_commit_size_extracted(self): + oh = _full_oh(SizeOfStackCommit=0x2000) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["stack_commit_size"] == 0x2000 + + def test_heap_reserve_size_extracted(self): + oh = _full_oh(SizeOfHeapReserve=0x300000) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["heap_reserve_size"] == 0x300000 + + def test_heap_commit_size_extracted(self): + oh = _full_oh(SizeOfHeapCommit=0x3000) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["heap_commit_size"] == 0x3000 + + def test_stack_heap_sizes_missing_return_none(self): + oh = _FakeOH() + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["stack_reserve_size"] is None + assert out["stack_commit_size"] is None + assert out["heap_reserve_size"] is None + assert out["heap_commit_size"] is None + + def test_pe32_plus_64bit_sizes(self): + # PE32+ binaries use 64-bit values; ensure they pass through + oh = _full_oh(SizeOfStackReserve=0x100000000) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["stack_reserve_size"] == 0x100000000 + + +# ================================================================= +# DLL characteristics flag ordering +# ================================================================= + +class TestDllCharacteristicsOrdering: + + def test_flags_returned_in_bit_position_order(self): + # 0xC1A0 = HIGH_ENTROPY_VA (0x0020) | DYNAMIC_BASE (0x0040) + # | NX_COMPAT (0x0100) | GUARD_CF (0x4000) + # | TERMINAL_SERVER_AWARE (0x8000) + oh = _full_oh(DllCharacteristics=0xC160) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics_flags"] == [ + "HIGH_ENTROPY_VA", + "DYNAMIC_BASE", + "NX_COMPAT", + "GUARD_CF", + "TERMINAL_SERVER_AWARE", + ] + + def test_ordering_deterministic_across_runs(self): + oh = _full_oh(DllCharacteristics=0x4160) + pe = _FakePE(optional_header=oh) + results = [] + for _ in range(20): + _, out = _parse_optional_header(pe) + results.append(out["dll_characteristics_flags"]) + assert all(r == results[0] for r in results) + + +# ================================================================= +# _parse_header +# ================================================================= + +class TestParseHeader: + + def test_full_header_extraction(self): + oh = _full_oh() + fh = _full_fh() + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + assert out["entry_point"] == 0x1000 + assert out["image_base"] == 0x140000000 + assert out["subsystem"] == 3 + assert out["subsystem_name"] == "WINDOWS_CUI" + assert out["timestamp"] == 1700000000 + assert out["machine"] == 0x8664 + assert out["characteristics"] == 0x22 + + def test_subsystem_name_for_all_known_values(self): + for subsystem_id, expected_name in SUBSYSTEM_NAMES.items(): + oh = _full_oh(Subsystem=subsystem_id) + fh = _full_fh() + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + assert out["subsystem"] == subsystem_id + assert out["subsystem_name"] == expected_name + + def test_subsystem_name_none_for_unknown_value(self): + oh = _full_oh(Subsystem=99) + fh = _full_fh() + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + assert out["subsystem"] == 99 + assert out["subsystem_name"] is None + + def test_missing_optional_header_uses_zeros(self): + fh = _full_fh() + pe = _FakePE(file_header=fh) + out = _parse_header(pe, None) + assert out["entry_point"] == 0 + assert out["image_base"] == 0 + assert out["subsystem"] == 0 + # Subsystem 0 is UNKNOWN per the table + assert out["subsystem_name"] == "UNKNOWN" + assert out["timestamp"] == 1700000000 + + def test_missing_file_header_uses_zeros(self): + oh = _full_oh() + pe = _FakePE(optional_header=oh) + out = _parse_header(pe, oh) + assert out["entry_point"] == 0x1000 + assert out["timestamp"] == 0 + assert out["machine"] == 0 + assert out["characteristics"] == 0 + + def test_both_headers_missing(self): + pe = _FakePE() + out = _parse_header(pe, None) + assert out == { + "entry_point": 0, + "image_base": 0, + "subsystem": 0, + "subsystem_name": "UNKNOWN", + "timestamp": 0, + "machine": 0, + "machine_name": "UNKNOWN", + "characteristics": 0, + } + + def test_returns_expected_key_set(self): + oh = _full_oh() + fh = _full_fh() + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + assert set(out.keys()) == { + "entry_point", "image_base", "subsystem", "subsystem_name", + "timestamp", "machine", "machine_name", "characteristics", + } + +# ================================================================= +# Win32VersionValue / Reserved1 fallback +# ================================================================= +class TestWin32VersionExtraction: + + def test_win32_version_value_falls_back_to_reserved1(self): + """Pefile uses Reserved1 for the deprecated Win32VersionValue field.""" + oh = _FakeOH(Reserved1=42) # only Reserved1 is set + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["win32_version_value"] == 42 + + + def test_win32_version_value_prefers_explicit_field_when_present(self): + """If both names are present, the explicit Win32VersionValue wins.""" + oh = _FakeOH(Win32VersionValue=99, Reserved1=42) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["win32_version_value"] == 99 + + + def test_win32_version_value_none_when_neither_present(self): + oh = _FakeOH() # neither Win32VersionValue nor Reserved1 set + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["win32_version_value"] is None + +# ================================================================= +# Machine names +# ================================================================= +class TestMachineNames: + + def test_machine_names_contains_well_known_values(self): + from iocx.parsers.pe_constants import MACHINE_NAMES + assert MACHINE_NAMES[0x014C] == "I386" + assert MACHINE_NAMES[0x8664] == "AMD64" + assert MACHINE_NAMES[0xAA64] == "ARM64" + assert MACHINE_NAMES[0x0200] == "IA64" + + def test_machine_name_for_unknown_returns_none(self): + from iocx.parsers.pe_constants import MACHINE_NAMES + assert MACHINE_NAMES.get(0x9999) is None + + def test_machine_zero_is_unknown(self): + """0x0000 is IMAGE_FILE_MACHINE_UNKNOWN per spec.""" + from iocx.parsers.pe_constants import MACHINE_NAMES + assert MACHINE_NAMES[0x0000] == "UNKNOWN" + + def test_machine_names_no_duplicate_values(self): + from iocx.parsers.pe_constants import MACHINE_NAMES + values = list(MACHINE_NAMES.values()) + assert len(values) == len(set(values)) + + def test_machine_name_decoded_in_header(self): + oh = _full_oh() + fh = _full_fh(Machine=0x8664) + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + assert out["machine"] == 0x8664 + assert out["machine_name"] == "AMD64" + + def test_machine_name_none_for_unknown_machine(self): + oh = _full_oh() + fh = _full_fh(Machine=0x9999) + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + assert out["machine"] == 0x9999 + assert out["machine_name"] is None + + def test_machine_name_zero_is_unknown(self): + """machine=0 explicitly decodes to 'UNKNOWN' per spec.""" + oh = _full_oh() + fh = _full_fh(Machine=0) + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + assert out["machine"] == 0 + assert out["machine_name"] == "UNKNOWN" + +# ================================================================= +# JSON safety +# ================================================================= + +class TestJsonSafety: + + def test_optional_header_output_serializes(self): + oh = _full_oh() + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + json.dumps(out) # must not raise + + def test_optional_header_with_missing_fields_serializes(self): + oh = _FakeOH() + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + json.dumps(out) + + def test_header_output_serializes(self): + oh = _full_oh() + fh = _full_fh() + pe = _FakePE(optional_header=oh, file_header=fh) + out = _parse_header(pe, oh) + json.dumps(out) + + def test_header_with_missing_fields_serializes(self): + pe = _FakePE() + out = _parse_header(pe, None) + json.dumps(out) + + def test_unknown_bits_as_string_serializes_cleanly(self): + oh = _full_oh(DllCharacteristics=0x10040) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + result = json.dumps(out) + assert "0x10000" in result + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + + def test_repeated_optional_header_parse_identical(self): + oh = _full_oh(DllCharacteristics=0x4160) + pe = _FakePE(optional_header=oh) + results = [] + for _ in range(20): + _, out = _parse_optional_header(pe) + results.append(out) + for r in results[1:]: + assert r == results[0] + + def test_repeated_header_parse_identical(self): + oh = _full_oh() + fh = _full_fh() + pe = _FakePE(optional_header=oh, file_header=fh) + results = [_parse_header(pe, oh) for _ in range(20)] + for r in results[1:]: + assert r == results[0] + + def test_optional_header_with_missing_fields_deterministic(self): + oh = _FakeOH() + pe = _FakePE(optional_header=oh) + results = [] + for _ in range(20): + _, out = _parse_optional_header(pe) + results.append(out) + for r in results[1:]: + assert r == results[0] + + +# ================================================================= +# Conservative field handling +# ================================================================= + +class TestConservativeFieldHandling: + + def test_individual_field_missing_does_not_break_others(self): + # Construct an OH missing only SizeOfStackReserve; other fields + # should populate normally + oh = _FakeOH( + SectionAlignment=4096, + Subsystem=3, + DllCharacteristics=0x4140, + # SizeOfStackReserve deliberately not set + SizeOfStackCommit=0x1000, + SizeOfHeapReserve=0x100000, + SizeOfHeapCommit=0x1000, + ) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["section_alignment"] == 4096 + assert out["dll_characteristics"] == 0x4140 + assert out["stack_commit_size"] == 0x1000 + assert out["heap_reserve_size"] == 0x100000 + assert out["stack_reserve_size"] is None # missing → None + + def test_partial_field_failures_do_not_propagate(self): + # All security-related new fields missing, but stack/heap present + oh = _FakeOH( + SectionAlignment=4096, + SizeOfStackReserve=0x100000, + SizeOfStackCommit=0x1000, + ) + pe = _FakePE(optional_header=oh) + _, out = _parse_optional_header(pe) + assert out["dll_characteristics"] is None + assert out["dll_characteristics_flags"] is None + assert out["dll_characteristics_unknown_bits"] is None + assert out["loader_flags"] is None + assert out["stack_reserve_size"] == 0x100000 # populated From 80c47452c23d9ff69c0917ceca3241cfa45c722e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 30 Jun 2026 09:47:18 +0100 Subject: [PATCH 26/35] CHANGELOG entries for requirement 1 --- CHANGELOG.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dcf223..8690c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,34 @@ bytes, rounded to 4 decimal places for snapshot stability. Entries with unreadable data produce `entropy: null` and the corresponding error tombstone. +- Optional Header metadata enrichment. The optional_header block + in public metadata now exposes security-relevant and sizing fields + previously not captured by IOCX, completing requirement 1. New fields: + - dll_characteristics — raw DllCharacteristics value. + - dll_characteristics_flags — decoded flag names from the + IMAGE_DLLCHARACTERISTICS_* set (e.g., "DYNAMIC_BASE", + "NX_COMPAT", "GUARD_CF"), sorted by bit position for snapshot + stability. + - dll_characteristics_unknown_bits — hex string for bits set in + DllCharacteristics but not in IOCX's known-flag mask, or null + when all set bits are recognised. + - win32_version_value — raw Win32VersionValue DWORD (deprecated + per PE spec; exposed for completeness). + - loader_flags — raw LoaderFlags DWORD (reserved per PE spec; + exposed for completeness). + - stack_reserve_size, stack_commit_size — SizeOfStackReserve + and SizeOfStackCommit (64-bit on PE32+ binaries). + - heap_reserve_size, heap_commit_size — SizeOfHeapReserve + and SizeOfHeapCommit. +- Subsystem name decoding. The header block now includes + subsystem_name, a decoded string from the IMAGE_SUBSYSTEM_* + table (e.g., "WINDOWS_CUI", "NATIVE", "EFI_APPLICATION"). + Returns null for subsystem values outside the well-known set. + The raw subsystem integer field is unchanged. +- New constants module iocx.parsers.pe_constants. Houses the + SUBSYSTEM_NAMES and DLL_CHARACTERISTICS_FLAGS lookup tables, + plus the derived DLL_CHARACTERISTICS_KNOWN_MASK. Sourced from + the Microsoft PE format specification. ## Changed @@ -132,6 +160,36 @@ during the requirement 4 work and caught before snapshot stamping — entropy values now match the previous release's behaviour for all existing fixtures. +- Convention for missing-field defaults. The new Optional Header + fields use None as the default value when pefile cannot extract + the field, distinct from 0 which indicates the binary's actual + value. This is a deliberate divergence from the existing fields' + default-to-zero convention. Rationale: the semantic split between + "missing" and "zero" is meaningful for security-relevant fields + (a DllCharacteristics of 0 means "no security features enabled," + which is itself a signal; null means "could not extract"). Existing + fields retain their default-to-zero behaviour for backward + compatibility. +- Extended metadata analyser refactored to remove duplicated decoding. + Following the parser-layer additions of subsystem_name (requirement 1) + and machine_name (this release), the analyse_extended module no + longer maintains its own _SUBSYSTEM_MAP and _MACHINE_MAP lookup + tables. The legacy subsystem_human and machine_human fields are + removed from extended metadata output. The parser layer is now the + single source of truth for both subsystem and machine name decoding. +- Machine name decoding moved to the parser layer. Added + _MACHINE_NAMES to iocx.parsers.pe_constants and machine_name + to _parse_header output. Consistent with the subsystem_name + convention introduced in requirement 1 (uppercase-underscore form, + None for unknown values). +- Resource entropy statistics tolerate per-entry errors. Following + requirement 4's introduction of resource-level errors reporting, + the extended analyser's resource summary computes entropy_min, + entropy_max, and entropy_avg over only the entries with computed + entropy values. Resources with entropy: None (due to per-entry + computation failures) are excluded from the aggregates. When no + resource has a computed entropy, all three statistics are None + rather than raising on empty min() / max(). ## Marked as RESERVE but consider removing in the future @@ -166,6 +224,21 @@ - The `_decode_langid` semantics are documented inline: primary language and sublang decomposition, fallback to default region, fallback to primary-language-only, fallback to `None`. +- Documented the new Optional Header fields in the schema reference, + including the dll_characteristics_unknown_bits field's role in + preserving complete information about non-decoded bits. +- Documented the mixed-default convention (0 for existing fields, + None for new fields) with rationale for the divergence. +- analyse_extended module purpose documented inline. Added a + module-level docstring clarifying that the module performs shape + conversion and derived-statistics computation only — not new + information extraction. Future contributors adding decoding logic + should consider whether it belongs in the parser layer instead. +- Validator-vs-metadata boundary commented in the exports block. + Added an inline note documenting that the extended exports view is + metadata-shaped and that structural validity of the export table is + reported separately via validator reason codes. Avoids future confusion + about whether analyse_extended should mirror validator output. ## Internal @@ -187,6 +260,17 @@ - Float precision pinned at 4 decimal places for entropy, matching the precision convention used in other entropy-bearing fields elsewhere in IOCX. +- 100% line coverage on _parse_optional_header, _parse_header, + and pe_constants. +- Test coverage includes: + - All 15 entries in SUBSYSTEM_NAMES exercised individually + - All 11 DLL characteristics flags exercised with bit-position + ordering pinned + - Unknown-bits detection for values outside the known mask + - Conservative field handling: individual missing fields do not + affect extraction of other fields + - JSON-safety contract: outputs round-trip through json.dumps + - Determinism: repeated parses produce identical output ## Compatibility @@ -211,6 +295,28 @@ `language_name` return change and the new fields will produce diffs in expected outputs. Refresh is mechanical via the existing fixture regeneration tooling. +- Existing field behaviour unchanged. Consumers reading + section_alignment, file_alignment, size_of_image, etc., see + the same values they did before. The default-to-zero behaviour is + preserved. +- New fields are additive. Consumers reading existing keys are + unaffected. Consumers wanting the new security-relevant fields can + read them via the documented keys; missing values surface as null. +- Snapshot refresh required for any fixture with an optional header. + Every fixture's optional_header block gains new keys; refresh is + mechanical via the existing fixture regeneration tooling. +- subsystem_human and machine_human removed from extended + metadata. Consumers should use subsystem_name (always present in + header, uppercase-underscore form) and machine_name (added in this + release). Migration is a string-conversion exercise: "WINDOWS_CUI" + ↔ "Windows CUI". If display-friendly forms are needed for CLI + rendering, that translation belongs in the renderer, not the metadata + layer. +- Snapshot refresh required. Any fixture whose extended metadata + output was snapshot-pinned will see subsystem_human and + machine_human removed, and the header block under + analysis.extended will match the public metadata's header block + exactly. Mechanical refresh. ## Known scheduled work From 9c53bcaefd40ad2ef08a0a1894198424ef38715e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 30 Jun 2026 11:14:44 +0100 Subject: [PATCH 27/35] feat(pe_delay_load_import): add deterministic raw-bytes delay-load parser New module independent of pefile's DIRECTORY_ENTRY_DELAY_IMPORT interpretation. Uses pefile only to locate the delay-load directory, determine PE32 vs PE32+ via OPTIONAL_HEADER.Magic, and resolve RVAs to file offsets via pe.get_data. Deterministic extraction of: - 32-byte IMAGE_DELAY_IMPORT_DESCRIPTOR structures via struct.unpack_from - Descriptor array walk with zero-terminator detection and hard count limit (4096), with distinct truncation tags for each termination cause - v1 (modern RVA) vs v0 (legacy VA) attribute mode captured from raw Attributes field rather than silently coerced - Import Name Table (INT) and Import Address Table (IAT) thunk arrays with DWORD/QWORD sizing determined by Magic - Per-thunk decoding with high-bit ordinal detection - IMAGE_IMPORT_BY_NAME structures with bounded ASCII scan (1024 bytes) - Bound state detection by bound_iat_rva != 0 - INT/IAT length parity check at descriptor level - DLL name string reading with bounded scan (512 bytes) and structural ASCII validation Determinism guarantees: - All array walks bounded by zero terminator AND hard count limit - Sub-structure failures emit deterministic tombstone tags in truncations[] and per-entry errors[] - Never raises; pefile.PEFormatError and other exceptions are narrowly caught and converted to tombstone entries - Output dict shape stable regardless of malformation pattern Returns None for absent delay-load directory (most binaries don't use delay-loading); returns a structurally-rich dict otherwise per the documented output contract. Verified end-to-end against mspaint.exe with dumpbin /imports cross-check: 107 imports decoded from gdiplus.dll's delay-load directory with byte-exact agreement on DLL name, hint values, IAT addresses (5369363602 = 0x14009FC92), ordering, and bound state. Refs: delay-load import parsing (originally deferred; bundled into this release for cross-tool divergence methodology value) --- iocx/parsers/pe_delay_load_import.py | 472 +++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 iocx/parsers/pe_delay_load_import.py diff --git a/iocx/parsers/pe_delay_load_import.py b/iocx/parsers/pe_delay_load_import.py new file mode 100644 index 0000000..0b982ed --- /dev/null +++ b/iocx/parsers/pe_delay_load_import.py @@ -0,0 +1,472 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Deterministic structural extraction of the PE delay-load import table. + +Independent of pefile's DIRECTORY_ENTRY_DELAY_IMPORT interpretation. +Pefile is used only to: + - Locate the delay-load directory (RVA, size) + - Determine PE32 vs PE32+ via OPTIONAL_HEADER.Magic + - Resolve RVAs to file offsets via pe.get_data + +All structural fields are decoded from the raw 32-byte +IMAGE_DELAY_IMPORT_DESCRIPTOR structure. The INT, IAT, and DLL name +strings are read raw. + +Output contract: + None - no delay-load directory present (not an error) + dict per the documented contract (see DelayImportStruct in + iocx.schemas.internal_schema). +""" + +from __future__ import annotations + +import re +import struct +from typing import Any, Dict, List, Optional, Tuple + +# IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT = 13 +_DELAY_IMPORT_DIRECTORY_INDEX = 13 +_DESCRIPTOR_SIZE = 32 # IMAGE_DELAY_IMPORT_DESCRIPTOR is 32 bytes + +# OPTIONAL_HEADER.Magic values +_MAGIC_PE32 = 0x10B +_MAGIC_PE32_PLUS = 0x20B + +# DLL name length cap to defend against unterminated reads +_DLL_NAME_MAX_LEN = 512 +# Import-by-name length cap +_IMPORT_NAME_MAX_LEN = 1024 + +# Hard limit on descriptors to defend against pathological inputs claiming +# arbitrarily many. Real binaries rarely have more than a few hundred. +_MAX_DESCRIPTORS = 4096 + +# Hard limit on imports per descriptor for the same reason +_MAX_IMPORTS_PER_DESCRIPTOR = 16384 + +# DLL name structural check: ASCII printable, typical filename charset. +# Conservative — accepts the common cases without trying to validate +# filesystem semantics. +_DLL_NAME_RE = re.compile(r"^[\x20-\x7E]{1,255}$") + + +def build_delay_import_structure(pe) -> Optional[Dict[str, Any]]: + """ + Locate and structurally decode the PE delay-load import table. + + Returns None if no delay-load directory is present. Otherwise returns + a dict per the module docstring contract. Never raises; decode + failures produce tombstone entries in `errors` and `truncations`. + """ + placement = _locate_delay_import_directory(pe) + if placement is None: + return None + + rva, size = placement + is_64bit = _is_pe32_plus(pe) + thunk_size = 8 if is_64bit else 4 + truncations: List[str] = [] + errors: List[str] = [] + + descriptors = _read_descriptors( + pe, rva, size, thunk_size, truncations, errors, + ) + + return { + "rva": rva, + "size": size, + "is_64bit": is_64bit, + "descriptors": descriptors, + "truncations": truncations, + "errors": errors, + } + + +# ================================================================= +# Locator +# ================================================================= + +def _locate_delay_import_directory(pe) -> Optional[Tuple[int, int]]: + """Return (rva, size) of the delay-load directory, or None if absent.""" + try: + data_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[_DELAY_IMPORT_DIRECTORY_INDEX] + rva = int(data_dir.VirtualAddress) + size = int(data_dir.Size) + except (AttributeError, IndexError, ValueError, TypeError): + return None + + if rva == 0 or size == 0: + return None + + return (rva, size) + + +def _is_pe32_plus(pe) -> bool: + """Determine PE32+ (64-bit) by reading OPTIONAL_HEADER.Magic.""" + try: + magic = int(pe.OPTIONAL_HEADER.Magic) + return magic == _MAGIC_PE32_PLUS + except (AttributeError, ValueError, TypeError): + # Default to 32-bit if we can't tell. Conservative — produces + # smaller thunks, lower risk of over-reading buffers. + return False + + +# ================================================================= +# Descriptor array +# ================================================================= + +def _read_descriptors( + pe, + base_rva: int, + declared_size: int, + thunk_size: int, + truncations: List[str], + errors: List[str], +) -> List[Dict[str, Any]]: + """ + Walk the array of IMAGE_DELAY_IMPORT_DESCRIPTOR structures. + + The array is terminated by a zero descriptor (all 32 bytes zero). + We also enforce a max descriptor count and stop at the declared + directory size to defend against pathological inputs. + """ + descriptors: List[Dict[str, Any]] = [] + pos = base_rva + end = base_rva + declared_size + + # Read up to _MAX_DESCRIPTORS or until zero terminator or end + for index in range(_MAX_DESCRIPTORS): + if pos + _DESCRIPTOR_SIZE > end: + # Declared size exceeded — could be truncation or just end + # of the array without a zero terminator. We tag it only if + # we haven't yet seen a zero terminator. + if descriptors and not _is_zero_terminator(descriptors[-1]): + truncations.append("delay_import_descriptor_unterminated") + break + + try: + raw = bytes(pe.get_data(pos, _DESCRIPTOR_SIZE)) + except Exception: + truncations.append("delay_import_descriptor_read_failed") + break + + if len(raw) < _DESCRIPTOR_SIZE: + truncations.append("delay_import_descriptor_truncated") + break + + decoded = _decode_descriptor(raw, index) + if decoded is None: + errors.append(f"descriptor_unpack_failed_at_{index}") + break + + # Check for zero terminator BEFORE adding to results + if _is_zero_descriptor(decoded): + break + + # Read INT, IAT, and DLL name for this descriptor + _enrich_descriptor(pe, decoded, thunk_size, truncations) + descriptors.append(decoded) + pos += _DESCRIPTOR_SIZE + else: + # Hit max without finding terminator + truncations.append("delay_import_descriptor_max_exceeded") + + return descriptors + + +def _decode_descriptor(buf: bytes, index: int) -> Optional[Dict[str, Any]]: + """Unpack a 32-byte IMAGE_DELAY_IMPORT_DESCRIPTOR.""" + try: + unpacked = struct.unpack_from(" bool: + """A descriptor with all-zero fields signals end of array.""" + return ( + d["attributes"] == 0 + and d["dll_name_rva"] == 0 + and d["module_handle_rva"] == 0 + and d["iat_rva"] == 0 + and d["int_rva"] == 0 + and d["bound_iat_rva"] == 0 + and d["unload_iat_rva"] == 0 + and d["timestamp"] == 0 + ) + + +def _is_zero_terminator(d: Dict[str, Any]) -> bool: + """Convenience for the unterminated-array check.""" + return _is_zero_descriptor(d) + + +# ================================================================= +# Per-descriptor enrichment +# ================================================================= + +def _enrich_descriptor( + pe, + descriptor: Dict[str, Any], + thunk_size: int, + truncations: List[str], +) -> None: + """Read DLL name, INT/IAT thunks, and per-import name strings.""" + # ---- DLL name ---- + dll_name_rva = descriptor["dll_name_rva"] + if dll_name_rva == 0: + descriptor["errors"].append("dll_name_rva_zero") + else: + name, err = _read_asciiz(pe, dll_name_rva, _DLL_NAME_MAX_LEN) + if err is not None: + descriptor["errors"].append(err) + else: + descriptor["dll_name"] = name + descriptor["dll_name_valid"] = bool(_DLL_NAME_RE.match(name)) + if not descriptor["dll_name_valid"]: + descriptor["errors"].append("dll_name_not_printable") + + # ---- INT (Import Name Table) ---- + int_rva = descriptor["int_rva"] + int_thunks = _read_thunk_array( + pe, int_rva, thunk_size, "int", + descriptor["errors"], truncations, + ) + + # ---- IAT (Import Address Table) ---- + iat_rva = descriptor["iat_rva"] + iat_thunks = _read_thunk_array( + pe, iat_rva, thunk_size, "iat", + descriptor["errors"], truncations, + ) + + # ---- Cross-validate INT/IAT lengths ---- + if len(int_thunks) != len(iat_thunks): + descriptor["errors"].append("int_iat_length_mismatch") + + # ---- Build per-import entries by joining INT and IAT ---- + max_len = max(len(int_thunks), len(iat_thunks)) + high_bit = 1 << (thunk_size * 8 - 1) + + for i in range(max_len): + int_value = int_thunks[i] if i < len(int_thunks) else None + iat_value = iat_thunks[i] if i < len(iat_thunks) else None + entry = _decode_import_entry( + pe, i, int_value, iat_value, high_bit, thunk_size, + ) + descriptor["imports"].append(entry) + + +def _decode_import_entry( + pe, + index: int, + int_value: Optional[int], + iat_value: Optional[int], + high_bit: int, + thunk_size: int, +) -> Dict[str, Any]: + """ + Build one DelayImportEntry from an INT/IAT pair. + + INT entry semantics: + - High bit set: low bits are an ordinal value + - High bit clear: value is an RVA to IMAGE_IMPORT_BY_NAME + """ + errors: List[str] = [] + is_ordinal = False + ordinal: Optional[int] = None + hint: Optional[int] = None + name: Optional[str] = None + name_rva: Optional[int] = None + name_valid = False + + if int_value is None: + errors.append("int_entry_missing") + elif int_value == 0: + errors.append("int_entry_zero") + elif int_value & high_bit: + is_ordinal = True + # Ordinal is the low 16 bits per PE spec + ordinal = int_value & 0xFFFF + if ordinal == 0: + errors.append("ordinal_zero") + else: + name_rva = int_value + # Read IMAGE_IMPORT_BY_NAME: WORD hint + ASCIIZ name + hint, name, read_err = _read_import_by_name(pe, name_rva) + if read_err is not None: + errors.append(read_err) + elif name is None: + errors.append("name_read_failed") + else: + name_valid = bool(re.match(r"^[\x20-\x7E]{1,512}$", name)) + if not name_valid: + errors.append("name_not_printable") + + return { + "index": index, + "is_ordinal": is_ordinal, + "ordinal": ordinal, + "hint": hint, + "name": name, + "name_rva": name_rva, + "name_valid": name_valid, + "iat_value": iat_value, + "errors": errors, + } + + +# ================================================================= +# Thunk array reader +# ================================================================= + +def _read_thunk_array( + pe, + rva: int, + thunk_size: int, + tag: str, + descriptor_errors: List[str], + truncations: List[str], +) -> List[int]: + """ + Read a NULL-terminated array of thunks (INT or IAT). + + Walks one thunk at a time until either a zero terminator is found, + the max-imports limit is hit, or the read fails. + """ + if rva == 0: + descriptor_errors.append(f"{tag}_rva_zero") + return [] + + thunks: List[int] = [] + pos = rva + fmt = " Tuple[Optional[str], Optional[str]]: + """ + Read a NUL-terminated ASCII string at the given RVA. + Returns (string, error_tag). On success, error_tag is None. + """ + try: + raw = bytes(pe.get_data(rva, max_len)) + except Exception: + return None, "read_failed" + + if not raw: + return None, "empty_read" + + nul_pos = raw.find(b"\x00") + if nul_pos == -1: + return None, "unterminated" + + try: + s = raw[:nul_pos].decode("ascii") + except UnicodeDecodeError: + s = raw[:nul_pos].decode("ascii", errors="replace") + return s, "non_ascii" + + return s, None + + +def _read_import_by_name( + pe, + rva: int, +) -> Tuple[Optional[int], Optional[str], Optional[str]]: + """ + Read IMAGE_IMPORT_BY_NAME structure: + WORD Hint + BYTE Name[] (NUL-terminated ASCII) + + Returns (hint, name, error_tag). + """ + try: + raw = bytes(pe.get_data(rva, _IMPORT_NAME_MAX_LEN)) + except Exception: + return None, None, "name_read_failed" + + if len(raw) < 3: + return None, None, "name_too_short" + + try: + (hint,) = struct.unpack_from(" Date: Tue, 30 Jun 2026 11:21:01 +0100 Subject: [PATCH 28/35] feat(validators/delay_imports): structural validation of decoded blob Maps pe_delay_imports output to eight new reason codes: Directory anomalies: - DELAY_IMPORT_DIRECTORY_INVALID_HEADER: top-level decode failed - DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS: directory extends past SizeOfImage - DELAY_IMPORT_TABLE_TRUNCATED: sub-table truncation or unterminated descriptor array (per-table emission via details[table]) Descriptor anomalies: - DELAY_IMPORT_DESCRIPTOR_INVALID: per-descriptor INT or IAT structural error (priority-resolved sub-reasons via details[reason], scoped by details[table]) - DELAY_IMPORT_DLL_NAME_INVALID: DLL name RVA unusable (priority-resolved sub-reasons: dll_name_rva_zero, read_failed, unterminated, dll_name_not_printable, non_ascii) - DELAY_IMPORT_INT_IAT_MISMATCH: INT and IAT have different lengths, violating the parallel-array invariant - DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE: descriptor uses v0 (pre-Windows 2000) mode where table fields are raw virtual addresses rather than RVAs Entry anomalies: - DELAY_IMPORT_ENTRY_INVALID: per-import entry malformed (priority-resolved sub-reasons: int_entry_missing, int_entry_zero, ordinal_zero, name_read_failed, name_too_short, hint_unpack_failed, name_unterminated, name_non_ascii, name_not_printable) Implementation choices: - Priority-resolved sub-reasons via module-level _PRIORITY constants, matching the discipline established in validator_exports - Single-issue-per-pathology emission for per-descriptor and per-entry checks; cross-table consistency check (INT/IAT mismatch) emitted as its own reason code rather than per-entry cascade - Top-level decode failure short-circuits all sub-validation - Absence of delay-load directory not treated as a defect (most binaries don't use delay-loading) - Bound state is normal behaviour; bound_iat_rva != 0 is not flagged - v0 attribute mode emitted as a dedicated reason code rather than silently coerced, supporting the cross-tool divergence demonstration this validator was scoped to enable Registered in the dispatcher between validate_exports and validate_entropy, completing the import/export structural validator cluster ahead of the entropy/derived layer. Refs: delay-load import parsing --- iocx/validators/delay_imports.py | 272 +++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 iocx/validators/delay_imports.py diff --git a/iocx/validators/delay_imports.py b/iocx/validators/delay_imports.py new file mode 100644 index 0000000..8006c62 --- /dev/null +++ b/iocx/validators/delay_imports.py @@ -0,0 +1,272 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Validate the delay-load import structure produced by parser_delay_imports. + +Absence of a delay-load directory is NOT a structural defect — most binaries +don't use delay-loading. We only emit codes when the directory is present +and structurally malformed. + +This validator covers: + - Parsing of IMAGE_DELAY_IMPORT_DESCRIPTOR + - INT/IAT validation + - DLL name RVA validation + - Malformed descriptor handling + +Reason codes emitted: + DELAY_IMPORT_DIRECTORY_INVALID_HEADER + DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS + DELAY_IMPORT_TABLE_TRUNCATED + DELAY_IMPORT_DESCRIPTOR_INVALID + DELAY_IMPORT_DLL_NAME_INVALID + DELAY_IMPORT_INT_IAT_MISMATCH + DELAY_IMPORT_ENTRY_INVALID + DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE +""" + +from typing import Any, Dict, List, Optional + +from iocx.reason_codes import ReasonCodes +from iocx.validators.schema import StructuralIssue +from iocx.schemas.internal_schema import InternalMetadata +from iocx.schemas.analysis import AnalysisDict +from .decorators import depends_on + + +# Priority-resolved sub-reasons for per-entry name/RVA pathologies. +# First-matching wins for deterministic emission. +_DLL_NAME_ERROR_PRIORITY = [ + "dll_name_rva_zero", + "read_failed", + "unterminated", + "dll_name_not_printable", + "non_ascii", +] + +_INT_RVA_ERROR_PRIORITY = [ + "int_rva_zero", + "int_truncated", + "int_read_failed", + "int_max_exceeded", + "int_unpack_failed", +] + +_IAT_RVA_ERROR_PRIORITY = [ + "iat_rva_zero", + "iat_truncated", + "iat_read_failed", + "iat_max_exceeded", + "iat_unpack_failed", +] + +_ENTRY_ERROR_PRIORITY = [ + "int_entry_missing", + "int_entry_zero", + "ordinal_zero", + "name_read_failed", + "name_too_short", + "hint_unpack_failed", + "name_unterminated", + "name_non_ascii", + "name_not_printable", +] + + +@depends_on("internal", "analysis") +def validate_delay_imports(metadata: InternalMetadata, + analysis: AnalysisDict) -> List[StructuralIssue]: + issues: List[StructuralIssue] = [] + + di = metadata.get("delay_import_struct") + if di is None: + return issues # no delay-load directory — not a defect + + size_of_image = analysis.get("size_of_image") + + # ---- Top-level decode failures short-circuit ---- + if di.get("errors"): + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_DIRECTORY_INVALID_HEADER, + details={"reason": "top_level_decode", + "errors": list(di["errors"])}, + )) + return issues + + _validate_placement(di, size_of_image, issues) + _validate_truncations(di, issues) + _validate_descriptors(di, size_of_image, issues) + + return issues + + +# ================================================================= +# Placement +# ================================================================= + +def _validate_placement(di: Dict[str, Any], + size_of_image: Optional[int], + issues: List[StructuralIssue]) -> None: + """ + The delay-load directory must lie within the PE image (SizeOfImage). + """ + rva = di.get("rva") + size = di.get("size") or 0 + + # Skip placement check if the analysis layer didn't populate + # size_of_image. This shouldn't happen in normal operation — if it + # does, an upstream bug needs investigating, not a placement issue. + if rva is None or size_of_image is None: + return + + if rva + size > size_of_image: + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS, + details={"rva": rva, "size": size, + "size_of_image": size_of_image}, + )) + + +# ================================================================= +# Truncations +# ================================================================= + +def _validate_truncations(di: Dict[str, Any], + issues: List[StructuralIssue]) -> None: + """ + Map parser truncation tags to a single reason code with structured + details. Each tag becomes one issue so the consumer sees one issue + per truncated table. + """ + for tag in di.get("truncations", []) or []: + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_TABLE_TRUNCATED, + details={"table": tag}, + )) + + +# ================================================================= +# Descriptor-level validation +# ================================================================= + +def _validate_descriptors(di: Dict[str, Any], + size_of_image: Optional[int], + issues: List[StructuralIssue]) -> None: + """ + Walk each IMAGE_DELAY_IMPORT_DESCRIPTOR and emit per-descriptor + structural issues. + """ + descriptors = di.get("descriptors", []) or [] + is_64bit = di.get("is_64bit", False) + + for descriptor in descriptors: + index = descriptor.get("index") + descriptor_errors = descriptor.get("errors", []) or [] + + # ---- v0 (legacy VA mode) attribute check ---- + # Pre-Windows 2000 binaries use raw VAs in delay-load tables + # rather than RVAs. Vanishingly rare in modern binaries. + if not descriptor.get("attributes_v1", True): + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE, + details={"index": index, + "attributes": descriptor.get("attributes")}, + )) + + # ---- DLL name validation ---- + dll_name_reason = _first_matching( + descriptor_errors, _DLL_NAME_ERROR_PRIORITY + ) + if dll_name_reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID, + details={"index": index, + "dll_name_rva": descriptor.get("dll_name_rva"), + "dll_name": descriptor.get("dll_name"), + "reason": dll_name_reason}, + )) + + # ---- INT/IAT table-level errors ---- + # INT and IAT each produce their own descriptor-level errors + # via the parser. Emit one issue per affected table. + int_reason = _first_matching( + descriptor_errors, _INT_RVA_ERROR_PRIORITY + ) + if int_reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_DESCRIPTOR_INVALID, + details={"index": index, + "table": "int", + "reason": int_reason, + "int_rva": descriptor.get("int_rva")}, + )) + + iat_reason = _first_matching( + descriptor_errors, _IAT_RVA_ERROR_PRIORITY + ) + if iat_reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_DESCRIPTOR_INVALID, + details={"index": index, + "table": "iat", + "reason": iat_reason, + "iat_rva": descriptor.get("iat_rva")}, + )) + + # ---- INT/IAT length mismatch ---- + # This is a strong signal of malformation. Emitted as its own + # reason code rather than folded into DESCRIPTOR_INVALID because + # it's a cross-table consistency check, not a per-table issue. + if "int_iat_length_mismatch" in descriptor_errors: + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_INT_IAT_MISMATCH, + details={"index": index, + "dll_name": descriptor.get("dll_name")}, + )) + + # ---- Per-import-entry validation ---- + _validate_import_entries(descriptor, issues) + + +def _validate_import_entries(descriptor: Dict[str, Any], + issues: List[StructuralIssue]) -> None: + """ + Emit per-import-entry issues. Each malformed entry produces at most + one issue with priority-resolved sub-reason. + """ + descriptor_index = descriptor.get("index") + imports = descriptor.get("imports", []) or [] + + for entry in imports: + entry_errors = entry.get("errors", []) or [] + if not entry_errors: + continue + + reason = _first_matching(entry_errors, _ENTRY_ERROR_PRIORITY) + if reason == "unknown": + continue + + issues.append(StructuralIssue( + issue=ReasonCodes.DELAY_IMPORT_ENTRY_INVALID, + details={ + "descriptor_index": descriptor_index, + "entry_index": entry.get("index"), + "is_ordinal": entry.get("is_ordinal"), + "ordinal": entry.get("ordinal"), + "name": entry.get("name"), + "name_rva": entry.get("name_rva"), + "reason": reason, + }, + )) + + +# ================================================================= +# Helpers +# ================================================================= + +def _first_matching(errors: List[str], candidates: List[str]) -> str: + """Return the first error tag from `candidates` that appears in `errors`.""" + for c in candidates: + if c in errors: + return c + return "unknown" From fcc4e2720f77696d7567bd17848918ee6cdf1056 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 30 Jun 2026 11:22:14 +0100 Subject: [PATCH 29/35] schema(internal): add precise types for delay-load structures New TypedDicts in internal_schema: - DelayImportEntry: per-import decoded view (ordinal vs name, hint, IAT value, per-entry errors) - DelayImportDescriptor: per-descriptor view including Attributes, v1/v0 mode flag, all five sub-table RVAs, bound state, and the imports list - DelayImportStruct: top-level delay-load struct shape produced by parser_delay_imports InternalMetadata gains delay_import_struct: Optional[DelayImportStruct]. Mirrors the typing discipline established for ResourcesStruct, VersionInfoStruct, and ExportStruct in earlier requirements. No runtime behaviour change. --- iocx/schemas/internal_schema.py | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/iocx/schemas/internal_schema.py b/iocx/schemas/internal_schema.py index 36cd7b7..bef66bd 100644 --- a/iocx/schemas/internal_schema.py +++ b/iocx/schemas/internal_schema.py @@ -150,6 +150,49 @@ class ExportStruct(TypedDict, total=False): errors: List[str] +# ------------------------- +# Delay-load import +# ------------------------- + +class DelayImportEntry(TypedDict, total=False): + index: int + is_ordinal: bool + ordinal: Optional[int] + hint: Optional[int] + name: Optional[str] + name_rva: Optional[int] + name_valid: bool + iat_value: Optional[int] + errors: List[str] + + +class DelayImportDescriptor(TypedDict, total=False): + index: int + attributes: int + attributes_v1: bool + dll_name_rva: int + dll_name: Optional[str] + dll_name_valid: bool + module_handle_rva: int + iat_rva: int + int_rva: int + bound_iat_rva: int + unload_iat_rva: int + timestamp: int + is_bound: bool # derived: True if bound_iat_rva != 0 + imports: List[DelayImportEntry] + errors: List[str] + + +class DelayImportStruct(TypedDict, total=False): + rva: int + size: int + is_64bit: bool + descriptors: List[DelayImportDescriptor] + truncations: List[str] + errors: List[str] + + # ------------------------- # Internal metadata schema # ------------------------- @@ -159,5 +202,6 @@ class InternalMetadata(TypedDict, total=False): version_info_struct: Optional[VersionInfoStruct] data_directories_raw: List[DataDirectoryRaw] export_struct: Optional[ExportStruct] + delay_import_struct: Optional[DelayImportStruct] optional_header_magic: int number_of_rva_and_sizes: int From ed6d53253cc22e22a2fbdf14437668f944cdbb4e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 30 Jun 2026 11:23:34 +0100 Subject: [PATCH 30/35] feat(metadata): wire build_delay_import_structure into internal metadata The delay-load struct is built alongside resources_struct, version_info_struct, and export_struct in the metadata builder. The new validate_delay_imports validator is registered in STRUCTURAL_VALIDATORS between validate_exports and validate_entropy. No public IOC contract change; delay_import_struct remains in internal metadata pending the scheduled coordinated public-contract refresh that will also promote version_info_struct, export_struct, and load_config metadata. --- iocx/engine.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iocx/engine.py b/iocx/engine.py index 94a71ed..bf3658b 100644 --- a/iocx/engine.py +++ b/iocx/engine.py @@ -15,6 +15,7 @@ from .parsers.pe_load_config import analyse_load_config from .parsers.pe_optional_header import extract_optional_header_metadata from .parsers.pe_exports import build_export_structure +from .parsers.pe_delay_load_import import build_delay_import_structure from .detectors import all_detectors from .models import Detection, PluginContext from .plugins.loader import PluginLoader @@ -166,6 +167,7 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: self._internal_metadata["resources_struct"] = build_resource_structure(pe) self._internal_metadata["version_info_struct"] = build_version_info(pe) self._internal_metadata["export_struct"] = build_export_structure(pe) + self._internal_metadata["delay_import_struct"] = build_delay_import_structure(pe) self._internal_metadata["data_directories_raw"] = analyse_data_directories_raw(pe) self._internal_metadata.update(extract_optional_header_metadata(pe)) internal: InternalMetadata = self._internal_metadata From f44e56d20e0938c8326f3c4fdc71fc06f47d1b26 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 30 Jun 2026 11:25:54 +0100 Subject: [PATCH 31/35] feat(metadata): wire validator and add reason codes The delay-load struct is built alongside resources_struct, version_info_struct, and export_struct in the metadata builder. The new validate_delay_imports validator is registered in STRUCTURAL_VALIDATORS between validate_exports and validate_entropy. All reason codes surfaced by the validator are added in this commit --- iocx/reason_codes.py | 14 ++++++++++++++ iocx/validators/__init__.py | 3 +++ 2 files changed, 17 insertions(+) diff --git a/iocx/reason_codes.py b/iocx/reason_codes.py index 4f6879a..3701f9b 100644 --- a/iocx/reason_codes.py +++ b/iocx/reason_codes.py @@ -147,6 +147,20 @@ class ReasonCodes: EXPORT_FUNCTION_RVA_INVALID = "export_function_rva_invalid" EXPORT_FORWARDER_MALFORMED = "export_forwarder_malformed" + # --- Delay-load import directory anomalies --- + DELAY_IMPORT_DIRECTORY_INVALID_HEADER = "delay_import_directory_invalid_header" + DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS = "delay_import_directory_out_of_bounds" + DELAY_IMPORT_TABLE_TRUNCATED = "delay_import_table_truncated" + + # --- Delay-load descriptor anomalies --- + DELAY_IMPORT_DESCRIPTOR_INVALID = "delay_import_descriptor_invalid" + DELAY_IMPORT_DLL_NAME_INVALID = "delay_import_dll_name_invalid" + DELAY_IMPORT_INT_IAT_MISMATCH = "delay_import_int_iat_mismatch" + DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE = "delay_import_attributes_legacy_va_mode" + + # --- Delay-load entry anomalies --- + DELAY_IMPORT_ENTRY_INVALID = "delay_import_entry_invalid" + # --- Packer heuristics (interpretation layer) --- PACKER_SECTION_NAME = "packer_section_name" PACKER_HIGH_ENTROPY_SECTION = "high_entropy_section" diff --git a/iocx/validators/__init__.py b/iocx/validators/__init__.py index 749ddf6..92722e0 100644 --- a/iocx/validators/__init__.py +++ b/iocx/validators/__init__.py @@ -13,6 +13,7 @@ from .resources import validate_resources from .version_info import validate_version_info from .exports import validate_exports +from .delay_imports import validate_delay_imports from .entropy import validate_entropy STRUCTURAL_VALIDATORS = { @@ -36,6 +37,8 @@ "version_info": validate_version_info, # Exports "exports": validate_exports, + # Delay imports + "delay_imports": validate_delay_imports, # Entropy metrics (high entropy sections, overlays, uniform patterns) "entropy": validate_entropy, } From e6f7a69707d1831fe582b6326a60aab0496d6349 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 30 Jun 2026 11:33:22 +0100 Subject: [PATCH 32/35] Rename delay imports parser --- iocx/engine.py | 2 +- iocx/parsers/{pe_delay_load_import.py => pe_delay_imports.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename iocx/parsers/{pe_delay_load_import.py => pe_delay_imports.py} (100%) diff --git a/iocx/engine.py b/iocx/engine.py index bf3658b..fd63364 100644 --- a/iocx/engine.py +++ b/iocx/engine.py @@ -15,7 +15,7 @@ from .parsers.pe_load_config import analyse_load_config from .parsers.pe_optional_header import extract_optional_header_metadata from .parsers.pe_exports import build_export_structure -from .parsers.pe_delay_load_import import build_delay_import_structure +from .parsers.pe_delay_imports import build_delay_import_structure from .detectors import all_detectors from .models import Detection, PluginContext from .plugins.loader import PluginLoader diff --git a/iocx/parsers/pe_delay_load_import.py b/iocx/parsers/pe_delay_imports.py similarity index 100% rename from iocx/parsers/pe_delay_load_import.py rename to iocx/parsers/pe_delay_imports.py From b702b59195390142b469cef171dc528f048671b1 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 30 Jun 2026 12:14:53 +0100 Subject: [PATCH 33/35] test(delay_imports): coverage on parser and validator Parser tests (~70 cases): - Locator paths: missing data directory, IndexError, AttributeError, zero RVA, zero size, valid placement - PE32 vs PE32+ Magic detection - 32-byte IMAGE_DELAY_IMPORT_DESCRIPTOR decoding: valid, all zero (terminator), unpack failure - Descriptor array walk: single descriptor, multiple descriptors, zero terminator, declared size exhaustion, hard count limit - INT/IAT thunk arrays: empty, terminator, truncation, max imports, unpack failure - IMAGE_IMPORT_BY_NAME structure: valid, too short, unterminated, non-ASCII, hint unpack failure - DLL name string reading: valid, zero RVA, read failed, unterminated, non-printable, non-ASCII - Ordinal entry decoding: high bit set, ordinal zero - Name entry decoding: high bit clear, valid name resolution - v1 vs v0 attribute mode capture - Bound state detection - Full roundtrip on simulated mspaint-like single-descriptor binary - Output contract: required keys, list type guarantees - Determinism: identical output across 20 runs on valid and malformed input Validator tests (~50 cases): - Absence and top-level decode short-circuit - Placement: in-bounds, exceeds image, silent fallbacks - Truncations: single, multiple, per-table emission - Per-descriptor: every error class, priority resolution, no double-emission - DLL name validation: all sub-reasons with priority order pinned - INT/IAT mismatch: pure mismatch and cascade with entry errors - v0 attribute mode: single emission and cascade with downstream errors - Per-entry: every error class with priority order pinned - Combined anomalies and output contract - Determinism across 20 runs All defensive code paths exercised via monkeypatched struct.error injection. No # pragma: no cover added; all defensive branches reachable through targeted input. --- tests/unit/parsers/test_pe_delay_imports.py | 1099 +++++++++++++++++ .../test_validator_delay_imports.py | 560 +++++++++ 2 files changed, 1659 insertions(+) create mode 100644 tests/unit/parsers/test_pe_delay_imports.py create mode 100644 tests/unit/validators/test_validator_delay_imports.py diff --git a/tests/unit/parsers/test_pe_delay_imports.py b/tests/unit/parsers/test_pe_delay_imports.py new file mode 100644 index 0000000..cae4df6 --- /dev/null +++ b/tests/unit/parsers/test_pe_delay_imports.py @@ -0,0 +1,1099 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Unit tests for iocx.parsers.pe_delay_imports. + +Strategy: +- Byte-level fixture builders construct IMAGE_DELAY_IMPORT_DESCRIPTOR + structures, INT/IAT thunk arrays, and IMAGE_IMPORT_BY_NAME blobs + directly via struct.pack. +- Fake PE objects with controlled OPTIONAL_HEADER, DATA_DIRECTORY[13], + and get_data() responses isolate parser logic from pefile. +- Determinism tests assert byte-for-byte stable output across runs. +""" + +from __future__ import annotations + +import struct +from typing import Any, Dict, List, Optional + +import pytest + +from iocx.parsers.pe_delay_imports import ( + build_delay_import_structure, + _decode_descriptor, + _decode_import_entry, + _is_pe32_plus, + _is_zero_descriptor, + _locate_delay_import_directory, + _read_asciiz, + _read_import_by_name, + _read_thunk_array, + _DESCRIPTOR_SIZE, + _DELAY_IMPORT_DIRECTORY_INDEX, + _MAGIC_PE32, + _MAGIC_PE32_PLUS, +) + + +# ================================================================= +# Byte-level builders +# ================================================================= + +def _build_descriptor( + attributes: int = 0x01, + dll_name_rva: int = 0x2000, + module_handle_rva: int = 0x3000, + iat_rva: int = 0x4000, + int_rva: int = 0x5000, + bound_iat_rva: int = 0, + unload_iat_rva: int = 0, + timestamp: int = 0, +) -> bytes: + """Build a 32-byte IMAGE_DELAY_IMPORT_DESCRIPTOR.""" + return struct.pack( + " bytes: + """Build a zero descriptor (array terminator).""" + return b"\x00" * _DESCRIPTOR_SIZE + + +def _pack_dword(value: int) -> bytes: + return struct.pack(" bytes: + return struct.pack(" bytes: + """Build a DWORD-sized thunk array terminated by a NULL DWORD.""" + return b"".join(_pack_dword(t) for t in thunks) + _pack_dword(0) + + +def _build_int_iat_array_64(thunks: List[int]) -> bytes: + """Build a QWORD-sized thunk array terminated by a NULL QWORD.""" + return b"".join(_pack_qword(t) for t in thunks) + _pack_qword(0) + + +def _build_import_by_name(hint: int, name: str) -> bytes: + """Build an IMAGE_IMPORT_BY_NAME structure: WORD hint + ASCIIZ name.""" + return struct.pack(" bytes: + return s.encode("ascii") + b"\x00" + + +# Bit patterns for ordinal vs name thunks +def _ordinal_thunk_32(ordinal: int) -> int: + return 0x80000000 | ordinal + + +def _ordinal_thunk_64(ordinal: int) -> int: + return 0x8000000000000000 | ordinal + + +# ================================================================= +# Fake PE object +# ================================================================= + +class _FakeDataDir: + def __init__(self, rva: int, size: int): + self.VirtualAddress = rva + self.Size = size + + +class _FakeOptHdr: + def __init__( + self, + delay_dir: Optional[_FakeDataDir], + magic: int = _MAGIC_PE32_PLUS, + ): + self.Magic = magic + # DATA_DIRECTORY needs at least 14 entries (index 13 is delay-load) + self.DATA_DIRECTORY = [None] * 14 + if delay_dir is not None: + self.DATA_DIRECTORY[_DELAY_IMPORT_DIRECTORY_INDEX] = delay_dir + + +class _FakePE: + """ + Minimal duck-typed pe object exposing OPTIONAL_HEADER, DATA_DIRECTORY, + and get_data(rva, size). + + get_data handles offset reads: if a requested (rva, size) range falls + inside a stored buffer (looked up by the buffer's base RVA), the + appropriate slice is returned. This matches real pefile behaviour + where the parser walks thunk arrays one element at a time with + incrementing RVAs. + """ + def __init__( + self, + delay_rva: int = 0, + delay_size: int = 0, + is_64bit: bool = True, + data_by_rva: Optional[Dict[int, bytes]] = None, + raise_on_get_data: Optional[Exception] = None, + raise_for_rva: Optional[int] = None, + ): + magic = _MAGIC_PE32_PLUS if is_64bit else _MAGIC_PE32 + if delay_rva == 0 and delay_size == 0: + self.OPTIONAL_HEADER = _FakeOptHdr(None, magic=magic) + else: + self.OPTIONAL_HEADER = _FakeOptHdr( + _FakeDataDir(delay_rva, delay_size), magic=magic, + ) + self._data = data_by_rva or {} + self._raise = raise_on_get_data + self._raise_for_rva = raise_for_rva + + def get_data(self, rva: int, size: int) -> bytes: + if self._raise is not None: + if self._raise_for_rva is None or self._raise_for_rva == rva: + raise self._raise + + candidates = sorted( + (base for base in self._data if base <= rva), + reverse=True, + ) + for base in candidates: + buf = self._data[base] + offset = rva - base + if offset <= len(buf): + # Returns the available slice; may be empty if offset == len(buf) + # or if buf itself is empty. Parser short-read detection handles this. + return buf[offset:offset + size] + + raise ValueError(f"no fixture data covers rva {rva:#x} size {size}") + + +# ================================================================= +# _locate_delay_import_directory +# ================================================================= + +class TestLocator: + + def test_no_data_directory_returns_none(self): + pe = type("FakePE", (), {})() + assert _locate_delay_import_directory(pe) is None + + def test_index_error_returns_none(self): + class _PE: + class OPTIONAL_HEADER: + DATA_DIRECTORY = [] + assert _locate_delay_import_directory(_PE) is None + + def test_attribute_error_returns_none(self): + class _PE: + class OPTIONAL_HEADER: + pass + assert _locate_delay_import_directory(_PE) is None + + def test_zero_rva_returns_none(self): + pe = _FakePE(delay_rva=0, delay_size=100) + assert _locate_delay_import_directory(pe) is None + + def test_zero_size_returns_none(self): + pe = _FakePE(delay_rva=0x1000, delay_size=0) + assert _locate_delay_import_directory(pe) is None + + def test_valid_directory_returns_rva_size(self): + pe = _FakePE(delay_rva=0x1000, delay_size=64) + assert _locate_delay_import_directory(pe) == (0x1000, 64) + + def test_type_error_in_int_cast_returns_none(self): + class _BadDir: + VirtualAddress = "not an int" + Size = 64 + + class _PE: + class OPTIONAL_HEADER: + DATA_DIRECTORY = [None] * 14 + _PE.OPTIONAL_HEADER.DATA_DIRECTORY[13] = _BadDir() + assert _locate_delay_import_directory(_PE) is None + + +# ================================================================= +# _is_pe32_plus +# ================================================================= + +class TestIsPe32Plus: + + def test_pe32_plus_magic_returns_true(self): + pe = _FakePE(is_64bit=True) + assert _is_pe32_plus(pe) is True + + def test_pe32_magic_returns_false(self): + pe = _FakePE(is_64bit=False) + assert _is_pe32_plus(pe) is False + + def test_missing_optional_header_returns_false(self): + pe = type("FakePE", (), {})() + assert _is_pe32_plus(pe) is False + + def test_invalid_magic_returns_false(self): + class _BadOH: + Magic = "not an int" + + class _PE: + OPTIONAL_HEADER = _BadOH() + assert _is_pe32_plus(_PE) is False + + +# ================================================================= +# _decode_descriptor and _is_zero_descriptor +# ================================================================= + +class TestDecodeDescriptor: + + def test_valid_descriptor_decoded(self): + buf = _build_descriptor( + attributes=0x01, + dll_name_rva=0x2000, + module_handle_rva=0x3000, + iat_rva=0x4000, + int_rva=0x5000, + bound_iat_rva=0x6000, + unload_iat_rva=0, + timestamp=0xDEADBEEF, + ) + d = _decode_descriptor(buf, index=0) + assert d is not None + assert d["attributes"] == 0x01 + assert d["attributes_v1"] is True + assert d["dll_name_rva"] == 0x2000 + assert d["module_handle_rva"] == 0x3000 + assert d["iat_rva"] == 0x4000 + assert d["int_rva"] == 0x5000 + assert d["bound_iat_rva"] == 0x6000 + assert d["unload_iat_rva"] == 0 + assert d["timestamp"] == 0xDEADBEEF + assert d["is_bound"] is True + assert d["imports"] == [] + assert d["errors"] == [] + + def test_v0_descriptor_attributes_v1_false(self): + buf = _build_descriptor(attributes=0x00) + d = _decode_descriptor(buf, index=0) + assert d["attributes"] == 0 + assert d["attributes_v1"] is False + + def test_unbound_descriptor(self): + buf = _build_descriptor(bound_iat_rva=0) + d = _decode_descriptor(buf, index=0) + assert d["is_bound"] is False + + def test_too_short_buffer_returns_none(self): + assert _decode_descriptor(b"\x00" * 16, index=0) is None + + def test_zero_descriptor_recognized(self): + d = _decode_descriptor(_zero_descriptor(), index=0) + assert _is_zero_descriptor(d) is True + + def test_non_zero_descriptor_not_recognized_as_terminator(self): + buf = _build_descriptor(dll_name_rva=0x2000) + d = _decode_descriptor(buf, index=0) + assert _is_zero_descriptor(d) is False + + def test_descriptor_unpack_failure_appends_error_and_breaks(self, monkeypatch): + """ + Cover the defensive struct.error path in _decode_descriptor. + + Force struct.unpack_from to raise on a buffer that has passed all + length checks, simulating a hypothetical struct module failure. + The parser must append an indexed error tag and break the walk. + """ + import iocx.parsers.pe_delay_imports as pdi + + real_unpack_from = struct.unpack_from + + def fake_unpack_from(fmt, buf, offset=0): + if fmt == " Dict[str, Any]: + return {"size_of_image": size_of_image} + + +def _make_entry( + index: int = 0, + is_ordinal: bool = False, + ordinal: Optional[int] = None, + hint: Optional[int] = 0x10, + name: Optional[str] = "Foo", + name_rva: Optional[int] = 0x6000, + name_valid: bool = True, + iat_value: Optional[int] = None, + errors: Optional[List[str]] = None, +) -> Dict[str, Any]: + return { + "index": index, + "is_ordinal": is_ordinal, + "ordinal": ordinal, + "hint": hint, + "name": name, + "name_rva": name_rva, + "name_valid": name_valid, + "iat_value": iat_value, + "errors": errors or [], + } + + +def _make_descriptor( + index: int = 0, + attributes: int = 0x01, + attributes_v1: bool = True, + dll_name_rva: int = 0x2000, + dll_name: Optional[str] = "test.dll", + dll_name_valid: bool = True, + module_handle_rva: int = 0x3000, + iat_rva: int = 0x4000, + int_rva: int = 0x5000, + bound_iat_rva: int = 0, + unload_iat_rva: int = 0, + timestamp: int = 0, + is_bound: bool = False, + imports: Optional[List[Dict[str, Any]]] = None, + errors: Optional[List[str]] = None, +) -> Dict[str, Any]: + return { + "index": index, + "attributes": attributes, + "attributes_v1": attributes_v1, + "dll_name_rva": dll_name_rva, + "dll_name": dll_name, + "dll_name_valid": dll_name_valid, + "module_handle_rva": module_handle_rva, + "iat_rva": iat_rva, + "int_rva": int_rva, + "bound_iat_rva": bound_iat_rva, + "unload_iat_rva": unload_iat_rva, + "timestamp": timestamp, + "is_bound": is_bound, + "imports": imports or [], + "errors": errors or [], + } + + +def _make_di( + rva: int = 0x1000, + size: int = 64, + is_64bit: bool = True, + descriptors: Optional[List[Dict[str, Any]]] = None, + truncations: Optional[List[str]] = None, + errors: Optional[List[str]] = None, +) -> Dict[str, Any]: + return { + "rva": rva, + "size": size, + "is_64bit": is_64bit, + "descriptors": descriptors or [], + "truncations": truncations or [], + "errors": errors or [], + } + + +def _codes(issues) -> List: + return [i["issue"] for i in issues] + + +def _details_for(issues, code) -> List[Dict[str, Any]]: + return [i["details"] for i in issues if i["issue"] == code] + + +# ================================================================= +# Absence +# ================================================================= + +class TestAbsence: + + def test_no_delay_import_struct_returns_no_issues(self): + assert validate_delay_imports({}, _make_analysis()) == [] + + def test_explicit_none_returns_no_issues(self): + assert validate_delay_imports( + {"delay_import_struct": None}, _make_analysis(), + ) == [] + + +# ================================================================= +# Top-level decode short-circuit +# ================================================================= + +class TestTopLevelDecodeFailure: + + def test_errors_present_emits_invalid_header_and_returns_early(self): + di = _make_di(errors=["header_read_failed"]) + di["truncations"] = ["delay_import_descriptor_truncated"] + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + codes = _codes(issues) + assert ReasonCodes.DELAY_IMPORT_DIRECTORY_INVALID_HEADER in codes + # Should not emit truncation issues due to early return + assert ReasonCodes.DELAY_IMPORT_TABLE_TRUNCATED not in codes + details = _details_for(issues, + ReasonCodes.DELAY_IMPORT_DIRECTORY_INVALID_HEADER) + assert details[0]["reason"] == "top_level_decode" + + +# ================================================================= +# Placement +# ================================================================= + +class TestPlacement: + + def test_in_bounds_no_issue(self): + di = _make_di(rva=0x1000, size=64) + issues = validate_delay_imports( + {"delay_import_struct": di}, + _make_analysis(size_of_image=0x100000), + ) + assert ReasonCodes.DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS not in _codes(issues) + + def test_extends_past_image_flagged(self): + di = _make_di(rva=0xFFFF0, size=0x200) + issues = validate_delay_imports( + {"delay_import_struct": di}, + _make_analysis(size_of_image=0x100000), + ) + details = _details_for(issues, + ReasonCodes.DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS) + assert len(details) == 1 + assert details[0]["rva"] == 0xFFFF0 + + def test_silent_when_size_of_image_missing(self): + di = _make_di(rva=0xFFFF0, size=0x200) + issues = validate_delay_imports( + {"delay_import_struct": di}, + _make_analysis(size_of_image=None), + ) + assert ReasonCodes.DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS not in _codes(issues) + + +# ================================================================= +# Truncations +# ================================================================= + +class TestTruncations: + + def test_no_truncations_no_issues(self): + di = _make_di(truncations=[]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + assert ReasonCodes.DELAY_IMPORT_TABLE_TRUNCATED not in _codes(issues) + + def test_single_truncation_emits_one_issue(self): + di = _make_di(truncations=["delay_import_descriptor_truncated"]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_TABLE_TRUNCATED) + assert len(details) == 1 + assert details[0]["table"] == "delay_import_descriptor_truncated" + + def test_multiple_truncations_emit_separate_issues(self): + di = _make_di(truncations=[ + "delay_import_descriptor_truncated", + "int_truncated", + "iat_truncated", + ]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_TABLE_TRUNCATED) + assert len(details) == 3 + + +# ================================================================= +# Descriptor validation +# ================================================================= + +class TestDescriptorValidation: + + def test_clean_descriptor_no_issues(self): + d = _make_descriptor() + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + assert issues == [] + + def test_v0_attributes_flagged(self): + d = _make_descriptor(attributes=0, attributes_v1=False) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, + ReasonCodes.DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE) + assert len(details) == 1 + assert details[0]["index"] == 0 + assert details[0]["attributes"] == 0 + + def test_dll_name_rva_zero_flagged(self): + d = _make_descriptor(errors=["dll_name_rva_zero"]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "dll_name_rva_zero" + + def test_dll_name_priority_resolution(self): + """dll_name_rva_zero wins over read_failed when both are present.""" + d = _make_descriptor(errors=["read_failed", "dll_name_rva_zero"]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "dll_name_rva_zero" + + def test_dll_name_not_printable_flagged(self): + d = _make_descriptor( + dll_name="kernel\x0132.dll", + dll_name_valid=False, + errors=["dll_name_not_printable"], + ) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID) + assert details[0]["reason"] == "dll_name_not_printable" + assert details[0]["dll_name"] == "kernel\x0132.dll" + + def test_int_rva_zero_flagged(self): + d = _make_descriptor(errors=["int_rva_zero"], int_rva=0) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_DESCRIPTOR_INVALID) + # Find the INT-specific one + int_details = [d for d in details if d["table"] == "int"] + assert len(int_details) == 1 + assert int_details[0]["reason"] == "int_rva_zero" + + def test_iat_rva_zero_flagged(self): + d = _make_descriptor(errors=["iat_rva_zero"], iat_rva=0) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_DESCRIPTOR_INVALID) + iat_details = [d for d in details if d["table"] == "iat"] + assert len(iat_details) == 1 + assert iat_details[0]["reason"] == "iat_rva_zero" + + def test_int_iat_mismatch_flagged(self): + d = _make_descriptor(errors=["int_iat_length_mismatch"]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_INT_IAT_MISMATCH) + assert len(details) == 1 + assert details[0]["dll_name"] == "test.dll" + + def test_no_double_emission_per_descriptor(self): + """A descriptor with multiple DLL-name errors emits only one issue.""" + d = _make_descriptor( + errors=["dll_name_rva_zero", "read_failed", "unterminated"], + ) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + dll_issues = [ + i for i in issues + if i["issue"] == ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID + ] + assert len(dll_issues) == 1 + + def test_entry_with_unknown_error_tag_skipped(self): + """ + Cover the defensive guard: an entry whose errors contain only tags + not in _ENTRY_ERROR_PRIORITY (e.g., a future parser tag that the + validator's priority list hasn't been updated to recognise) is + silently skipped without emitting a reason code. + """ + e = _make_entry(errors=["future_parser_tag_not_yet_recognised"]) + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + # No DELAY_IMPORT_ENTRY_INVALID should fire — the unknown tag is + # not in the priority list, so the validator skips emission rather + # than reporting a spurious "unknown" reason. + assert ReasonCodes.DELAY_IMPORT_ENTRY_INVALID not in _codes(issues) + + +# ================================================================= +# Entry validation +# ================================================================= + +class TestEntryValidation: + + def test_clean_entry_no_issues(self): + e = _make_entry() + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + assert ReasonCodes.DELAY_IMPORT_ENTRY_INVALID not in _codes(issues) + + def test_ordinal_zero_flagged(self): + e = _make_entry( + is_ordinal=True, ordinal=0, + errors=["ordinal_zero"], + ) + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_ENTRY_INVALID) + assert len(details) == 1 + assert details[0]["reason"] == "ordinal_zero" + assert details[0]["is_ordinal"] is True + assert details[0]["ordinal"] == 0 + + def test_int_entry_missing_flagged(self): + e = _make_entry(errors=["int_entry_missing"]) + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_ENTRY_INVALID) + assert details[0]["reason"] == "int_entry_missing" + + def test_int_entry_zero_flagged(self): + e = _make_entry(errors=["int_entry_zero"]) + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_ENTRY_INVALID) + assert details[0]["reason"] == "int_entry_zero" + + def test_name_unterminated_flagged(self): + e = _make_entry( + name=None, name_valid=False, + errors=["name_unterminated"], + ) + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_ENTRY_INVALID) + assert details[0]["reason"] == "name_unterminated" + + def test_name_not_printable_flagged(self): + e = _make_entry( + name="Foo\x01Bar", name_valid=False, + errors=["name_not_printable"], + ) + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_ENTRY_INVALID) + assert details[0]["reason"] == "name_not_printable" + assert details[0]["name"] == "Foo\x01Bar" + + def test_entry_priority_resolution(self): + """int_entry_missing wins over name_unterminated.""" + e = _make_entry(errors=["name_unterminated", "int_entry_missing"]) + d = _make_descriptor(imports=[e]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_ENTRY_INVALID) + assert details[0]["reason"] == "int_entry_missing" + + def test_multiple_bad_entries_each_flagged(self): + e1 = _make_entry(index=0, errors=["int_entry_missing"]) + e2 = _make_entry(index=1, errors=["name_unterminated"]) + e3 = _make_entry(index=2) # clean + d = _make_descriptor(imports=[e1, e2, e3]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + details = _details_for(issues, ReasonCodes.DELAY_IMPORT_ENTRY_INVALID) + assert len(details) == 2 + + +# ================================================================= +# Combined scenarios +# ================================================================= + +class TestCombinedAnomalies: + + def test_v0_with_cascading_dll_errors_emits_both(self): + d = _make_descriptor( + attributes=0, attributes_v1=False, + errors=["dll_name_not_printable"], + ) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + codes = set(_codes(issues)) + assert ReasonCodes.DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE in codes + assert ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID in codes + + def test_multiple_descriptors_each_independently_validated(self): + d1 = _make_descriptor(index=0, errors=["dll_name_rva_zero"]) + d2 = _make_descriptor(index=1, attributes=0, attributes_v1=False) + di = _make_di(descriptors=[d1, d2]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + codes = set(_codes(issues)) + assert ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID in codes + assert ReasonCodes.DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE in codes + + +# ================================================================= +# Output contract +# ================================================================= + +class TestOutputContract: + + def test_returns_list(self): + result = validate_delay_imports( + {"delay_import_struct": _make_di()}, _make_analysis(), + ) + assert isinstance(result, list) + + def test_clean_returns_empty_list(self): + result = validate_delay_imports( + {"delay_import_struct": _make_di()}, _make_analysis(), + ) + assert result == [] + + def test_each_issue_has_issue_and_details(self): + d = _make_descriptor(errors=["dll_name_rva_zero"]) + di = _make_di(descriptors=[d]) + issues = validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + for issue in issues: + assert "issue" in issue + assert "details" in issue + assert isinstance(issue["details"], dict) + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + + def test_repeated_validation_produces_identical_issues(self): + d = _make_descriptor( + attributes=0, attributes_v1=False, + errors=["dll_name_not_printable", "int_iat_length_mismatch"], + imports=[ + _make_entry(index=0, errors=["int_entry_missing"]), + _make_entry(index=1, errors=["name_unterminated"]), + ], + ) + di = _make_di( + truncations=["delay_import_descriptor_truncated"], + descriptors=[d], + ) + metadata = {"delay_import_struct": di} + analysis = _make_analysis() + + results = [ + validate_delay_imports(metadata, analysis) for _ in range(20) + ] + for r in results[1:]: + assert r == results[0] + + def test_priority_resolution_deterministic(self): + d = _make_descriptor( + errors=["read_failed", "dll_name_rva_zero", "unterminated"], + ) + di = _make_di(descriptors=[d]) + results = [ + validate_delay_imports( + {"delay_import_struct": di}, _make_analysis(), + ) + for _ in range(20) + ] + for r in results[1:]: + assert r == results[0] + # Confirm priority winner + details = _details_for(results[0], + ReasonCodes.DELAY_IMPORT_DLL_NAME_INVALID) + assert details[0]["reason"] == "dll_name_rva_zero" From 59415af21a86814315182bdb79e38d072928de63 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 1 Jul 2026 11:31:37 +0100 Subject: [PATCH 34/35] docs(reason_codes): document delay-load import reason codes - Extend the reason-codes reference with a new top-level Delay-Load --- CHANGELOG.md | 420 ++++-------------- docs/specs/reason-codes.md | 127 +++++- ...ral-validation-deterministic-heuristics.md | 39 ++ 3 files changed, 252 insertions(+), 334 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8690c55..0c17c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,347 +1,121 @@ -# **v0.7.5 — Unreleased** +# v0.7.5 — Unreleased + +This release substantially expands IOCX's structural validator suite with four new parser/validator pairs (export tables, delay-load imports, VS_VERSIONINFO, and the resource directory's Type → Name → Language hierarchy), enriches public metadata with security-relevant Optional Header and per-resource fields, and adds 24 new structural reason codes across exports, resources, and delay-load. All new code lands with 100% line and branch coverage backed by real-binary verification. ## Added -- **Resource directory hierarchy enforcement.** The resource validator now - tracks tree depth and enforces the PE specification's Type → Name → Language - layering. Two new reason codes are emitted: - - `RESOURCE_DIRECTORY_LANGUAGE_NOT_ID` — a depth-2 (Language layer) entry - uses a name instead of an integer LCID. - - `RESOURCE_DATA_AT_INVALID_DEPTH` — a data leaf appears outside the - Language layer. -- **Deterministic VS_VERSIONINFO extraction.** New `pe_version_info` parser - module decodes the version-info envelope, VS_FIXEDFILEINFO, StringFileInfo - and VarFileInfo structures purely from bytes using `struct.unpack_from`. - Leaf selection across multiple RT_VERSION entries is deterministic, sorted - by `(name_id, language_id)`. The decoder never raises; sub-structure - failures emit tombstone tags in an `errors[]` list. -- **Version-info structural validator.** New `validator_version_info` module - maps parser output to four new reason codes: - - `RESOURCE_VERSIONINFO_INVALID_HEADER` — placement, `szKey`, or `wLength` - malformed. - - `RESOURCE_VERSIONINFO_INVALID_FIXEDINFO` — VS_FIXEDFILEINFO signature or - struct version wrong, or parse failed. - - `RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO` — StringFileInfo, - StringTable, or String malformed. - - `RESOURCE_VERSIONINFO_INVALID_VARFILEINFO` — VarFileInfo or Var malformed, - or Translation array not DWORD-aligned. - - Absence of RT_VERSION is not treated as a structural defect. -- **Precise internal metadata typing.** `InternalMetadata.resources_struct` is - now `Optional[ResourcesStruct]` with a fully-typed `ResourceEntry` shape - replacing `List[Any]`. New `VersionInfoStruct` and its sub-types - (`FixedFileInfo`, `StringFileInfo`, `StringTable`, `VarFileInfo`, - `VarEntry`, `Translation`) are declared in the schema. -- **Deterministic export table extraction.** New `pe_exports` module - decodes the 40-byte `IMAGE_EXPORT_DIRECTORY` header, the Export Address - Table, Export Name Pointer Table, and Export Ordinal Table purely from - bytes using `struct.unpack_from`. Forwarder detection follows the PE - spec rule (address RVA falls within the export directory range). - Name and forwarder string reads are bounded; the decoder never raises; - sub-structure failures emit tombstone tags in `truncations[]` and - per-entry `errors[]`. -- **Export table structural validator.** New `exports` module - maps parser output to ten new reason codes: - - `EXPORT_DIRECTORY_INVALID_HEADER` — header malformed or declared - counts inconsistent with declared array RVAs. - - `EXPORT_DIRECTORY_OUT_OF_BOUNDS` — directory extends past - `SizeOfImage`. - - `EXPORT_TABLE_TRUNCATED` — declared sub-table size exceeds available - bytes (per-table emission via `details["table"]`). - - `EXPORT_NAME_RVA_INVALID` — name pointer RVA unusable - (priority-resolved sub-reasons: `name_rva_missing`, `name_rva_zero`, - `read_failed`, `unterminated`). - - `EXPORT_NAME_NOT_ASCII` — name decoded but contains non-printable - bytes (priority-resolved sub-reasons: `non_ascii`, - `name_not_printable_ascii`). - - `EXPORT_NAME_POINTER_TABLE_UNSORTED` — ENPT violates the PE-spec - requirement that names be sorted lexicographically for binary search. - - `EXPORT_NAME_ORDINAL_INDEX_INVALID` — EOT entry missing or points - outside the EAT. - - `EXPORT_ORDINAL_OUT_OF_RANGE` — `Base + NumberOfFunctions - 1` - exceeds 16-bit range. - - `EXPORT_FUNCTION_RVA_INVALID` — function address RVA exceeds - `SizeOfImage`. - - `EXPORT_FORWARDER_MALFORMED` — forwarder string unreadable or - violates `DllName.SymbolName` / `DllName.#Ordinal` grammar. - - Absence of an export directory is not treated as a structural defect; - most EXEs legitimately have no exports. -- **Precise export-table typing.** New TypedDicts for `ExportStruct`, - `ExportDirectoryHeader`, `ExportFunctionEntry`, and - `ExportNamePointerEntry`. `InternalMetadata.export_struct` is typed as - `Optional[ExportStruct]`. -- **Enriched resource metadata in CLI output.** The `_parse_resources` - function now exposes a structured `ResourceEntry` for every resource - in the PE, covering all four fields called out in requirement 4 - (type, size, language and codepage, entropy) plus the schema-declared - `name`, `rva`, and `raw_offset` fields. Output is deterministic and - JSON-safe. -- **Per-entry structured error reporting.** Resources whose data bytes - cannot be read are no longer silently dropped from the output. Instead, - the entry is emitted with an `errors` list populated with tombstone - tags describing the failure: - - `size_invalid` — declared size is zero or negative. - - `rva_invalid` — data RVA is negative. - - `data_out_of_bounds` — RVA + size exceeds the memory-mapped image. - - `raw_offset_invalid` — `get_offset_from_rva` failed to resolve the - file offset. - - This makes the resource count visible to consumers even when individual - entries cannot be fully decoded. -- **Codepage field on resource entries.** The `CodePage` field from the - PE resource data structure is now captured as `codepage` in the - output, typed as `Optional[int]` (null when zero or absent). -- **Deterministic resource ordering.** The output list is sorted by - `(type, language, rva)` to ensure snapshot stability across runs. -- **Per-resource Shannon entropy.** Computed over the resource's data - bytes, rounded to 4 decimal places for snapshot stability. Entries - with unreadable data produce `entropy: null` and the corresponding - error tombstone. -- Optional Header metadata enrichment. The optional_header block - in public metadata now exposes security-relevant and sizing fields - previously not captured by IOCX, completing requirement 1. New fields: - - dll_characteristics — raw DllCharacteristics value. - - dll_characteristics_flags — decoded flag names from the - IMAGE_DLLCHARACTERISTICS_* set (e.g., "DYNAMIC_BASE", - "NX_COMPAT", "GUARD_CF"), sorted by bit position for snapshot - stability. - - dll_characteristics_unknown_bits — hex string for bits set in - DllCharacteristics but not in IOCX's known-flag mask, or null - when all set bits are recognised. - - win32_version_value — raw Win32VersionValue DWORD (deprecated - per PE spec; exposed for completeness). - - loader_flags — raw LoaderFlags DWORD (reserved per PE spec; - exposed for completeness). - - stack_reserve_size, stack_commit_size — SizeOfStackReserve - and SizeOfStackCommit (64-bit on PE32+ binaries). - - heap_reserve_size, heap_commit_size — SizeOfHeapReserve - and SizeOfHeapCommit. -- Subsystem name decoding. The header block now includes - subsystem_name, a decoded string from the IMAGE_SUBSYSTEM_* - table (e.g., "WINDOWS_CUI", "NATIVE", "EFI_APPLICATION"). - Returns null for subsystem values outside the well-known set. - The raw subsystem integer field is unchanged. -- New constants module iocx.parsers.pe_constants. Houses the - SUBSYSTEM_NAMES and DLL_CHARACTERISTICS_FLAGS lookup tables, - plus the derived DLL_CHARACTERISTICS_KNOWN_MASK. Sourced from - the Microsoft PE format specification. +### New structural validators + +- **Export table parser and validator** (pe_exports, exports). Decodes the 40-byte IMAGE_EXPORT_DIRECTORY, EAT, ENPT, and EOT purely from bytes; emits ten new reason codes covering directory, name-pointer, and function-entry anomalies. Forwarder detection follows the PE-spec rule (address RVA within export directory range). Absence of an export directory is not treated as a defect. +- **Delay-load import parser and validator** (parser_delay_imports, validator_delay_imports). Decodes the 32-byte IMAGE_DELAY_IMPORT_DESCRIPTOR, INT, and IAT purely from bytes; emits eight new reason codes covering directory, descriptor, and entry anomalies. PE32+ vs PE32 thunk sizing determined once from OPTIONAL_HEADER.Magic; v1 vs v0 attribute mode captured explicitly rather than coerced; bound state detected by bound_iat_rva != 0. Absence of a delay-load directory is not treated as a defect. +- **VS_VERSIONINFO parser and validator** (pe_version_info, validator_version_info). Decodes the version-info envelope, VS_FIXEDFILEINFO, StringFileInfo, and VarFileInfo purely from bytes; emits four new reason codes covering header, FixedInfo, StringFileInfo, and VarFileInfo malformations. Leaf selection across multiple RT_VERSION entries is deterministic, sorted by (name_id, language_id). Absence of RT_VERSION is not treated as a defect. +- **Resource hierarchy enforcement.** Resource validator now tracks tree depth and enforces the PE spec's Type → Name → Language layering, emitting two new reason codes (RESOURCE_DIRECTORY_LANGUAGE_NOT_ID, RESOURCE_DATA_AT_INVALID_DEPTH). + +### Reason codes added (24 total) + +- **Resource hierarchy (2):** RESOURCE_DIRECTORY_LANGUAGE_NOT_ID, RESOURCE_DATA_AT_INVALID_DEPTH +- **VS_VERSIONINFO (4):** RESOURCE_VERSIONINFO_INVALID_HEADER, _INVALID_FIXEDINFO, _INVALID_STRINGFILEINFO, _INVALID_VARFILEINFO +- **Exports (10):** EXPORT_DIRECTORY_INVALID_HEADER, _OUT_OF_BOUNDS, EXPORT_TABLE_TRUNCATED, EXPORT_NAME_RVA_INVALID, _NOT_ASCII, _POINTER_TABLE_UNSORTED, _ORDINAL_INDEX_INVALID, EXPORT_ORDINAL_OUT_OF_RANGE, EXPORT_FUNCTION_RVA_INVALID, EXPORT_FORWARDER_MALFORMED +- **Delay-load (8):** DELAY_IMPORT_DIRECTORY_INVALID_HEADER, _OUT_OF_BOUNDS, DELAY_IMPORT_TABLE_TRUNCATED, DELAY_IMPORT_DESCRIPTOR_INVALID, DELAY_IMPORT_DLL_NAME_INVALID, _INT_IAT_MISMATCH, _ATTRIBUTES_LEGACY_VA_MODE, DELAY_IMPORT_ENTRY_INVALID + +All new codes follow the established pattern: priority-resolved sub-reasons surfaced via details["reason"], with sub-table scoping via details["table"] where applicable. + +### Public metadata enrichment + +- **Optional Header fields.** New fields in the optional_header block: dll_characteristics (raw value), dll_characteristics_flags (decoded flag names sorted by bit position), dll_characteristics_unknown_bits (hex string for unrecognised bits), win32_version_value, loader_flags, stack_reserve_size, stack_commit_size, heap_reserve_size, heap_commit_size. +- **Header decoding.** New subsystem_name field in the header block, decoded from IMAGE_SUBSYSTEM_* (e.g., "WINDOWS_CUI"); returns null for unknown values. Raw subsystem integer unchanged. New machine_name field decoded from IMAGE_FILE_MACHINE_* (e.g., "AMD64", "I386", "ARM64"); the supporting MACHINE_NAMES table covers all 29 documented machine types. +- **Resource metadata.** The resources field now exposes a structured ResourceEntry per resource covering type, name, language, language_name, codepage, size, entropy, rva, raw_offset, and errors. Per-resource Shannon entropy is rounded to 4 decimal places. Output is sorted by (type, language, rva) for snapshot stability. Resources whose data bytes cannot be read are no longer silently dropped; they are emitted with errors populated (tags: size_invalid, rva_invalid, data_out_of_bounds, raw_offset_invalid). + +### Schema typing + +- New TypedDicts: ExportStruct, ExportDirectoryHeader, ExportFunctionEntry, ExportNamePointerEntry, DelayImportStruct, DelayImportDescriptor, DelayImportEntry, VersionInfoStruct and its sub-types (FixedFileInfo, StringFileInfo, StringTable, VarFileInfo, VarEntry, Translation). +- InternalMetadata.resources_struct is now Optional[ResourcesStruct] with a fully-typed ResourceEntry shape replacing List[Any]. InternalMetadata.export_struct, delay_import_struct, and version_info_struct are typed as Optional of their respective structs. +- New constants module iocx.parsers.pe_constants houses SUBSYSTEM_NAMES, MACHINE_NAMES, DLL_CHARACTERISTICS_FLAGS, and the derived DLL_CHARACTERISTICS_KNOWN_MASK. ## Changed -- **Resource parser hardens against corrupt RVAs.** `pe.get_offset_from_rva` - calls are now guarded against `pefile.PEFormatError` and `AttributeError`. - A corrupt RVA produces a `-1` sentinel in `raw_offset` rather than - propagating the exception. The validator's existing `data_raw < 0` arm - maps this to the existing `RESOURCE_DATA_OUT_OF_BOUNDS` reason code; no new - code introduced. -- **Validator dispatcher order.** `validate_version_info` is registered - between `validate_resources` and `validate_entropy`, reflecting its - position as a payload-specific validator nested under the resource tree. -- **Validator dispatcher order.** `validate_exports` is registered - between `validate_version_info` and `validate_entropy`, completing the - structural validator chain ahead of the entropy/derived layer. -- **`ResourceEntry` schema expanded.** Fields are now `total=False` - Optional to accommodate per-entry computation failures. New fields: - `codepage`, `errors`. Existing fields retain their meanings; `name`, - `rva`, and `raw_offset` are now populated where they were previously - declared but absent from output. -- **`_decode_langid` returns `None` for undecodable LANGIDs.** - Previously returned the magic string `"unknown"`, which conflated - several distinct states (not provided, structurally invalid, primary - language unmapped). The new behaviour returns `None` from every - "cannot decode" path, allowing consumers to distinguish cleanly. - The early-return guard `if langid < 0x0400` was removed; it was - rejecting valid neutral-sublang LANGIDs (e.g., LANGID `0x0001` - decodes correctly as `"ar"` for Arabic). -- **Resource entropy now computed over the correct byte range.** - Previously sliced `get_memory_mapped_image()` with the raw file - offset; now correctly uses the RVA. This was a regression introduced - during the requirement 4 work and caught before snapshot stamping — - entropy values now match the previous release's behaviour for all - existing fixtures. -- Convention for missing-field defaults. The new Optional Header - fields use None as the default value when pefile cannot extract - the field, distinct from 0 which indicates the binary's actual - value. This is a deliberate divergence from the existing fields' - default-to-zero convention. Rationale: the semantic split between - "missing" and "zero" is meaningful for security-relevant fields - (a DllCharacteristics of 0 means "no security features enabled," - which is itself a signal; null means "could not extract"). Existing - fields retain their default-to-zero behaviour for backward - compatibility. -- Extended metadata analyser refactored to remove duplicated decoding. - Following the parser-layer additions of subsystem_name (requirement 1) - and machine_name (this release), the analyse_extended module no - longer maintains its own _SUBSYSTEM_MAP and _MACHINE_MAP lookup - tables. The legacy subsystem_human and machine_human fields are - removed from extended metadata output. The parser layer is now the - single source of truth for both subsystem and machine name decoding. -- Machine name decoding moved to the parser layer. Added - _MACHINE_NAMES to iocx.parsers.pe_constants and machine_name - to _parse_header output. Consistent with the subsystem_name - convention introduced in requirement 1 (uppercase-underscore form, - None for unknown values). -- Resource entropy statistics tolerate per-entry errors. Following - requirement 4's introduction of resource-level errors reporting, - the extended analyser's resource summary computes entropy_min, - entropy_max, and entropy_avg over only the entries with computed - entropy values. Resources with entropy: None (due to per-entry - computation failures) are excluded from the aggregates. When no - resource has a computed entropy, all three statistics are None - rather than raising on empty min() / max(). - -## Marked as RESERVE but consider removing in the future - -- Dead `size == 0` branch in `validate_directory` (unreachable: `size` is - derived from `len(entries)` and always ≥ 16). -- Unused `rsrc_raw` and `rsrc_raw_size` locals in the resource validator. +### Validator dispatcher + +Three new validators registered in the structural validator chain, in order: + +``` +validate_resources → validate_version_info → validate_exports → validate_delay_imports → validate_entropy +``` + +This completes the resource and import/export structural validator clusters ahead of the entropy/derived layer. + +### Parser robustness + +- **Guarded RVA→offset conversion.** pe.get_offset_from_rva calls are now guarded against pefile.PEFormatError and AttributeError. A corrupt RVA produces a -1 sentinel in raw_offset rather than propagating the exception. The validator's existing data_raw < 0 arm maps this to the existing RESOURCE_DATA_OUT_OF_BOUNDS reason code; no new code introduced. +- **Resource entropy now computed over the correct byte range.** Previously sliced get_memory_mapped_image() with the raw file offset; now correctly uses the RVA. Caught before snapshot stamping; entropy values match the previous release's behaviour for all existing fixtures. + +### Schema and decoding semantics + +- **ResourceEntry schema expanded.** Fields are now total=False Optional to accommodate per-entry computation failures. New fields: codepage, errors. The name, rva, and raw_offset fields are now populated where they were previously declared but absent from output. +- **_decode_langid returns None for undecodable LANGIDs** (previously returned the magic string "unknown"). The early-return guard if langid < 0x0400 was removed; it was rejecting valid neutral-sublang LANGIDs (LANGID 0x0001 now correctly decodes as "ar" for Arabic). +- **Optional Header missing-field convention.** New Optional Header fields use None as the default value when pefile cannot extract the field, distinct from 0 which indicates the binary's actual value. This is a deliberate semantic split for security-relevant fields where "missing" and "zero" carry different meaning. Existing Optional Header fields retain their default-to-zero behaviour for backward compatibility. + +### Extended analyser refactor + +- **analyse_extended cleaned up.** Removed duplicated _SUBSYSTEM_MAP and _MACHINE_MAP lookup tables now that decoded names come from the parser layer. The legacy subsystem_human and machine_human fields are removed from extended metadata output. Resource entropy summary statistics (entropy_min, entropy_max, entropy_avg) now compute over only entries with computed entropy values, excluding entropy: None entries. ## Fixed -- Resource validator no longer silently returns when a directory's own RVA - falls outside `.rsrc`. Behaviour previously suppressed any reporting for - malformed directory placement. +- **Resource validator no longer silently returns** when a directory's own RVA falls outside .rsrc. Previously suppressed any reporting for malformed directory placement. ## Documentation -- Reason-codes reference extended with two new subsections: - *Resource Hierarchy Anomalies* and *Resource Version-Info Anomalies*. -- New validator documentation section (2.10) for the version-info validator, - including an explicit determinism rationale paragraph. -- Brief clarifying note added to the resources validator section explaining - the layering between resource-tree validation and payload validators - nested beneath it. -- Reason-codes reference extended with a new top-level *Export Anomalies* - section in three subsections (Directory, Name Pointer, Function Entry) - and a dedicated *Export Sub-Reasons* taxonomy section documenting the - `details["reason"]` contract for each code that carries one. -- New validator documentation section 2.11 for the exports validator with - explicit determinism rationale. -- Resource metadata documentation extended to describe the new - `ResourceEntry` shape, the `errors` field semantics, and the - `codepage` field's `null`-on-absent convention. -- The `_decode_langid` semantics are documented inline: primary - language and sublang decomposition, fallback to default region, - fallback to primary-language-only, fallback to `None`. -- Documented the new Optional Header fields in the schema reference, - including the dll_characteristics_unknown_bits field's role in - preserving complete information about non-decoded bits. -- Documented the mixed-default convention (0 for existing fields, - None for new fields) with rationale for the divergence. -- analyse_extended module purpose documented inline. Added a - module-level docstring clarifying that the module performs shape - conversion and derived-statistics computation only — not new - information extraction. Future contributors adding decoding logic - should consider whether it belongs in the parser layer instead. -- Validator-vs-metadata boundary commented in the exports block. - Added an inline note documenting that the extended exports view is - metadata-shaped and that structural validity of the export table is - reported separately via validator reason codes. Avoids future confusion - about whether analyse_extended should mirror validator output. +- **Reason codes reference** extended with new top-level sections for Resource Hierarchy Anomalies, Resource Version-Info Anomalies, Export Anomalies, and Delay-Load Import Anomalies, each with dedicated sub-reason taxonomy sections documenting the details["reason"] and details["table"] contracts. +- **Validator documentation** gained sections 2.5 (VS_VERSIONINFO), 2.6 (exports), and 2.7 (delay-load imports), each with explicit determinism rationale. Section 2.7 frames the three spec-interpretation questions (v0 vs v1 attribute mode, INT/IAT mirror vs bound assumption, declared-size vs walk-to-terminator) that produce cross-tool divergence. +- **Schema reference** documents the new Optional Header fields (including the dll_characteristics_unknown_bits role in preserving complete information about non-decoded bits) and the mixed-default convention with rationale. +- **analyse_extended module purpose** documented inline via module docstring clarifying that the module performs shape conversion and derived-statistics computation only, not new information extraction. +- **_decode_langid semantics** documented inline (primary language and sublang decomposition, fallback to default region, fallback to primary-language-only, fallback to None). ## Internal -- 100% line and branch coverage on `pe_version_info`, - `validator_version_info`, and the resource validator additions. -- Defensive-path coverage for every `except` clause via monkeypatched - `struct.error` injection. -- Narrow-except negative tests confirm the parser's exception handling does - not silently swallow exceptions outside `(PEFormatError, AttributeError)`. -- 100% line and branch coverage on `pe_exports` and - `exports` validator. -- Defensive-path coverage via monkeypatched `struct.error` injection - and narrow-except negative tests. -- One `# pragma: no cover` applied to a defensive return in the - validator's `_first_unsorted_index` helper, documented inline as - unreachable from the validator's call site. -- Memory-mapped image slicing uses the RVA (not the raw file offset), - which is the correct index into `get_memory_mapped_image()` output. -- Float precision pinned at 4 decimal places for entropy, matching the - precision convention used in other entropy-bearing fields elsewhere - in IOCX. -- 100% line coverage on _parse_optional_header, _parse_header, - and pe_constants. -- Test coverage includes: - - All 15 entries in SUBSYSTEM_NAMES exercised individually - - All 11 DLL characteristics flags exercised with bit-position - ordering pinned - - Unknown-bits detection for values outside the known mask - - Conservative field handling: individual missing fields do not - affect extraction of other fields - - JSON-safety contract: outputs round-trip through json.dumps - - Determinism: repeated parses produce identical output +- **100% line and branch coverage** on all new modules: pe_version_info, validator_version_info, pe_exports, exports, parser_delay_imports, validator_delay_imports, _parse_optional_header, _parse_header, pe_constants, and resource validator additions. +- **Defensive-path coverage** via monkeypatched struct.error injection across all four parsers. Narrow-except negative tests confirm parser exception handling does not silently swallow exceptions outside the documented catch list. +- **One # pragma: no cover** applied to a defensive return in the exports validator's _first_unsorted_index helper, documented inline as unreachable from the caller. +- **End-to-end binary verification.** Delay-load parser cross-checked against mspaint.exe via dumpbin /imports: 107 imports decoded from gdiplus.dll's delay-load directory with byte-exact agreement on DLL name, hint values, IAT addresses, ordering, and bound state across both tools. + +**Total: ~650 new tests bringing the suite to 1370 tests.** ## Compatibility -- **No reason-code remapping.** Existing fixture expected outputs are - unchanged for all binaries that don't exercise the new pathways. -- **No public IOC schema changes.** Version-info data is currently exposed - only in internal metadata and CLI rendering; public IOC schema exposure - is deferred to a later release with a deliberate fixture corpus refresh. -- Invalid optional header fixtures JSON contracts updated: addition of export anomalies to heuristic output. -- Resource fixture snapshots refreshed to reflect new field shape and `language_name` semantics. -- **Resource output shape is additive but reformatted.** Consumers - reading the `resources` field will see new keys (`codepage`, - `errors`, `name`, `rva`, `raw_offset`) and may see additional - entries that previously didn't appear (those with errors). Existing - per-entry field meanings are unchanged. -- **`language_name` no longer returns the string `"unknown"`.** - Consumers checking `language_name == "unknown"` will need to update - to `language_name is None`. This is a deliberate semantic correction - rather than a passive breaking change — the previous value was a - magic-string sentinel that conflated several states. -- **Snapshot refresh required for any fixture with resources.** The - `language_name` return change and the new fields will produce diffs - in expected outputs. Refresh is mechanical via the existing fixture - regeneration tooling. -- Existing field behaviour unchanged. Consumers reading - section_alignment, file_alignment, size_of_image, etc., see - the same values they did before. The default-to-zero behaviour is - preserved. -- New fields are additive. Consumers reading existing keys are - unaffected. Consumers wanting the new security-relevant fields can - read them via the documented keys; missing values surface as null. -- Snapshot refresh required for any fixture with an optional header. - Every fixture's optional_header block gains new keys; refresh is - mechanical via the existing fixture regeneration tooling. -- subsystem_human and machine_human removed from extended - metadata. Consumers should use subsystem_name (always present in - header, uppercase-underscore form) and machine_name (added in this - release). Migration is a string-conversion exercise: "WINDOWS_CUI" - ↔ "Windows CUI". If display-friendly forms are needed for CLI - rendering, that translation belongs in the renderer, not the metadata - layer. -- Snapshot refresh required. Any fixture whose extended metadata - output was snapshot-pinned will see subsystem_human and - machine_human removed, and the header block under - analysis.extended will match the public metadata's header block - exactly. Mechanical refresh. +### No remapping of existing reason codes + +Existing fixture expected outputs are unchanged for binaries that don't exercise the new pathways. + +### Snapshot refresh required + +The following changes will produce diffs in fixture expected outputs and require a coordinated snapshot refresh: + +- Resource fixtures gain new ResourceEntry keys (codepage, errors, name, rva, raw_offset); resources with errors now appear where they were previously silently dropped. +- language_name no longer returns "unknown"; consumers checking language_name == "unknown" must update to language_name is None. +- Every fixture with an optional header gains new Optional Header keys (dll_characteristics, _flags, _unknown_bits, win32_version_value, loader_flags, stack/heap sizing fields). +- header block gains subsystem_name and machine_name keys. +- Extended metadata no longer emits subsystem_human and machine_human; consumers should use subsystem_name and machine_name (the parser layer is now the single source of truth for both). + +All refreshes are mechanical via the existing fixture regeneration tooling. + +### No public IOC schema changes in this release + +export_struct and delay_import_struct are exposed only in internal metadata by design - they feed validators, not consumer schema. Public IOC schema exposure for version-info is deferred to a coordinated future release with corresponding fixture corpus refresh. ## Known scheduled work -- Six single-anomaly fixtures targeting the new resource directory reason codes (specs queued; - construction to follow). -- `pefile_usage_policy.md` documenting the deterministic-subset usage pattern - (to be drafted alongside the reproducibility appendix work). -- Public IOC schema field for `version_info` (planned for a future release - with corpus refresh and schema-version bump). -- Single-anomaly fixtures targeting the new export reason codes (specs - drafted; construction to follow). Includes one negative-control - fixture (`exp_forwarder_to_ordinal_valid`) demonstrating that the - validator correctly accepts the spec-valid `#Ordinal` forwarder - syntax without false positive. -- Resource fixtures targeting the new `errors` field paths - (`size_invalid`, `rva_invalid`, `data_out_of_bounds`, - `raw_offset_invalid`). Currently the corpus exercises only the - clean path; single-anomaly fixtures for each error tag would round - out coverage. -- `SUBLANG` table refinement. The current implementation models - sublang values as language-independent, which is incorrect for - multilingual edge cases (sublang `0x02` means UK English with - primary English, but Swiss German with primary German). A flat - LCID → BCP-47 mapping is the structural fix; deferred as a separate - ticket since the current behaviour is correct for the common case. +- **Single-anomaly fixtures** targeting the new reason codes across resources, version-info, exports, and delay-load (~25 fixtures planned). Includes negative-control fixtures (exp_forwarder_to_ordinal_valid, delay_well_formed_bound_modern, delay_well_formed_unbound_with_ordinal_import) demonstrating that validators do not false-positive on healthy spec-valid inputs. +- **Public IOC schema promotion** of version_info_struct planned for a future release with corpus refresh. Contains consumer-facing metadata (CompanyName, ProductVersion, OriginalFilename, FileDescription) with established IOC value. +- **Internal-only structural data.** export_struct, delay_import_struct, and Load Config metadata remain internal by design — they exist to feed validators and heuristics, not the public IOC schema. Consumers needing structural information about these directories should rely on the validators' reason codes and (for exports/delay-load) the existing consumer-facing metadata fields. +- **SUBLANG table refinement.** The current implementation models sublang values as language-independent, which is incorrect for multilingual edge cases (sublang 0x02 means UK English with primary English, but Swiss German with primary German). A flat LCID → BCP-47 mapping is the structural fix; deferred since current behaviour is correct for the common case. +- **TLS Directory parser and validator.** Originally deferred from this release; planned for the next release. +- **Cross-tool divergence study** using the new fixtures. Delay-load is a particularly strong divergence candidate because the three identified spec-interpretation questions are known to produce inconsistent output across pefile, LIEF, Ghidra, and IDA. Tracked separately as a methodology contribution opportunity. +- **Filed work items**: + - pefile_usage_policy.md documenting the deterministic-subset usage pattern. + - PE_CFG_DECLARATION_INCONSISTENT heuristic (DllCharacteristics ↔ Load Config GuardFlags cross-validator consistency). + - PE_FIELDS_IMPLAUSIBLE heuristic for "cannot exist in wild" binaries. + - PE_DLL_CHARACTERISTICS_INCONSISTENT heuristic for intra-field flag dependency violations (e.g., HIGH_ENTROPY_VA without DYNAMIC_BASE). + - Existing-field default migration (the 0 vs None inconsistency in OptionalHeaderInfo). --- diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 52e55cd..b082488 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -180,26 +180,26 @@ ### Export Directory Anomalies | Reason Code | What Triggers It | Example Pattern | Scope | |-------------|------------------|-----------------|-------| -| **EXPORT_DIRECTORY_INVALID_HEADER** | The 40‑byte IMAGE_EXPORT_DIRECTORY header could not be decoded, or its declared counts and array RVAs are mutually inconsistent (e.g., NumberOfFunctions > 0 but AddressOfFunctions == 0, or NumberOfNames > NumberOfFunctions) | NumberOfNames = 50, NumberOfFunctions = 20 | Per‑file -| **EXPORT_DIRECTORY_OUT_OF_BOUNDS** | The export directory's declared (rva, size) extends past SizeOfImage | Directory RVA = 0x1F0000, size = 0x1000, SizeOfImage = 0x1F0500 | Per‑file -| **EXPORT_TABLE_TRUNCATED** | One of the export sub‑tables (EAT, ENPT, EOT) declares more entries than the file physically contains, or pe.get_data failed to read the declared extent | NumberOfFunctions = 1000, EAT physical extent only covers 50 entries | Per‑file +| **EXPORT_DIRECTORY_INVALID_HEADER** | The 40‑byte IMAGE_EXPORT_DIRECTORY header could not be decoded, or its declared counts and array RVAs are mutually inconsistent (e.g., NumberOfFunctions > 0 but AddressOfFunctions == 0, or NumberOfNames > NumberOfFunctions) | NumberOfNames = 50, NumberOfFunctions = 20 | Per‑file | +| **EXPORT_DIRECTORY_OUT_OF_BOUNDS** | The export directory's declared (rva, size) extends past SizeOfImage | Directory RVA = 0x1F0000, size = 0x1000, SizeOfImage = 0x1F0500 | Per‑file | +| **EXPORT_TABLE_TRUNCATED** | One of the export sub‑tables (EAT, ENPT, EOT) declares more entries than the file physically contains, or pe.get_data failed to read the declared extent | NumberOfFunctions = 1000, EAT physical extent only covers 50 entries | Per‑file | ### Export Name Pointer Anomalies | Reason Code | What Triggers It | Example Pattern | Scope | |-------------|------------------|-----------------|-------| -| **EXPORT_NAME_RVA_INVALID** | A name pointer entry's RVA is zero, missing, or points to a string that could not be read or was unterminated within the maximum scan length | Name RVA = 0x0, or RVA points to bytes with no NUL terminator within 1024 bytes | Per‑entry -| **EXPORT_NAME_NOT_ASCII** | A name string decoded successfully but contains non‑printable bytes or characters outside the printable ASCII range (0x20–0x7E) | Name = "Foo\x01Bar", or name decoded with Unicode replacement characters | Per‑entry -| **EXPORT_NAME_POINTER_TABLE_UNSORTED** | The Export Name Pointer Table is not sorted lexicographically by name, violating the PE spec requirement that enables binary search by GetProcAddress | Names in order: ["Zeta", "Alpha", "Mu"] | Per‑file -| **EXPORT_NAME_ORDINAL_INDEX_INVALID** | An EOT entry is missing, or its value is greater than or equal to NumberOfFunctions (i.e., it points outside the EAT) | EOT entry = 500, NumberOfFunctions = 100 | Per‑entry +| **EXPORT_NAME_RVA_INVALID** | A name pointer entry's RVA is zero, missing, or points to a string that could not be read or was unterminated within the maximum scan length | Name RVA = 0x0, or RVA points to bytes with no NUL terminator within 1024 bytes | Per‑entry | +| **EXPORT_NAME_NOT_ASCII** | A name string decoded successfully but contains non‑printable bytes or characters outside the printable ASCII range (0x20–0x7E) | Name = "Foo\x01Bar", or name decoded with Unicode replacement characters | Per‑entry | +| **EXPORT_NAME_POINTER_TABLE_UNSORTED** | The Export Name Pointer Table is not sorted lexicographically by name, violating the PE spec requirement that enables binary search by GetProcAddress | Names in order: ["Zeta", "Alpha", "Mu"] | Per‑file | +| **EXPORT_NAME_ORDINAL_INDEX_INVALID** | An EOT entry is missing, or its value is greater than or equal to NumberOfFunctions (i.e., it points outside the EAT) | EOT entry = 500, NumberOfFunctions = 100 | Per‑entry | ### Export Function Entry Anomalies | Reason Code | What Triggers It | Example Pattern | Scope | |-------------|------------------|-----------------|-------| -| **EXPORT_ORDINAL_OUT_OF_RANGE** | The maximum computed ordinal (Base + NumberOfFunctions - 1) exceeds the 16‑bit range. Per PE spec, ordinals must fit in a WORD | Base = 0xFFF0, NumberOfFunctions = 32, max ordinal = 0x1000F | Per‑file -| **EXPORT_FUNCTION_RVA_INVALID** | A function entry's address RVA is non‑zero, is not a forwarder, and points outside the PE image (>= SizeOfImage) | Address RVA = 0x2000000, SizeOfImage = 0x400000 | Per‑entry -| **EXPORT_FORWARDER_MALFORMED** | A function entry's RVA points within the export directory (indicating a forwarder) but the resulting string is unreadable, contains non‑printable bytes, or does not match the spec format DllName.SymbolName or DllName.#Ordinal | Forwarder string = "KERNEL32\x01LoadLibraryA", or "InvalidForwarderNoDot" | Per‑entry +| **EXPORT_ORDINAL_OUT_OF_RANGE** | The maximum computed ordinal (Base + NumberOfFunctions - 1) exceeds the 16‑bit range. Per PE spec, ordinals must fit in a WORD | Base = 0xFFF0, NumberOfFunctions = 32, max ordinal = 0x1000F | Per‑file | +| **EXPORT_FUNCTION_RVA_INVALID** | A function entry's address RVA is non‑zero, is not a forwarder, and points outside the PE image (>= SizeOfImage) | Address RVA = 0x2000000, SizeOfImage = 0x400000 | Per‑entry | +| **EXPORT_FORWARDER_MALFORMED** | A function entry's RVA points within the export directory (indicating a forwarder) but the resulting string is unreadable, contains non‑printable bytes, or does not match the spec format DllName.SymbolName or DllName.#Ordinal | Forwarder string = "KERNEL32\x01LoadLibraryA", or "InvalidForwarderNoDot" | Per‑entry | ## EXPORT SUB‑REASONS @@ -219,7 +219,8 @@ Several export reason codes carry a reason field in their details payload that n The table field (not reason) identifies the affected sub‑table: -| table value | Meaning +| table value | Meaning | +|-------------|---------| | export_directory_header | The 40‑byte header itself was short | | eat_truncated, enpt_truncated, eot_truncated | A sub‑table's declared size exceeded available file bytes | | eat_read_failed, enpt_read_failed, eot_read_failed | pe.get_data raised when reading the sub‑table | @@ -241,33 +242,137 @@ Priority‑resolved; the first matching tag wins: Priority‑resolved: | Sub‑reason | Meaning | +|------------|---------| | non_ascii | Decode produced Unicode replacement characters | | name_not_printable_ascii | Decoded successfully but contains bytes outside 0x20–0x7E | ### EXPORT_NAME_ORDINAL_INDEX_INVALID | Sub‑reason | Meaning | +|------------|---------| | missing | Parser could not read the EOT entry | | out_of_range | Ordinal index >= NumberOfFunctions | ### EXPORT_ORDINAL_OUT_OF_RANGE | Sub‑reason | Meaning | +|------------|---------| | max_exceeds_u16 | Base + NumberOfFunctions − 1 > 0xFFFF | ### EXPORT_FORWARDER_MALFORMED | Sub‑reason | Meaning | +|------------|---------| | unreadable | RVA points into export directory but string could not be decoded | | format | Decoded fine but does not match DllName.SymbolName or DllName.#Ordinal | ### EXPORT_FUNCTION_RVA_INVALID | Sub‑reason | Meaning | +|------------|---------| | exceeds_image | Address RVA >= SizeOfImage | --- +## DELAY‑LOAD IMPORT ANOMALIES + +### Delay‑Load Directory Anomalies + +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **DELAY_IMPORT_DIRECTORY_INVALID_HEADER** | The delay‑load directory's parser could not complete top‑level decoding (e.g., the directory placement could not be read, or initial structures were unrecoverable) | Directory at RVA that pefile cannot resolve to a section | Per‑file | +| **DELAY_IMPORT_DIRECTORY_OUT_OF_BOUNDS** | The delay‑load directory's declared (rva, size) extends past SizeOfImage | Directory RVA = 0xFF000, size = 0x4000, SizeOfImage = 0x100000 | Per‑file | +| **DELAY_IMPORT_TABLE_TRUNCATED** | One of the delay‑load sub‑tables (descriptor array, INT, IAT, bound IAT, unload IAT) declares more entries than the file physically contains, or pe.get_data failed to read the declared extent, or no zero‑descriptor terminator was found before reaching the directory's declared end | Descriptor count of 50 but only 12 fit in declared size; or INT walk hits max imports without NULL thunk | Per‑file | + +### Delay‑Load Descriptor Anomalies + +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **DELAY_IMPORT_DESCRIPTOR_INVALID** | A per‑descriptor structural error: the INT or IAT RVA is zero despite the descriptor being non‑terminating, the sub‑table read failed, or the sub‑table was unparseable. Affects one descriptor at a time | Descriptor for "gdiplus.dll" has int_rva = 0 | Per‑descriptor | +| **DELAY_IMPORT_DLL_NAME_INVALID** | A descriptor's DLL name RVA points to a string that is zero, unreadable, unterminated within the maximum scan length, or contains non‑printable bytes | Name RVA = 0x0, or name = "kernel32\x01dll" | Per‑descriptor | +| **DELAY_IMPORT_INT_IAT_MISMATCH** | A descriptor's Import Name Table and Import Address Table have different lengths. Per spec, they must be parallel arrays of identical length terminated by a NULL thunk | INT has 14 entries, IAT has 12 entries — strong malformation signal | Per‑descriptor | +| **DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE** | A descriptor's Attributes field has the low bit clear, indicating v0 (pre‑Windows 2000) mode where table fields are raw VAs rather than RVAs. Vanishingly rare in modern binaries | Attributes = 0x00000000 instead of 0x00000001 | Per‑descriptor | + +### Delay‑Load Entry Anomalies + +| Reason Code | What Triggers It | Example Pattern | Scope | +|-------------|------------------|-----------------|-------| +| **DELAY_IMPORT_ENTRY_INVALID** | A per‑import entry has structural malformation: the INT thunk is missing or zero, an ordinal value is zero (invalid), or the IMAGE_IMPORT_BY_NAME structure is malformed (unreadable, too short, unterminated, or non‑printable name) | INT entry with high bit set but ordinal = 0; or hint+name structure with NUL‑less buffer | Per‑entry | + +## DELAY‑LOAD IMPORT SUB‑REASONS + +Most delay‑load reason codes carry a reason field in their details payload that narrows the pathology. The full taxonomy: + +### DELAY_IMPORT_DIRECTORY_INVALID_HEADER + +| Sub‑reason | Meaning | +|------------|---------| +| top_level_decode | The parser could not complete top‑level decoding of the delay‑load directory | + +### DELAY_IMPORT_TABLE_TRUNCATED + +The table field (not reason) identifies the affected sub‑table: + +| table value | Meaning | +|-------------|---------| +| delay_import_descriptor_truncated | A descriptor's 32‑byte structure was short | +| delay_import_descriptor_read_failed | pe.get_data raised when reading a descriptor | +| delay_import_descriptor_unterminated | No zero descriptor found before the directory's declared end | +| delay_import_descriptor_max_exceeded | Hit the hard descriptor limit (4096) without finding a NULL terminator | +| int_truncated, iat_truncated | A sub‑table's declared extent exceeded available file bytes | +| int_read_failed, iat_read_failed | pe.get_data raised when reading a thunk | +| int_max_exceeded, iat_max_exceeded | Hit the imports‑per‑descriptor limit (16384) without finding a NULL terminator | +| int_unpack_failed, iat_unpack_failed | struct.unpack failed on a thunk value | + +### DELAY_IMPORT_DESCRIPTOR_INVALID + +Carries both table and reason in details: + +| table reason (priority order) | Meaning | +|-------------------------------|---------| +| int int_rva_zero | INT RVA is zero despite the descriptor being non‑terminating | +| int int_truncated, int_read_failed, int_max_exceeded, int_unpack_failed | Sub‑table read or parse failure (also surfaces via DELAY_IMPORT_TABLE_TRUNCATED) | +| iat iat_rva_zero | IAT RVA is zero | +| iat iat_truncated, iat_read_failed, iat_max_exceeded, iat_unpack_failed | Same as INT | + +### DELAY_IMPORT_DLL_NAME_INVALID + +Priority‑resolved; the first matching tag wins: + +| Sub‑reason | Meaning | +|------------|---------| +| dll_name_rva_zero | DLL name RVA was explicitly zero | +| read_failed | pe.get_data raised when reading the DLL name string | +| unterminated | No NUL terminator found within the maximum scan length (512 bytes) | +| dll_name_not_printable | Decoded successfully but contains bytes outside 0x20–0x7E | +| non_ascii | Decode produced Unicode replacement characters | + +### DELAY_IMPORT_INT_IAT_MISMATCH + +No sub‑reasons; the code itself names the pathology. Cross‑table length disagreement. + +### DELAY_IMPORT_ATTRIBUTES_LEGACY_VA_MODE + +No sub‑reasons. Fires when the Attributes field's low bit is zero. + +### DELAY_IMPORT_ENTRY_INVALID + +Priority‑resolved: + +| Sub‑reason | Meaning | +|------------|---------| +| int_entry_missing | Parser could not read this entry's INT thunk | +| int_entry_zero | INT entry is zero at an unexpected position (terminator before INT length matches IAT) | +| ordinal_zero | High bit set on INT entry but ordinal value is zero | +| name_read_failed | pe.get_data raised when reading the IMAGE_IMPORT_BY_NAME structure | +| name_too_short | IMAGE_IMPORT_BY_NAME buffer was less than 3 bytes | +| hint_unpack_failed | Could not unpack the WORD hint | +| name_unterminated | Name string had no NUL terminator within the maximum scan length | +| name_non_ascii | Name decode produced Unicode replacement characters | +| name_not_printable | Name decoded successfully but contains non‑printable bytes | + +--- + ## **PACKER HEURISTICS (Interpretation Layer)** | Reason Code | What Triggers It | Example Pattern | Scope | diff --git a/docs/specs/structural-validation-deterministic-heuristics.md b/docs/specs/structural-validation-deterministic-heuristics.md index 0b1757f..2fa68cd 100644 --- a/docs/specs/structural-validation-deterministic-heuristics.md +++ b/docs/specs/structural-validation-deterministic-heuristics.md @@ -33,6 +33,8 @@ This is **structural verification**. Each validator inspects a distinct subsystem of the PE format. Together, they form a complete, deterministic structural model of the binary. +Some structural metadata extracted by parsers is **producer-facing**: it exists to enable validators and heuristics, not to be exposed via the public IOC schema. Examples include the export and delay-load structural details. Other structural metadata is **consumer-facing** and intended for public exposure: version-info string fields are an example of this, planned for promotion in a future release. + --- # **2.1 Entropy Validator** @@ -252,6 +254,43 @@ This ensures that for any given malformed export table, the validator produces t --- +## 2.12 Delay-Load Imports Validator + +### Validates the structural integrity of the PE delay-load import directory and its descriptor array. + +This validator performs: + +- Top-level decode failure detection and short-circuit for unrecoverable directory placement. +- Delay-load directory placement within `SizeOfImage`. +- Truncation reporting across the descriptor array and per-descriptor INT and IAT sub-tables. +- Per-descriptor structural validation: zero-RVA sub-tables, sub-table read failures, length-budget exhaustion. +- DLL name string validation: RVA presence, readability, NUL termination, printable ASCII compliance. +- INT/IAT parallel-array length consistency check (the strongest single signal of malformation). +- v0 (legacy VA-mode) attribute detection for pre-Windows 2000 binaries. +- Per-entry validation of INT thunks and IMAGE_IMPORT_BY_NAME structures, including ordinal validity, hint readability, and name structural correctness. + +Absence of a delay-load directory is not treated as a structural defect — most binaries do not use delay-loading. Bound state (`bound_iat_rva != 0`) is captured by the parser but not flagged as anomalous; bound delay-load is the normal pattern for Microsoft-shipped binaries. + +The delay-load directory is one of the most divergence-prone surfaces in the PE format. Three properties make general-purpose delay-load parsers prone to inconsistent output: + +The directory is a chain of variable-content structures whose interpretation depends on a single bit in the Attributes field — v0 binaries (Attributes bit 0 clear) use raw virtual addresses requiring ImageBase subtraction, while v1 binaries use RVAs directly. Many parsers do not implement v0 support and silently coerce the values, producing output that differs across tool versions and across binaries depending on which mode is detected. + +The INT and IAT are parallel arrays whose elements must agree on length, but whose interpretations diverge: an INT entry is a thunk describing an import (ordinal or hint+name pointer); an IAT entry is initially a mirror of the INT thunk and later becomes a runtime-resolved address. Bound binaries have the IAT pre-populated with bound addresses, breaking the mirror property. Parsers that assume the mirror property unconditionally produce wrong results on bound binaries; parsers that assume the bound property unconditionally produce wrong results on unbound binaries. + +The descriptor array is terminated by a zero-filled IMAGE_DELAY_IMPORT_DESCRIPTOR rather than by a count field. Parsers that trust the directory's declared size and parsers that walk until terminator both work on well-formed binaries but disagree on truncated ones — the former stops at declared end and reports a clean truncation; the latter reads past declared end if a terminator is absent, producing data that the former considers out-of-bounds. + +The delay-load parser is implemented as a pure `struct`-level decoder over `pe.get_data`-acquired byte buffers: + +- The 32-byte IMAGE_DELAY_IMPORT_DESCRIPTOR structure is unpacked via a single `struct.unpack_from` call. No reliance on pefile's `DIRECTORY_ENTRY_DELAY_IMPORT` interpretation. +- The Attributes v1 flag is captured from the raw value; v0 binaries are reported via a dedicated reason code rather than silently coerced. The PE32+ vs PE32 distinction is captured from `OPTIONAL_HEADER.Magic` once at parse-start and used for the entire walk to determine thunk size (DWORD vs QWORD). +- The descriptor array walk has both an explicit zero-descriptor terminator check and a hard count limit (4096), with distinct truncation tags for each termination cause. +- INT and IAT thunk arrays are walked with the same dual-bounded strategy: zero terminator detection plus hard limit (16384 per descriptor). Each thunk's struct.unpack failure is reported deterministically. +- Bound state is detected by `bound_iat_rva != 0` (a single byte-level field comparison), not by inference from IAT value patterns. The detection is bit-exact across runs. +- INT/IAT length mismatch is detected by a single integer comparison after both arrays are walked. The validator emits a dedicated reason code rather than letting the inconsistency propagate as per-entry errors. +- The IMAGE_IMPORT_BY_NAME structure is read with a bounded scan (1024 bytes) and validated against printable ASCII rules. Substructure failures emit deterministic tombstone tags in per-entry `errors` lists. + +--- + # **3. Deterministic Heuristics Layer** ### *Heuristics interpret structural truth — they never override it.* From 19a111a8e98f8ff774fda803abaa30f10164daa4 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 1 Jul 2026 12:07:47 +0100 Subject: [PATCH 35/35] Final release documentation for v0.7.5 --- CHANGELOG.md | 5 ++++- README-pypi.md | 18 ++++++++---------- README.md | 11 ++++++++++- pyproject.toml | 2 +- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c17c5f..ee01e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -# v0.7.5 — Unreleased +# **v0.7.5 - Structural validator expansion** +**Released: 2026‑07‑01** This release substantially expands IOCX's structural validator suite with four new parser/validator pairs (export tables, delay-load imports, VS_VERSIONINFO, and the resource directory's Type → Name → Language hierarchy), enriches public metadata with security-relevant Optional Header and per-resource fields, and adds 24 new structural reason codes across exports, resources, and delay-load. All new code lands with 100% line and branch coverage backed by real-binary verification. @@ -120,6 +121,7 @@ export_struct and delay_import_struct are exposed only in internal metadata by d --- # **v0.7.4.1 — Windows‑Compatible PE Detection Hotfix** +**Released: 2026‑05‑28** IOCX v0.7.4.1 removes the `python-magic` dependency, improves PE detection accuracy, and reduces IOCX’s attack surface. @@ -140,6 +142,7 @@ IOCX v0.7.4.1 removes the `python-magic` dependency, improves PE detection accur --- # **v0.7.4 — Advanced Directory Parsing & Metadata Expansion** +**Released: 2026‑05‑26** IOCX v0.7.4 significantly expands static PE coverage with advanced directory parsing, extended metadata extraction, and deterministic structural validation. This release improves correctness across modern compiler outputs while preserving IOCX’s static‑only, zero execution design. diff --git a/README-pypi.md b/README-pypi.md index 15fb53b..dac56be 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -40,21 +40,19 @@ If you need predictable, automatable IOC extraction — IOCX is built for you. --- +## Version highlights (v0.7.5) + +- Added detection for malformed exports, delay-load tables, resources, VS_VERSIONINFO, and Optional Header fields via 24 structural reason codes +- Surfaces security metadata — DLL characteristics flags, subsystem/machine decoding, per-resource Shannon entropy +- Never crashes on malformed input — byte-level parsing with structured error tombstones +- 1370 tests at 100% coverage — deterministic output, snapshot-stable, cross-verified against `dumpbin` + ## Version highlights (v0.7.4.1) - Removed the `python-magic` dependency, which caused import failures on Windows systems - Added a pure‑Python file‑type detector for full cross‑platform portability - No behavioural changes to IOC extraction -- The `--min-length` consistency fix is planned for **v0.7.5** - -## Version highlights (v0.7.4) - -- Full **Load Config Directory** parsing and validation -- Extended Optional Header metadata for downstream heuristics -- Structural anomaly heuristics (GuardCF, unmapped cookie, SEH issues) -- Faster, more resilient PE Analysis -- Raw IOC extraction remains world-class -- Zero regressions across all workloads +- The `--min-length` consistency fix is planned for **v0.7.6** --- diff --git a/README.md b/README.md index fdf80dc..82e73f6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + @@ -202,6 +202,15 @@ Fast path — no PE parsing.

Show Version History
+### **v0.7.5 — Structural Validator Expansion** +- Four new PE structural validators — exports, delay-load imports, VS_VERSIONINFO, and resource hierarchy +- 24 new reason codes with priority-resolved sub-reason taxonomies +- Deterministic byte-level parsing — no reliance on pefile's lazy interpretation +- Security-relevant metadata — DLL characteristics, subsystem/machine name decoding, per-resource entropy +- 1370 tests at 100% coverage — end-to-end verified against `dumpbin` on real binaries + +--- + ### **v0.7.4.1 — Windows Compatibility Hotfix** - Removed the `python-magic` dependency, which caused import failures on Windows systems - Added a pure‑Python file‑type detector for full cross‑platform portability diff --git a/pyproject.toml b/pyproject.toml index 78395ac..11e5217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "iocx" -version = "0.7.4.1" +version = "0.7.5" description = "A deterministic, high‑performance static‑analysis engine that extracts high‑signal IOCs from PE binaries, text, and logs — built for SOC automation and modern threat‑analysis pipelines." authors = [ { name = "MalX Labs" }