diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 12a47c3896..cd7c62d86b 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -251,6 +251,14 @@ explicit path or endpoint and do not need it. absence of that table, not proof the image is unsigned in every catalog sense. Signatures are not verified. See the [PE certificate guide](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md). +- PE import and delay-load tables: `inspect_pe_imports` with the explicit + local PE32/PE32+ file. This built-in target-free inspection requires no + Windows SDK. Preserve DLL names, named and ordinal functions, ILT/IAT RVAs, + delay-load descriptors, and the pefile import hash of the standard import + table. A missing import directory is observed absence of that table, not + proof the image never imported APIs through another table. Bound snapshots + and loaded IAT contents remain unobserved. See the + [PE import guide](https://github.com/morluto/rea/blob/main/docs/pe-imports.md). - ELF note tables: `inspect_elf_notes` with the explicit local ELF32/ELF64 file. This built-in target-free inspection requires no disassembler. Preserve PT_NOTE and SHT_NOTE ranges, owners, types, descriptor SHA-256, and GNU diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index fdf876fa20..5a45394b2b 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -112,6 +112,19 @@ not proof the image is unsigned in every catalog or overlay sense. Signatures, chains, timestamps, and Authenticode digest coverage remain unobserved. See [PE Authenticode certificate tables](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md). +## PE import and delay-load tables + +Use `inspect_pe_imports` for an explicit local PE32/PE32+ image when the +question concerns imported DLL and function names, ordinals, delay-load +descriptors, or the pefile import hash. No `open_binary`, Windows SDK, or +target execution is required. The CLI equivalent is `inspect-pe-imports PATH`. +Preserve artifact SHA-256, directory file ranges, original DLL spellings, and +ILT/IAT RVAs. A missing `IMAGE_DIRECTORY_ENTRY_IMPORT` directory is observed +absence of that table, not proof the image never imported APIs through another +table or overlay. Bound snapshots, loaded IAT contents, and export-forward +resolution remain unobserved. See +[PE import and delay-load tables](https://github.com/morluto/rea/blob/main/docs/pe-imports.md). + ## ELF note tables Use `inspect_elf_notes` for an explicit local ELF32/ELF64 image when the diff --git a/README.md b/README.md index 13ccf9f28e..5afad7d976 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ Additional tools and host support depend on the target: | Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required | | Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](docs/pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](docs/elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, imports, lambdas and the Code header | Built-in static BEAM inspection; [Erlang BEAM](docs/erlang-beam.md) | | WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e46e10acb3..9ae116bdbb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -16,6 +16,7 @@ notices supplied with the software you actually obtain or redistribute. | Material | Use and licensing records | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `package-lock.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. | +| pefile ordinal tables | Import-hash ordinal names are derived from [pefile 2024.8.26](https://github.com/erocarrera/pefile/tree/4b3b1e2e568a88d4f1897d694d684f23d9e270c4), copyright 2004–2024 Ero Carrera. Preserve the [MIT license](licenses/pefile-2024.8.26-LICENSE.txt). The Python package is not a runtime dependency. | | NativeAOT parser | REA's derived parser uses the MIT-licensed [ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot/tree/effeb734fc570c32650f88b159608979dc7b423e) work of Washi and the NativeAOT layout definitions in [dotnet/runtime](https://github.com/dotnet/runtime/tree/a3b2d40328be0be3f8dd43f0e5cc925b8827b044). Preserve [Washi's MIT notice](licenses/nativeaot-parser-MIT.txt) and the [.NET Foundation MIT notice](licenses/dotnet-runtime-MIT.txt). | | EVM analysis | `evmole@0.9.3`, MIT, copyright 2023 Maxim Andreev. The published npm archive omits its license document; REA retains the [original upstream license](third_party/evmole/LICENSE) and [provenance](third_party/evmole/README.md). It is also included in the runtime notices. | | Windows native addon | The release addon is compiled with MinGW-w64 and statically links GCC runtime libraries. Preserve the [MinGW-w64 runtime notices](licenses/MinGW-w64-runtime.txt), [GPLv3 text](licenses/GPL-3.0.txt), and [GCC Runtime Library Exception 3.1](licenses/GCC-Runtime-Library-Exception-3.1.txt). The exception governs eligible compiled combinations; it does not relicense the runtime itself under MIT. The Node-API build headers are from Node 22.19.0; its [original license and component notices](licenses/Node-22.19.0-LICENSE.txt) are retained. Node itself is supplied by the user. Snapshot provenance and release-toolchain review requirements are in [licenses/README.md](licenses/README.md). | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 253bf6ff80..7120eab1fe 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -33,6 +33,7 @@ export default defineConfig({ { text: "Model artifacts", link: "/model-artifacts" }, { text: "Rust dependencies", link: "/rust-binaries" }, { text: "PE certificates", link: "/pe-certificates" }, + { text: "PE imports", link: "/pe-imports" }, { text: "ELF notes", link: "/elf-notes" }, { text: "CPython pyc files", link: "/python-pyc" }, { text: "Erlang BEAM", link: "/erlang-beam" }, diff --git a/docs/cli.md b/docs/cli.md index deed003531..20b8ac866b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -290,6 +290,17 @@ embedded X.509 serial, issuer, subject, and validity fields. A missing security directory is observed absence of that table. See [PE Authenticode certificate tables](pe-certificates.md). +Inspect PE import and delay-load tables without executing the image: + +```bash +rea inspect-pe-imports /absolute/path/to/application.exe --json +``` + +The workflow reports DLL names, named and ordinal functions, delay-load +descriptors, and a pefile-compatible import hash of the standard import table. +A missing import directory is observed absence of that table. See +[PE import and delay-load tables](pe-imports.md). + Inspect ELF PT_NOTE and SHT_NOTE tables without executing the image: ```bash diff --git a/docs/index.md b/docs/index.md index 0230b11d09..1a83f408fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Task | Guides | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [ELF notes](elf-notes.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [ELF notes](elf-notes.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | | Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | diff --git a/docs/pe-imports.md b/docs/pe-imports.md new file mode 100644 index 0000000000..628ef07100 --- /dev/null +++ b/docs/pe-imports.md @@ -0,0 +1,73 @@ +# PE import and delay-load tables + +`inspect_pe_imports` / `inspect-pe-imports` answers whether one selected local +PE32/PE32+ image declares an `IMAGE_DIRECTORY_ENTRY_IMPORT` table, which DLLs +and functions that table names, which delay-load descriptors are present, and +the pefile-compatible import hash of the standard import table. It reads the +file without executing it and does not require a Windows SDK or disassembler. + +```bash +rea inspect-pe-imports /absolute/path/to/application.exe --json +``` + +The CLI resolves relative paths against the caller's working directory. MCP +and the advertised input schema require an absolute filesystem path and +preserve that exact selection: + +```json +{ + "name": "inspect_pe_imports", + "arguments": { "path": "/artifacts/application.exe" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, and byte length. +`import_directory` and `delay_import_directory` report the data directory RVA +and size, or `null` when a directory is absent or zero. Their `locations` are +the file spans actually read for the descriptor array and its terminator. When +consecutive descriptors map to discontiguous file bytes (for example across a +section boundary), each span is reported separately, and each becomes its own +Evidence file-offset range. +If the first descriptor cannot be examined, the directory declaration remains +present with no consumed `locations`, and coverage is partial. Descriptors, +thunks and NUL-terminated names stay within PE's 32-bit RVA space, including +PE32+ images; earlier valid rows survive an overflowing descriptor walk. + +Each `imports[]` item reports the DLL name, descriptor RVAs, and named or +ordinal functions with ILT/IAT RVAs. `delay_imports[]` reports RVA-based +delay-load descriptors separately. `imphash` is pefile's `get_imphash()` over +the standard import table only; delay-load rows do not participate. Ordinal +imports use pefile's ordinal name tables and otherwise its `ord` +fallback. + +`imphash.md5` is `null` with a `reason` whenever the value would not be +pefile's hash of this image: the standard import table is partial, or pefile +would read a different row sequence (names it treats as invalid or truncates, +ordinal 0, descriptors without functions, or tables its bounds and import +symbol cap truncate). Delay-load issues do not withhold the hash. + +A missing import directory is observed absence of that table. It is not proof +the image never imported APIs through another table or overlay. + +## Limits and failures + +The selected file is limited to 64 MiB by default (512 MiB maximum). At most +256 import and delay-load descriptors are examined by default (4096 maximum), +and at most 8192 functions across every table (262144 maximum). Malformed PE +headers and exhausted DLL or function budgets fail without partial success. +Decoded DLL and function names are limited to a fixed 16 MiB of retained text +with no runtime override; `max_file_bytes`, `max_dlls` and `max_functions` do +not raise it. Resource failures report the exhausted allowance as `limit` +(`dlls`, `functions` or `retained_text`) with matching recovery. + +Unreadable DLL names, hint/name entries, or thunks are reported as partial +coverage with `issues`. A descriptor whose required table RVA is zero (a +standard `FirstThunk`, or a delay-load import address or name table) is +reported as `malformed_entry` with no functions, never as a complete empty +inventory. Delay-load descriptors that store virtual addresses rather than RVAs +are recorded as `unsupported_form` and are not decoded. + +This workflow does not resolve bound imports, loaded IAT contents, or +export-forward names in another DLL. diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 0b686fa96c..164e69b363 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -154,6 +154,7 @@ npx rea-agents@latest setup | الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](../native-investigation.md) | | بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 13f510f5cf..f3f0f40b52 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -154,6 +154,7 @@ REA benötigt Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) oder 26+ (>=26.12.1) so | Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](../native-investigation.md) | | Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 5fa717d055..fe21e44cfb 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -156,6 +156,7 @@ npx rea-agents@latest setup | Δυαδικά αρχεία Go | Ενσωματωμένη έκδοση μεταγλωττιστή, modules, αντικαταστάσεις και ρυθμίσεις μεταγλώττισης | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· δεν απαιτείται εγκατάσταση του Go | | Δυαδικά αρχεία Rust | Ενσωματωμένα ονόματα, εκδόσεις, πηγές και σχέσεις εξάρτησης των crates από το cargo-auditable | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· [δυαδικά αρχεία Rust](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Αρχείο WASM | Έλεγχο εγκυρότητας, ενότητες, ακριβείς μορφές εισαγωγών/εξαγωγών σε WAT και αποκωδικοποιημένο WAT | WABT 1.0.42 που παρέχει ο χρήστης· `inspect_wasm_artifact` | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 9e56d60ac2..dd4b489ba2 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -154,6 +154,7 @@ REA requiere Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), adem | Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](../native-investigation.md) | | Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index 51c3a11184..2b830fccbc 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -146,6 +146,7 @@ REA به Node.js 22.x (>=22.23.4)، 24.x (>=24.22.1) یا 26+ (>=26.12.1)، به | باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](../native-investigation.md) | | ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index a5d7d46a1b..d33c322cd9 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -153,6 +153,7 @@ REA nécessite Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), ai | Binaires natifs | Pseudo-code, assembleur, chaînes, symboles, appels et références | Hopper, Ghidra ou IDA ; [analyse native](../native-investigation.md) | | Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` | | Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index 808a5ae616..8715456c94 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -156,6 +156,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](../native-investigation.md) | | Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` | | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index cde6e8d7f1..ed3e7a201a 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -153,6 +153,7 @@ REA richiede Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), oltre | Binari nativi | Pseudocodice, assembly, stringhe, simboli, chiamate e riferimenti | Hopper, Ghidra o IDA; [analisi nativa](../native-investigation.md) | | Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` | | Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index 1d31db25b9..f37aa8ae52 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -154,6 +154,7 @@ REA には Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)、または 26+ (>= | ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](../native-investigation.md) | | オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 638fc6add9..8af51c5f15 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -156,6 +156,7 @@ REA에는 Node.js 22.x(>=22.23.4), 24.x(>=24.22.1) 또는 26+ (>=26.12.1)와 npm | Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 | | Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` | diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index 8abd927804..11fbe51174 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -156,6 +156,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.23.4), 24.x (>=24.22.1), അല്ല | Go binaries | ഉൾച്ചേർത്ത compiler version, modules, replacements, build settings | Built-in static ELF/PE/thin Mach-O പരിശോധന; Go installation ആവശ്യമില്ല | | Rust binaries | ഉൾച്ചേർത്ത cargo-auditable crate പേരുകൾ, versions, sources, dependency edges | Built-in static ELF/PE/thin Mach-O പരിശോധന; [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index f0de869c65..2b509cfeee 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -154,6 +154,7 @@ REA wymaga Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) lub 26+ (>=26.12.1) oraz n | Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](../native-investigation.md) | | Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index f1b1a6847d..26bf804f94 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -154,6 +154,7 @@ O REA requer Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), alé | Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](../native-investigation.md) | | Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index a84f158a91..90f210a068 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -156,6 +156,7 @@ REA требует Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) или 26+ (>=26.1 | Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index e3b12949aa..02b428cb1a 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -154,6 +154,7 @@ REA ต้องใช้ Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) หรื | ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](../native-investigation.md) | | โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 8a8a447bf5..c280e828e2 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -154,6 +154,7 @@ REA; Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) veya 26+ (>=26.12.1) ve npm gere | Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez | | Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 0703767054..7907ed37ec 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -154,6 +154,7 @@ REA потребує Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) або 26+ (>=26 | Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](../native-investigation.md) | | Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index 00573989b8..0dc7604067 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -196,6 +196,7 @@ Mazeed tools aur host support target par depend karte hain: | Godot PCK | Archive inventory aur extraction, original header/directory facts aur selected content identities | Built-in static formats 2, 3 aur 4; Godot zaroori nahi | | Rust binaries | Embedded cargo-auditable crate names, versions, sources aur dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries aur PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import aur delay-load DLL names, ordinals, IAT RVAs aur pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id aur ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, imports, lambdas aur Code header | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artifact | Validation, sections, exact import/export WAT forms aur decoded WAT | Aapka diya hua WABT 1.0.42; `inspect_wasm_artifact` | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index 283e3db3da..66f357e7ac 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -156,6 +156,7 @@ REA yêu cầu Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) hoặc 26+ (>=26.12.1) | Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 2b3984e399..ca9000bfda 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -157,6 +157,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | | 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 6209778852..39f16fb696 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -157,6 +157,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | +| PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | | 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) | diff --git a/docs/verification/pe-imports-2026-10-24.md b/docs/verification/pe-imports-2026-10-24.md new file mode 100644 index 0000000000..439a3f038a --- /dev/null +++ b/docs/verification/pe-imports-2026-10-24.md @@ -0,0 +1,255 @@ +# PE import inventory verification — 2026-10-24 + +The analyst task is to list a local PE's standard and delay imports, keep source +locations and partial facts, and obtain a pefile-compatible imphash only when +the retained standard import sequence supports it. CLI and MCP use the same +application service and static parser. Targets are read without execution. + +## Revisions and ownership + +- Original PR: `0b65cbcfe2bf78a808d41e12fa5aee9c0fc93da4`. +- Main integration and mapping index: `336f83cbb08dcc02a2d4c932f9c82f2c7b83554e`. +- Measured baseline: `073e2612e783dc38d3603cd7dcc9dc721c0d9412`, the preceding + integration with provider identity imports and artifact-failure classification + repaired so the runtime builds. The measured candidate uses the same provider. +- Measured candidate: `105f4ea66ebc82e559fe1000aabe75ced3024a41`. + +`readPeImage` owns machine, image base, directories, alignments and declared raw +section pointers. The import parser consumes those facts rather than reading the +PE header again or substituting a missing base with zero. The shared mapping +index preserves canonical ambiguous/unmapped failures and adjacent mappings. +Its construction is O(n log n), with O(log n) nonempty range queries; both normal +and failed lookups avoid a linear fallback. Indexed and canonical CString reads +share one implementation and can reconstruct strings across physical sections. +These structures belong to one parse, with no cross-request cache or invalidation. + +Derived ILT/IAT slots are checked against the 32-bit RVA space before a row is +returned. A malformed table preserves preceding valid rows and reports partial +coverage instead of failing the output schema. Named thunks are checked before +conversion from bigint, retaining the exact source hex when reserved high bits +are set. The format's named hint/name pointer uses 31 bits; see the +[Microsoft PE format specification](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#import-lookup-table). + +Early cancellation preserves the caller's reason before input validation in +PE imports and the two adjacent certificate/ELF-note services. Artifact failure +classification consumes the shared owner's origin facts and regular-file error +predicate; cleanup ownership and retry remain with `ArtifactResourceScope`. + +## Independent oracle and regressions + +The oracle is pefile **2024.8.26**, installed without dependencies into the +temporary verification directory. Its `pefile.py` SHA-256 is +`0d0eb68f0f169182613dc64b3ab50b20855508c1c1d2faafe8fccf23edb6a345`. +The release's upstream commit is +[`4b3b1e2e568a88d4f1897d694d684f23d9e270c4`](https://github.com/erocarrera/pefile/tree/4b3b1e2e568a88d4f1897d694d684f23d9e270c4). +All **696** copied ordinal names match its three complete maps: 425 oleaut32, +196 ws2_32 and 75 wsock32. Own-key lookup and `ord` fallback are retained. +The upstream MIT license is preserved in `licenses/` and the notices inventory. + +Independent byte fixtures establish these differences: + +- A cross-section function name is `Function128` in native mappings; pefile + reads `Functi`. A cross-section DLL name is `KERNEL32.dll` natively and + `KERNEL32.d` in pefile. Complete native facts remain useful, while hashing them + would produce a different valid-looking MD5. +- pefile stops after 2,048 section headers. With imports in section 2,049, native + parsing retains both functions while pefile finds no import table. +- pefile also stops at a null section header or a section with at least three + of its structural warnings. Native parsing can still see a later import table. + +The candidate checks byte-mapping compatibility from the parsed owner facts, +including pointer/address normalization, section clipping and those stop rules. +Unproven geometry withholds only the hash, with an explicit reason. Delay-only +issues do not invalidate the standard table's hash. This is conservative +compatibility checking, not universal qualification of every unusual PE layout. + +PE32/PE32+ IAT overflow and exact wide-thunk regressions fail on the old producer. +The three section-stop cases fail on the precursor before its stop guard was +added; the independent oracle sees no imports in each. All pass after correction. +Each of the three early-cancellation services fails its null-reason control +before the fix; afterward both null and caller-object identity are preserved. +The existing retained-text failure already propagates through its tagged error +and is not claimed as a newly fixed defect. + +Executed checks at the candidate stages: + +- **58** parser/mapping/image/cancellation checks pass. Randomized mapping + comparisons use the canonical mapper as an independent semantic oracle. +- **172** selected domain/service/filesystem/CLI/MCP/artifact-lifecycle checks + pass before the final section-stop guard; sets overlap and are not added. +- The final parser file passes after the stress fixture's header/section ranges + are made disjoint. **5** public CLI cases then pass, including both IAT widths, + a complete cross-section name, section-cap and null-section hash refusals. +- Full static gates pass after provider integration and after the compatibility + guard. The initial missing artifact-failure API compile error was corrected. + +The public CLI checks parse actual responses with the named output contract. +MCP tests exercise the real SDK and advertised schema. The artifact-consumer +suite covers physical read errors, cancellation, cleanup failure and retry with +the PE provider included. No deep native engine or Windows runtime was newly +qualified by these static-format checks. + +## Shipped PE artifacts + +The two launchers come from the official +[pip 24.0 wheel](https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl), +SHA-256 `ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc`. +They were extracted as analysis artifacts, without installation or execution. + +| Artifact | Bytes | SHA-256 | Oracle result | +| -------- | ------: | ------------------------------------------------------------------ | --------------------------------------------------------- | +| t32.exe | 97,792 | `6b4195e640a85ac32eb6f9628822a622057df1e459df7c17a12f97aeabc9415b` | 2 DLLs / 85 functions; `5e24f42b46c247f13d78f0f21a4a2bf7` | +| t64.exe | 108,032 | `81a618f21cb87db9076134e70388b6e9cb7c2106739011b6a51772d22cae06b7` | 2 DLLs / 86 functions; `c51d659b4b1142d4af3795d09f1d63f7` | + +For both launchers the ordered DLLs, names, ordinals, hints, IAT RVAs, machine, +bitness and imphash agree with pefile. The benchmark verifies the complete named +rows, not merely the function count or MD5. + +## Completed-task costs + +Three alternating pairs per fixture execute one fresh public CLI process: +`rea inspect-pe-imports PATH --json`. All **24** tasks complete. Complete Evidence +objects are equal across all six runs of each fixture, including provenance, +coverage, limitations, locations and identity; nothing is stripped for equality. +Artifact digests remain unchanged. The stress fixture contains 2,048 independent +sections and 2,048 functions, SHA-256 +`0540676161eb7408805277a75e4d47e3d752732b6d5f294c59c8c670c7c1e905`. +The empty fixture is 3,072 bytes, SHA-256 +`b32058d9121313554af54bc0aed762871d3d8eb5e2d9eeab6a365f0518c6f897`. + +| Complete CLI task | Estimated stdout + invocation tokens, before → after | Elapsed median s | CPU median s | Sampled family RSS median MiB | +| -------------------------- | ---------------------------------------------------: | ---------------: | ------------: | ----------------------------: | +| No imports | 676 → 676 | 1.817 → 1.867 | 2.157 → 2.219 | 364.6 → 369.9 | +| Shipped PE32 | 3,678 → 3,678 | 1.868 → 1.867 | 2.201 → 2.221 | 365.2 → 366.4 | +| Shipped PE32+ | 3,715 → 3,715 | 1.868 → 1.868 | 2.238 → 2.220 | 367.1 → 370.3 | +| 2,048 sections / functions | 70,342 → 70,342 | 1.969 → 2.069 | 2.413 → 2.482 | 388.4 → 391.2 | + +Tokens use tokenx 1.3.0 estimates after timing, not actual model usage. The task +uses known CLI commands without catalog/schema/help or continuation calls. +Prompts, model framing, rendering and client JSON parsing are unmeasured. +Node is 24.22.1 on Linux 6.1.0-54 x86-64, affinity CPUs 0–1, on a shared host. +Every process is fresh; OS caches were not flushed and had prior oracle reads. +There is no warm provider-cache measurement. Build/setup costs are separate. + +Elapsed ranges overlap, and the 50 ms profiler poll quantizes short-task timing; +no speedup is claimed. The largest case's median increases about 0.10 s elapsed, +0.07 s CPU and 2.9 MiB RSS for the additional correctness checks. RSS is the +50 ms observed sum across descendants: shared pages may be counted twice and +short/unobserved processes missed. CPU accounts for waited children. No resource +stop occurred. The mapping index was already in the baseline, so these numbers +do not isolate its speedup or qualify a 65,535-section worst case. + +## Main integration verification + +Integration `a5a1f919e896488d0651e621f334efec84e9132f` includes main +`044c9705eadf62f7c47ddd42a0b2dc065b26b228`. The PE refinement is a type +intersection with main's new container union; runtime PE interpretation is +unchanged. The newly added Roman Urdu README receives the same PE imports link +as the canonical table. The full static gate passes; documentation/license +validation and test discovery pass after this link correction. The complete +selected parser/mapping/image/service/filesystem/CLI/MCP/lifecycle selection +passes **201** checks in 54.062 s (60.363 s waited-child CPU). Sets above overlap. +Later main-integration performance was not remeasured; the cost table remains +pinned to its recorded revisions. These tests use built-in static parsing, not +a simulated claim of deep-engine behavior. + +## Additional RVA review and independent ordinal control + +The pre-merge review found a real missing boundary: PE32+ virtual mappings could +carry descriptors, their terminator, or a DLL name beyond the 32-bit RVA end. +Five new controls fail before correction (four standard/delay directory cases +and one NUL beyond the RVA end). The producer now owns one RVA-span predicate +used by primitive reads and partial row admission. CString reads include only +bytes within that space. A name whose NUL is the final legal byte still passes. + +An unexamined first descriptor retains the declared directory with zero consumed +locations and partial coverage. The schema represents that observation; the +service rejects an empty consumed-span list claimed as complete. Four public +CLI cases and the real MCP SDK/advertised AJV schema exercise both zero-span and +preceding-row outcomes. The expanded selected workflow passes **213** checks +in 71.408 s (71.595 s waited-child CPU). The full static gate passes. The earlier +cost table remains pinned to its revisions; these later RVA checks were not +included in that first measurement; the separate comparison below covers them. + +The ordinal-mask suggestion was tested rather than applied: installed pefile +2024.8.26 itself uses `addr_of_data & 0x7FFFFFFF` in its ordinal corruption +heuristic, including PE32+. Independent images with both ILT/IAT words rewritten +produce these results: + +| Raw PE32+ thunk | pefile result | Native result | +| ------------------ | ----------------------------------------------------------- | -------------------------------------- | +| `8000000100000001` | ordinal 1 + ExitProcess; `72d9cd67504bb3b9f29c37a226bf4331` | identical, complete raw thunk retained | +| `8000000000010001` | import table discarded; empty hash | hash withheld | +| `8000000000000001` | ordinal 1 + ExitProcess; `72d9cd67504bb3b9f29c37a226bf4331` | identical | + +All three controls already pass before the RVA correction. Expanding the mask +to 63 payload bits would diverge from this pinned pefile behavior. The reason +text now specifies the low 31-bit payload; the raw thunk remains available, and +matching pefile does not claim loader validity for reserved bits. + +## RVA-guard completed-task follow-up + +To measure the final guard separately from other integration changes, repeat +three alternating pairs of the same four completed CLI tasks, now comparing +`a7406eca8137c9fae58a12cb8354f042047f5045` against +`feda57ae4ef420bc72e8e04393b75810da86cc3c` on their common main integration. +All **24** tasks complete. All complete Evidence objects match across each +fixture's six runs, and every ordered inventory field still matches the +independent pefile oracle. Artifact digests stay unchanged; stderr is empty. +The token estimator, invocation/discovery scope, process/cache conditions and +profiler limitations are the same as the earlier measurement. + +| Complete CLI task | Estimated stdout + invocation tokens, before → after | Elapsed median s | CPU median s | Sampled family RSS median MiB | +| -------------------------- | ---------------------------------------------------: | ---------------: | ------------: | ----------------------------: | +| No imports | 676 → 676 | 2.220 → 2.019 | 2.383 → 2.382 | 367.6 → 364.0 | +| Shipped PE32 | 3,678 → 3,678 | 1.918 → 1.918 | 2.253 → 2.203 | 366.8 → 367.9 | +| Shipped PE32+ | 3,715 → 3,715 | 1.973 → 1.968 | 2.363 → 2.293 | 368.9 → 369.3 | +| 2,048 sections / functions | 70,342 → 70,342 | 2.825 → 2.221 | 2.423 → 2.428 | 382.9 → 389.0 | + +No speedup is claimed. In the largest case, elapsed ranges are 2.019–3.085 s +before and 2.019–2.776 s after. CPU medians differ by about 0.004 s; sampled +RSS increases about 6.1 MiB (1.6%). Shared-host variance dominates the apparent +elapsed improvement. This validates the measured completed tasks, not a maximum +format-size or transport-buffer qualification. Later main changes remain +outside these pinned measurements. + +## Coverage consistency + +Three new service controls reject contradictory standard-import, delay-import, +and complete descriptor counts; all three fail before the correction. The +service now compares counts with the retained rows before issuing Evidence. +Partial results may still report more examined descriptors than retained DLLs, +as verified with an unsupported delay descriptor. Complete results must agree +and carry no issues. Existing function-count traversals are reused; the change +adds no further row scan. These validation checks postdate the pinned cost +measurements above. + +The expanded selection passes **217** checks in 73.179 s (70.360 s +waited-child CPU), with a sampled process-family peak of 2,039.3 MiB. Full +`npm run check` passes. These counts overlap earlier selections. A subsequent +main integration at `7f70797b98c226009163358f91a9cfcedf03b390` includes the +upstream plist-fixture correction for the three unrelated native-provider +failures seen in CI run 40055148308. + +## Descriptor mapping stops + +A later review identified the same partial-result boundary when a descriptor's +uint32 RVA is legal but lacks a file mapping. Four controls (standard/delay, +first descriptor/after one row) all threw before correction. Both walks now +use one descriptor admission function: range failures and mapping failures +append explicit issues, then preserve the directory declaration, consumed +spans and preceding rows. Only mapping failures are caught; unrelated failures +continue to propagate. Standard-table failures withhold imphash; delay-table +failures preserve an independently complete standard hash. + +The public CLI covers all four mapping-stop cases alongside the RVA cases. +The actual MCP SDK and advertised output schema cover an unmapped first +descriptor. These later validation changes are outside the pinned performance +comparisons above; no additional speed or maximum-size claim is made. + +Final integration at main `7f70797b` passes **277** selected checks: the +expanded PE/shared lifecycle selection plus native-provider tests that cover +the upstream plist-fixture correction. This takes 126.978 s, 127.567 s +waited-child CPU and 2,061.9 MiB sampled process-family RSS. These are test +suite costs, not analyst-task costs. Full `npm run check`, `npm run docs:check` +and test discovery pass; discovery accounts for all 2,323 files exactly once. diff --git a/licenses/pefile-2024.8.26-LICENSE.txt b/licenses/pefile-2024.8.26-LICENSE.txt new file mode 100644 index 0000000000..0866dd7f8f --- /dev/null +++ b/licenses/pefile-2024.8.26-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2004-2024 Ero Carrera + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/licenses/upstream-sources.json b/licenses/upstream-sources.json index 2fbf6a8855..c42bb91821 100644 --- a/licenses/upstream-sources.json +++ b/licenses/upstream-sources.json @@ -73,5 +73,10 @@ "file": "licenses/protobuf-es-v2.16.0-LICENSE.txt", "source": "https://raw.githubusercontent.com/bufbuild/protobuf-es/b3b232f7f466eda27c66e8f63c82f930578b6cc6/LICENSE", "sha256": "c04b4216f1cd4c5a4f7fb2f2a1b0ae70d847e9e0cac7c9dee9bf8cc03177c449" + }, + { + "file": "licenses/pefile-2024.8.26-LICENSE.txt", + "source": "https://raw.githubusercontent.com/erocarrera/pefile/4b3b1e2e568a88d4f1897d694d684f23d9e270c4/LICENSE", + "sha256": "b44409c067c6da52bfb54bb6624fb11a7b52157c3a13ae1400232f8196e86ad3" } ] diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 85fa303e2e..52f3177125 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -260,6 +260,13 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_pe_certificates", ), }, + { + identity: sources.artifactProviders.PE_IMPORTS_PROVIDER, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_pe_imports", + ), + }, { identity: sources.artifactProviders.ELF_NOTES_PROVIDER, contracts: diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 31abe683e3..7c5e65eb1b 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -529,7 +529,7 @@ const workflowAvailabilityFor = ({ }; if (name === "inspect_pe_resources" || name === "inspect_native_exports") return { reason: "available", remediation: null }; - if (name === "inspect_pe_certificates") + if (name === "inspect_pe_certificates" || name === "inspect_pe_imports") return { reason: "available", remediation: null }; if (name === "inspect_elf_notes") return { reason: "available", remediation: null }; diff --git a/src/application/binaryDiagnostics/BinaryDiagnosticsCancellation.test.ts b/src/application/binaryDiagnostics/BinaryDiagnosticsCancellation.test.ts new file mode 100644 index 0000000000..c020e0474b --- /dev/null +++ b/src/application/binaryDiagnostics/BinaryDiagnosticsCancellation.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "vitest"; +import { ElfNotesService } from "./ElfNotesService.js"; +import { PeCertificatesService } from "./PeCertificatesService.js"; +import { PeImportsService } from "./PeImportsService.js"; +import { + ELF_NOTES_PROVIDER, + PE_CERTIFICATES_PROVIDER, + PE_IMPORTS_PROVIDER, +} from "../../domain/investigationProviders.js"; + +const inspect = (): never => { + throw new Error("An already cancelled request must not reach its provider"); +}; + +it.each([ + [ + "PE imports", + () => new PeImportsService({ identity: PE_IMPORTS_PROVIDER, inspect }), + ], + [ + "PE certificates", + () => + new PeCertificatesService({ + identity: PE_CERTIFICATES_PROVIDER, + inspect, + }), + ], + [ + "ELF notes", + () => new ElfNotesService({ identity: ELF_NOTES_PROVIDER, inspect }), + ], +] as const)( + "preserves an early cancellation reason for %s before validation", + async (_label, create) => { + const service = create(); + try { + for (const reason of [null, { caller: "selected task" }]) { + const result = await service.inspect(null, { + signal: AbortSignal.abort(reason), + }); + if (result.ok) throw new Error("Expected early cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + } + } finally { + await service.close(); + } + }, +); diff --git a/src/application/binaryDiagnostics/ElfNotesService.ts b/src/application/binaryDiagnostics/ElfNotesService.ts index e34f4effb3..4bb19dbcf1 100644 --- a/src/application/binaryDiagnostics/ElfNotesService.ts +++ b/src/application/binaryDiagnostics/ElfNotesService.ts @@ -34,7 +34,9 @@ export class ElfNotesService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const input = inspectElfNotesInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/src/application/binaryDiagnostics/PeCertificatesService.ts b/src/application/binaryDiagnostics/PeCertificatesService.ts index 0ce7aa9e18..2a37994e4c 100644 --- a/src/application/binaryDiagnostics/PeCertificatesService.ts +++ b/src/application/binaryDiagnostics/PeCertificatesService.ts @@ -37,7 +37,9 @@ export class PeCertificatesService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const input = inspectPeCertificatesInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/src/application/binaryDiagnostics/PeImportsPort.ts b/src/application/binaryDiagnostics/PeImportsPort.ts new file mode 100644 index 0000000000..229c04d45a --- /dev/null +++ b/src/application/binaryDiagnostics/PeImportsPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectPeImportsInput, + PeImports, +} from "../../domain/native/peImports.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and PE import-table decoding, independent of a disassembler session. */ +export interface PeImportsPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectPeImportsInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/PeImportsService.test.ts b/src/application/binaryDiagnostics/PeImportsService.test.ts new file mode 100644 index 0000000000..0b8a777333 --- /dev/null +++ b/src/application/binaryDiagnostics/PeImportsService.test.ts @@ -0,0 +1,242 @@ +import { expect, it } from "vitest"; +import { PeImportsService } from "./PeImportsService.js"; +import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js"; +import type { PeImports } from "../../domain/native/peImports.js"; +import { peImportsSchema } from "../../domain/native/peImports.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; + +const identity = PE_IMPORTS_PROVIDER; +const report: PeImports = { + artifact: { + path: "/artifacts/app.exe", + sha256: "a".repeat(64), + bytes: 512, + }, + format: "pe32-plus", + machine: 0x8664, + bits: 64, + architecture: "amd64", + import_directory: null, + delay_import_directory: null, + imports: [], + delay_imports: [], + imphash: { + algorithm: "pefile", + md5: null, + reason: "IMAGE_DIRECTORY_ENTRY_IMPORT is absent.", + }, + coverage: { + status: "complete", + reason: null, + examined_dlls: 0, + imports: 0, + delay_imports: 0, + }, + issues: [], + limitations: ["IMAGE_DIRECTORY_ENTRY_IMPORT is absent."], +}; + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new PeImportsService({ + identity, + inspect: () => { + calls += 1; + return Promise.resolve(ok(report)); + }, + }); + const relative = await service.inspect({ path: "app.exe" }); + const extra = await service.inspect({ + path: report.artifact.path, + extra: true, + }); + expect(relative.ok).toBe(false); + expect(extra.ok).toBe(false); + if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError); + if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); + +it("records the selected PE and an absent import table", async () => { + const result = await new PeImportsService({ + identity, + inspect: () => Promise.resolve(ok(report)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "observed", + subject: { format: "pe", architecture: "x86_64" }, + normalized_result: report, + }); +}); + +it.each(["imports", "delay_imports", "examined_dlls"] as const)( + "rejects contradictory complete %s coverage", + async (field) => { + const result = await new PeImportsService({ + identity, + inspect: () => + Promise.resolve( + ok({ + ...report, + coverage: { ...report.coverage, [field]: 1 }, + }), + ), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); + }, +); + +it.each(["import_directory", "delay_import_directory"] as const)( + "accepts unexamined %s declarations only with partial coverage", + async (field) => { + for (const status of ["complete", "partial"] as const) { + const value: PeImports = { + ...report, + [field]: { rva: 0xfffffff0, size: 40, locations: [] }, + coverage: { + ...report.coverage, + status, + reason: + status === "partial" + ? "First descriptor cannot be examined." + : null, + }, + issues: + status === "partial" + ? [ + { + code: "malformed_entry", + location: "0xfffffff0", + message: "First descriptor cannot be examined.", + }, + ] + : [], + }; + const result = await new PeImportsService({ + identity, + inspect: () => Promise.resolve(ok(value)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(status === "partial"); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); + } + }, +); + +it("discards an observation if the caller cancels while the provider completes", async () => { + const controller = new AbortController(); + const service = new PeImportsService({ + identity, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report)); + }, + }); + const result = await service.inspect( + { path: report.artifact.path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); + +it("publishes every consumed directory span as its own Evidence location", async () => { + const split: PeImports = { + ...report, + import_directory: { + rva: 0x1000, + size: 40, + locations: [ + { offset: 0x100, bytes: 20 }, + { offset: 0x1e0, bytes: 20 }, + ], + }, + imports: [ + { + index: 0, + name: "KERNEL32.dll", + name_rva: 0x1100, + original_first_thunk_rva: 0x1120, + first_thunk_rva: 0x1128, + time_date_stamp: 0, + forwarder_chain: 0, + location: { offset: 0x100, bytes: 20 }, + functions: [], + }, + ], + coverage: { ...report.coverage, examined_dlls: 1 }, + imphash: { ...report.imphash, reason: "The descriptor has no functions." }, + limitations: ["Loaded import state remains unobserved."], + }; + const result = await new PeImportsService({ + identity, + inspect: () => Promise.resolve(ok(split)), + }).inspect({ path: report.artifact.path }); + if (!result.ok) throw result.error; + expect(result.value.locations).toEqual([ + { kind: "artifact-path", path: report.artifact.path }, + { kind: "file-offset-range", start: 0x100, end: 0x114 }, + { kind: "file-offset-range", start: 0x1e0, end: 0x1f4 }, + ]); + const outside = await new PeImportsService({ + identity, + inspect: () => + Promise.resolve( + ok({ + ...split, + import_directory: { + rva: 0x1000, + size: 40, + locations: [ + { offset: 0x100, bytes: 20 }, + { offset: 0x1f8, bytes: 20 }, + ], + }, + }), + ), + }).inspect({ path: report.artifact.path }); + expect(outside.ok).toBe(false); + if (!outside.ok) expect(outside.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("retains examined unsupported descriptors without inventing DLL rows", async () => { + const partial: PeImports = { + ...report, + delay_import_directory: { + rva: 0x1000, + size: 64, + locations: [{ offset: 0x100, bytes: 64 }], + }, + coverage: { + ...report.coverage, + status: "partial", + reason: "An unsupported descriptor was examined.", + examined_dlls: 1, + }, + issues: [ + { + code: "unsupported_form", + location: "0x1000", + message: "Delay descriptor uses virtual addresses.", + }, + ], + }; + const result = await new PeImportsService({ + identity, + inspect: () => Promise.resolve(ok(partial)), + }).inspect({ path: report.artifact.path }); + if (!result.ok) throw result.error; + const normalized = peImportsSchema.parse(result.value.normalized_result); + expect(normalized.coverage).toMatchObject({ + status: "partial", + examined_dlls: 1, + }); + expect(normalized.delay_imports).toEqual([]); +}); diff --git a/src/application/binaryDiagnostics/PeImportsService.ts b/src/application/binaryDiagnostics/PeImportsService.ts new file mode 100644 index 0000000000..eaad73f967 --- /dev/null +++ b/src/application/binaryDiagnostics/PeImportsService.ts @@ -0,0 +1,177 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { PeImportsPort } from "./PeImportsPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectPeImportsInputSchema, + peImportsSchema, + type PeImports, +} from "../../domain/native/peImports.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { + decodePeMachine, + peOptionalHeaderMagic, + type PeArchitecture, +} from "../../domain/peMachine.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_pe_imports"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class PeImportsService { + constructor(readonly provider: PeImportsPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + const input = inspectPeImportsInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const result = await this.provider.inspect(input.data, options); + if (!result.ok) return result; + const report = peImportsSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + !validReport(report.data, input.data) + ) + return err( + new AnalysisOutputError( + OPERATION, + "PE import provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + const architecture = subjectArchitecture(value); + const locations = [ + { kind: "artifact-path" as const, path: value.artifact.path }, + ...fileRange(value.import_directory), + ...fileRange(value.delay_import_directory), + ]; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "pe", + sha256: value.artifact.sha256, + ...(architecture === undefined ? {} : { architecture }), + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations, + }, + ), + options?.signal, + ); + } +} + +const subjectArchitecture = (report: PeImports): PeArchitecture | undefined => { + const machine = decodePeMachine(report.machine); + if (machine === undefined || machine.nativeOsOverride) return undefined; + const magic = report.format === "pe32-plus" ? 0x20b : 0x10b; + return magic === peOptionalHeaderMagic(machine.architecture) + ? machine.architecture + : undefined; +}; + +const fileRange = ( + directory: PeImports["import_directory"], +): readonly { + kind: "file-offset-range"; + start: number; + end: number; +}[] => + directory === null + ? [] + : directory.locations.map(({ offset, bytes }) => ({ + kind: "file-offset-range" as const, + start: offset, + end: offset + bytes, + })); + +const validReport = ( + report: PeImports, + input: { + readonly max_dlls: number; + readonly max_functions: number; + readonly max_file_bytes: number; + }, +): boolean => { + const inside = ({ offset, bytes }: { offset: number; bytes: number }) => + offset <= report.artifact.bytes - bytes; + const dllCount = report.imports.length + report.delay_imports.length; + if ( + report.artifact.bytes > input.max_file_bytes || + report.coverage.examined_dlls > input.max_dlls || + dllCount > report.coverage.examined_dlls || + (report.coverage.status === "complete" && + (dllCount !== report.coverage.examined_dlls || report.issues.length > 0)) + ) + return false; + const importCount = report.imports.reduce( + (count, dll) => count + dll.functions.length, + 0, + ); + const delayImportCount = report.delay_imports.reduce( + (count, dll) => count + dll.functions.length, + 0, + ); + if ( + importCount !== report.coverage.imports || + delayImportCount !== report.coverage.delay_imports || + importCount + delayImportCount > input.max_functions + ) + return false; + for (const directory of [ + report.import_directory, + report.delay_import_directory, + ]) { + if ( + directory !== null && + (!directory.locations.every(inside) || + (directory.locations.length === 0 && + report.coverage.status === "complete")) + ) + return false; + } + const descriptorsFit = ( + rows: readonly { location: { offset: number; bytes: number } }[], + ) => rows.every((row) => inside(row.location)); + return descriptorsFit(report.imports) && descriptorsFit(report.delay_imports); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 91f0f6803d..fb5ecbe6b5 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -7,6 +7,7 @@ import { createElfNotesService, createPeResourcesService, createPeCertificatesService, + createPeImportsService, createNativeExportsService, } from "../composition/binaryDiagnostics.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -18,6 +19,7 @@ import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js"; import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js"; import { inspectPeCertificatesInputSchema } from "../domain/native/peCertificates.js"; +import { inspectPeImportsInputSchema } from "../domain/native/peImports.js"; import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -143,6 +145,41 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectPeImports, { + description: + "Inspect PE import and delay-load table identities without executing the image", + args: z.object({ path: z.string().describe("Local PE32/PE32+ image") }), + options: z.object({ + maxFileBytes: inspectPeImportsInputSchema.shape.max_file_bytes, + maxDlls: inspectPeImportsInputSchema.shape.max_dlls, + maxFunctions: inspectPeImportsInputSchema.shape.max_functions, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const pe = createPeImportsService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectPeImports, + async () => + finishAnalysisCommand( + () => + pe.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_dlls: options.maxDlls, + max_functions: options.maxFunctions, + }, + { signal }, + ), + pe, + "rea-pe-imports", + "inspect_pe_imports", + signal, + ), + ); + }), + }); cli.command(CLI_COMMANDS.inspectPeResources, { description: "Inspect PE resource identities, payload ranges, hashes and icon-group relationships", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index c06c32e570..7a577663eb 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -71,6 +71,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectErlangBeam: "inspect-erlang-beam", inspectPeResources: "inspect-pe-resources", inspectPeCertificates: "inspect-pe-certificates", + inspectPeImports: "inspect-pe-imports", inspectNativeExports: "inspect-native-exports", inspectElfNotes: "inspect-elf-notes", inspectAnalysisView: "inspect-analysis-view", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index b0b562bf41..df34d4ee53 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -8,6 +8,8 @@ import { PeResourcesService } from "../application/binaryDiagnostics/PeResources import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js"; import { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js"; import { PeCertificatesProvider } from "../native/pe/PeCertificatesProvider.js"; +import { PeImportsService } from "../application/binaryDiagnostics/PeImportsService.js"; +import { PeImportsProvider } from "../native/pe/PeImportsProvider.js"; import { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import { ElfNotesProvider } from "../native/elf/ElfNotesProvider.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; @@ -24,6 +26,10 @@ export const createPeResourcesService = (): PeResourcesService => export const createPeCertificatesService = (): PeCertificatesService => new PeCertificatesService(new PeCertificatesProvider()); +/** Portable PE import-table inspection with no engine or catalog verification. */ +export const createPeImportsService = (): PeImportsService => + new PeImportsService(new PeImportsProvider()); + /** Portable PE/ELF export tables with no engine, toolchain or target acquisition. */ export const createNativeExportsService = (): NativeExportsService => new NativeExportsService(new NativeExportsProvider()); diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 65d938465b..646b16f0b4 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -32,6 +32,10 @@ import { inspectPeCertificatesInputSchema, peCertificatesSchema, } from "../../domain/native/peCertificates.js"; +import { + inspectPeImportsInputSchema, + peImportsSchema, +} from "../../domain/native/peImports.js"; import { inspectElfNotesInputSchema, elfNotesSchema, @@ -109,6 +113,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_pe_imports", + ...toolContractMetadata("inspect_pe_imports"), + kind: "native-provider", + description: + "Inspect one explicit local PE32/PE32+ image's IMAGE_DIRECTORY_ENTRY_IMPORT and delay-load tables without executing it or opening a disassembler. Returns DLL names, named and ordinal functions, ILT/IAT RVAs, delay-load descriptors, and the pefile import hash of the standard import table, withheld with a reason when that table is partial or pefile would hash a different row sequence. A missing import directory is observed absence of that table, not proof the image never imported APIs through another table or overlay. Bound snapshots, loaded IAT contents and export-forward resolution remain unobserved. Reads a stable regular file; malformed PE headers and exhausted DLL or function budgets return no partial success. Unreadable names or thunks and descriptors missing a required table are reported as partial coverage with issues. Portable on supported Node hosts; no Windows SDK or engine installation required.", + inputSchema: inspectPeImportsInputSchema, + outputSchema: evidenceResultOf(peImportsSchema), + examples: [ + { + title: "Inspect PE import and delay-load identities", + input: { path: "/artifacts/application.exe" }, + }, + ], + }, { name: "inspect_pe_resources", ...toolContractMetadata("inspect_pe_resources"), diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index d4d701f8cb..2761ed5cd3 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -32,6 +32,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_desktop_application: "states that directory triage performs no target execution", inspect_pe_certificates: "states that it does not execute the image", + inspect_pe_imports: "states that it does not execute the image", inspect_go_binary: "reads embedded metadata without executing the target", inspect_rust_binary: "reads an embedded dependency list without executing the target", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 4ae4149a96..fc5ee54690 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -258,6 +258,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_byte_entropy: evidence, inspect_pe_resources: evidence, inspect_pe_certificates: evidence, + inspect_pe_imports: evidence, inspect_native_exports: evidence, inspect_elf_notes: evidence, inspect_analysis_view: evidence, diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index ebd415e05c..a0e3c23126 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -33,6 +33,13 @@ export const PE_CERTIFICATES_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free PE import-table decoding. */ +export const PE_IMPORTS_PROVIDER = Object.freeze({ + id: "rea-pe-imports", + name: "REA PE import parser", + version: "1", +} as const); + /** Provider identity for portable, execution-free ELF note-table decoding. */ export const ELF_NOTES_PROVIDER = Object.freeze({ id: "rea-elf-notes", diff --git a/src/domain/native/peImports.test.ts b/src/domain/native/peImports.test.ts new file mode 100644 index 0000000000..ffdbc7786e --- /dev/null +++ b/src/domain/native/peImports.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from "vitest"; +import { inspectPeImportsInputSchema, peImportsSchema } from "./peImports.js"; + +it("rejects extra caller arguments and admits an empty import inventory", () => { + expect( + inspectPeImportsInputSchema.safeParse({ + path: "/artifacts/app.exe", + extra: true, + }).success, + ).toBe(false); + const report = peImportsSchema.parse({ + artifact: { + path: "/artifacts/app.exe", + sha256: "a".repeat(64), + bytes: 512, + }, + format: "pe32-plus", + machine: 0x8664, + bits: 64, + architecture: "amd64", + import_directory: null, + delay_import_directory: null, + imports: [], + delay_imports: [], + imphash: { + algorithm: "pefile", + md5: null, + reason: "IMAGE_DIRECTORY_ENTRY_IMPORT is absent.", + }, + coverage: { + status: "complete", + reason: null, + examined_dlls: 0, + imports: 0, + delay_imports: 0, + }, + issues: [], + limitations: [ + "IMAGE_DIRECTORY_ENTRY_IMPORT is absent. That is not proof the image never imported APIs through another table or overlay.", + ], + }); + expect(report.imports).toEqual([]); + expect(report.imphash.md5).toBeNull(); +}); diff --git a/src/domain/native/peImports.ts b/src/domain/native/peImports.ts new file mode 100644 index 0000000000..ab0c11713b --- /dev/null +++ b/src/domain/native/peImports.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const range = z.strictObject({ offset: uint, bytes: uint }); +const md5Schema = z.string().regex(/^[a-f0-9]{32}$/u); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_PE_IMPORT_FILE_BYTES = 536870912; +/** Hard ceiling on import and delay-load descriptors examined together. */ +export const MAX_INSPECT_PE_IMPORT_DLLS = 4096; +/** Hard ceiling on named and ordinal functions across every table. */ +export const MAX_INSPECT_PE_IMPORT_FUNCTIONS = 262144; +/** Fixed ceiling on decoded DLL and function name text retained in one report. */ +export const MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES = 16 * 1024 * 1024; + +/** Complete bounded inspection of PE import and delay-load tables. */ +export const inspectPeImportsInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a PE image", + ), + max_file_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_PE_IMPORT_FILE_BYTES) + .default(67108864) + .describe("Maximum stable artifact bytes to read (default 64 MiB)"), + max_dlls: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_PE_IMPORT_DLLS) + .default(256) + .describe( + "Maximum import and delay-load descriptors to examine; exceeding the budget fails without partial success", + ), + max_functions: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_PE_IMPORT_FUNCTIONS) + .default(8192) + .describe( + "Maximum imported functions to examine across every table; exceeding the budget fails without partial success", + ), +}); +export type InspectPeImportsInput = z.infer; + +const peImportFunctionSchema = z.strictObject({ + hint: uint.nullable(), + name: z.string().min(1).nullable(), + ordinal: z.number().int().min(0).max(0xffff).nullable(), + ilt_rva: uint.nullable(), + iat_rva: uint, + thunk_hex: z.string().regex(/^[0-9a-f]+$/u), +}); + +const peImportDllSchema = z.strictObject({ + index: uint, + name: z.string().min(1).nullable(), + name_rva: uint, + original_first_thunk_rva: uint, + first_thunk_rva: uint, + time_date_stamp: uint, + forwarder_chain: uint, + location: range, + functions: z.array(peImportFunctionSchema), +}); + +const peDelayImportDllSchema = z.strictObject({ + index: uint, + name: z.string().min(1).nullable(), + name_rva: uint, + attributes: uint, + import_address_table_rva: uint, + import_name_table_rva: uint, + bound_import_rva: uint, + unload_information_table_rva: uint, + module_handle_rva: uint, + time_date_stamp: uint, + location: range, + functions: z.array(peImportFunctionSchema), +}); + +/** + * `locations` are the file spans the parser actually consumed for the + * descriptor array and its terminator, in RVA order. Consecutive descriptors + * that map to discontiguous file bytes produce separate spans. + * No bytes are consumed when the first slot cannot be examined; the declared + * directory remains present and coverage is partial. + */ +const directorySchema = z + .strictObject({ + rva: uint, + size: uint, + locations: z.array(range), + }) + .nullable(); + +const peImportIssueSchema = z.strictObject({ + code: z.enum([ + "malformed_entry", + "descriptor_unreadable", + "name_unreadable", + "thunk_unreadable", + "unsupported_form", + ]), + location: z.string().min(1).nullable(), + message: z.string().min(1), +}); + +const coverageSchema = z.strictObject({ + status: z.enum(["complete", "partial"]), + reason: z.string().min(1).nullable(), + examined_dlls: uint, + imports: uint, + delay_imports: uint, +}); + +const imphashSchema = z.strictObject({ + algorithm: z.literal("pefile"), + md5: md5Schema.nullable(), + reason: z.string().min(1).nullable(), +}); + +/** Static import inventory of one PE32/PE32+ image, read without execution. */ +export const peImportsSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, + bytes: uint, + }), + format: z.enum(["pe32", "pe32-plus"]), + machine: uint, + bits: z.union([z.literal(32), z.literal(64)]), + architecture: z.string().min(1), + import_directory: directorySchema, + delay_import_directory: directorySchema, + imports: z.array(peImportDllSchema), + delay_imports: z.array(peDelayImportDllSchema), + imphash: imphashSchema, + coverage: coverageSchema, + issues: z.array(peImportIssueSchema), + limitations: z.array(z.string().min(1)), +}); +export type PeImports = z.infer; +export type PeImportDll = z.infer; +export type PeDelayImportDll = z.infer; +export type PeImportFunction = z.infer; +export type PeImportIssue = z.infer; diff --git a/src/native/metadata/BinaryContainer.test.ts b/src/native/metadata/BinaryContainer.test.ts index 1603d63e72..65cf2aaa1a 100644 --- a/src/native/metadata/BinaryContainer.test.ts +++ b/src/native/metadata/BinaryContainer.test.ts @@ -1,10 +1,95 @@ import { expect, it } from "vitest"; import { + BinaryFormatFailure, + BinaryMappingFailure, + createFileMappingIndex, mappedFileOffset, readMappedCString, type BinaryContainer, + type FileMapping, + type ZeroFillMapping, } from "./BinaryContainer.js"; +const outcome = (translate: () => number): number | string => { + try { + return translate(); + } catch (cause) { + if (cause instanceof BinaryMappingFailure) return cause.reason; + if (cause instanceof BinaryFormatFailure) return cause.message; + throw cause; + } +}; + +/** Deterministic xorshift so failures replay exactly. */ +const random = (seed: number) => { + let state = seed; + return (limit: number): number => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return (state >>> 0) % limit; + }; +}; + +it("indexes exactly the spans mappedFileOffset accepts, rejects and translates", () => { + const next = random(0x5eed); + const seen = new Set(); + for (let round = 0; round < 150; round++) { + const mappings: FileMapping[] = Array.from({ length: 1 + next(6) }, () => ({ + address: 0x1000n + BigInt(next(96)), + offset: next(4) === 0 ? 0x400 : next(256), + size: next(48), + })); + // Some rounds alias one mapping at the same translation to cover consistent overlap. + if (next(3) === 0 && mappings[0] !== undefined) + mappings.push({ + address: mappings[0].address + 4n, + offset: mappings[0].offset + 4, + size: Math.max(0, mappings[0].size - 4), + }); + const zeroFills: ZeroFillMapping[] = Array.from( + { length: next(3) }, + () => ({ + address: 0x1000n + BigInt(next(160)), + size: BigInt(next(16)), + }), + ); + const lookup = createFileMappingIndex(mappings, zeroFills).offset; + const indexed: (number | string)[] = []; + const scanned: (number | string)[] = []; + for (let address = 0xff8n; address < 0x10a8n; address++) { + for (const size of [1, 2, 4, 9, 20]) { + indexed.push(outcome(() => lookup(address, size))); + scanned.push( + outcome(() => mappedFileOffset(mappings, address, size, zeroFills)), + ); + } + } + expect(indexed, `round ${String(round)}`).toEqual(scanned); + for (const value of scanned) + seen.add(typeof value === "number" ? "translated" : value); + } + expect([...seen].sort()).toEqual(["ambiguous", "translated", "unmapped"]); +}); + +it("maps adjacent ranges sharing one file translation", () => { + const lookup = createFileMappingIndex([ + { address: 0x1000n, offset: 0x400, size: 0x10 }, + { address: 0x1010n, offset: 0x410, size: 0x10 }, + ]).offset; + expect(lookup(0x1008n, 8)).toBe(0x408); + expect(lookup(0x100cn, 8)).toBe(0x40c); +}); + +it("rejects partially overlapping mappings with different file translations", () => { + const lookup = createFileMappingIndex([ + { address: 0x1000n, offset: 0x400, size: 0x20 }, + { address: 0x1018n, offset: 0x900, size: 0x10 }, + ]).offset; + expect(lookup(0x1000n, 0x10)).toBe(0x400); + expect(() => lookup(0x1010n, 0x10)).toThrow(/ambiguous/u); +}); + const mappings = [ { address: 100n, offset: 0, size: 4 }, { address: 104n, offset: 4, size: 4 }, @@ -32,21 +117,58 @@ it("refuses a zero-fill alias inside a span spanning multiple mappings", () => { mappedFileOffset(mappings, 102n, 4, [{ address: 104n, size: 1n }]), ).toThrow(/zero-fill/); }); -it("reads strings across discontiguous source ranges without examining bytes after NUL", () => { - const image: BinaryContainer = { - format: "pe", - architecture: "amd64", - bits: 64, - byte_order: "little", - pe_header_mapping: mappings[0]!, - mappings: [{ address: 104n, offset: 8, size: 4 }], - zero_fills: [{ address: 106n, size: 2n }], - sections: [], - }; - expect( - readMappedCString(Buffer.from("ABCDxxxxE\0xx"), image, 102n, 8).toString(), - ).toBe("CDE"); - expect(() => - mappedFileOffset([...mappings.slice(0, 1), ...image.mappings], 102n, 4), - ).toThrow(/ambiguous/); -}); +it.each(["scan", "index"] as const)( + "reads strings across discontiguous source ranges without examining bytes after NUL (%s)", + (mode) => { + const image: BinaryContainer = { + format: "pe", + architecture: "amd64", + bits: 64, + byte_order: "little", + pe_header_mapping: mappings[0]!, + mappings: [{ address: 104n, offset: 8, size: 4 }], + zero_fills: [{ address: 106n, size: 2n }], + sections: [], + }; + const bytes = Buffer.from("ABCDxxxxE\0xx"); + const result = + mode === "scan" + ? readMappedCString(bytes, image, 102n, 8) + : createFileMappingIndex( + [mappings[0]!, ...image.mappings], + image.zero_fills, + ).readCString(bytes, 102n, 8); + expect(result.toString()).toBe("CDE"); + expect(() => + mappedFileOffset([...mappings.slice(0, 1), ...image.mappings], 102n, 4), + ).toThrow(/ambiguous/); + }, +); + +it.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])( + "preserves zero-span and invalid-size semantics for %s", + (size) => { + const index = createFileMappingIndex(mappings); + expect(outcome(() => index.offset(104n, size))).toEqual( + outcome(() => mappedFileOffset(mappings, 104n, size)), + ); + }, +); + +it.each([false, true])( + "keeps conflict classification before gap and zero-fill checks (%s)", + (conflicting) => { + const ranges = [ + { address: 100n, offset: 0, size: 2 }, + { address: 104n, offset: conflicting ? 8 : 4, size: 2 }, + ]; + const zeroFills = [{ address: 102n, size: 1n }]; + const index = createFileMappingIndex(ranges, zeroFills); + expect(outcome(() => index.offset(101n, 4))).toBe( + conflicting ? "ambiguous" : "unmapped", + ); + expect(outcome(() => index.offset(101n, 4))).toEqual( + outcome(() => mappedFileOffset(ranges, 101n, 4, zeroFills)), + ); + }, +); diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index e8fd6ff4d0..c7c92e4818 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -315,6 +315,207 @@ export const mappedFileOffset = ( return found; }; +/** Index of the last sorted value at or below `value`, or -1. */ +const lastAtOrBelow = (sorted: readonly bigint[], value: bigint): number => { + let low = 0; + let high = sorted.length; + while (low < high) { + const middle = (low + high) >>> 1; + if ((sorted[middle] as bigint) <= value) low = middle + 1; + else high = middle; + } + return low - 1; +}; + +/** Sorted starts with the furthest end reached by any span starting at or before each. */ +const prefixReach = ( + spans: readonly { readonly start: bigint; readonly end: bigint }[], +): { readonly starts: bigint[]; readonly reach: bigint[] } => { + const sorted = [...spans].sort((left, right) => + left.start < right.start ? -1 : left.start > right.start ? 1 : 0, + ); + const starts: bigint[] = []; + const reach: bigint[] = []; + let furthest = -1n; + for (const span of sorted) { + if (span.end > furthest) furthest = span.end; + starts.push(span.start); + reach.push(furthest); + } + return { starts, reach }; +}; + +/** + * Indexed `mappedFileOffset` for parsers that translate many spans in one + * image. Construction is O(n log n) over mappings; each lookup is O(log n), so + * hostile section counts cannot multiply the cost of every table entry. It + * accepts, rejects and translates exactly the spans `mappedFileOffset` does; + * a span that is both a conflicting file alias and a zero-fill overlap may be + * refused under either ambiguity message. + */ +export const createFileMappingIndex = ( + mappings: readonly FileMapping[], + zeroFills: readonly ZeroFillMapping[] = [], +): FileMappingIndex => { + const backed = mappings + .filter((mapping) => mapping.size > 0) + .map((mapping) => ({ + start: mapping.address, + end: mapping.address + BigInt(mapping.size), + translation: BigInt(mapping.offset) - mapping.address, + })); + const zeroFilled = prefixReach( + zeroFills + .filter((zeroFill) => zeroFill.size > 0n) + .map((zeroFill) => ({ + start: zeroFill.address, + end: zeroFill.address + zeroFill.size, + })), + ); + // Elementary intervals between every mapping boundary, each with the single + // file translation shared by all mappings covering it (or none/ambiguous). + const events = new Map(); + const event = (at: bigint) => { + let entry = events.get(at); + if (entry === undefined) { + entry = { add: [], remove: [] }; + events.set(at, entry); + } + return entry; + }; + for (const span of backed) { + event(span.start).add.push(span.translation); + event(span.end).remove.push(span.translation); + } + const boundaries = [...events.keys()].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); + const active = new Map(); + // Undefined is a gap; null is an overlap with conflicting translations. + const translations: (bigint | null | undefined)[] = []; + for (const boundary of boundaries) { + const entry = events.get(boundary); + for (const translation of entry?.remove ?? []) { + const count = (active.get(translation) ?? 0) - 1; + if (count === 0) active.delete(translation); + else active.set(translation, count); + } + for (const translation of entry?.add ?? []) + active.set(translation, (active.get(translation) ?? 0) + 1); + translations.push( + active.size === 0 + ? undefined + : active.size === 1 + ? (active.keys().next().value as bigint) + : null, + ); + } + // runEnd[i]: end of the maximal run of intervals sharing interval i's translation. + const runEnd: bigint[] = boundaries.map(() => 0n); + for (let index = boundaries.length - 1; index >= 0; index--) { + const next = boundaries[index + 1]; + const translation = translations[index]; + runEnd[index] = + next === undefined + ? (boundaries[index] as bigint) + : translation !== null && + translation !== undefined && + translations[index + 1] === translation + ? (runEnd[index + 1] as bigint) + : next; + } + const ambiguousPrefix = [0]; + const previousMapped: number[] = []; + const nextMapped: number[] = []; + const translationRuns: number[] = []; + let previous = -1; + let run = 0; + let previousTranslation: bigint | undefined; + for (const [index, translation] of translations.entries()) { + ambiguousPrefix.push( + (ambiguousPrefix[index] ?? 0) + (translation === null ? 1 : 0), + ); + if (typeof translation === "bigint") { + if (translation !== previousTranslation) run += 1; + previousTranslation = translation; + previous = index; + } + previousMapped.push(previous); + translationRuns.push(run); + } + let next = translations.length; + for (let index = translations.length - 1; index >= 0; index--) { + if (typeof translations[index] === "bigint") next = index; + nextMapped[index] = next; + } + const offset = (address: bigint, size: number): number => { + if (!Number.isSafeInteger(size) || size <= 0) + return mappedFileOffset(mappings, address, size, zeroFills); + const end = address + BigInt(size); + const interval = lastAtOrBelow(boundaries, address); + const lower = Math.max(0, interval); + const upper = lastAtOrBelow(boundaries, end - 1n); + const first = nextMapped[lower] ?? translations.length; + const last = previousMapped[upper] ?? -1; + // Like the scan, conflicts anywhere in the requested span win over gaps. + if ( + (ambiguousPrefix[upper + 1] ?? 0) - (ambiguousPrefix[lower] ?? 0) > 0 || + (first <= upper && + last >= lower && + translationRuns[first] !== translationRuns[last]) + ) + throw new BinaryMappingFailure( + "ambiguous", + "Virtual span has ambiguous or noncontiguous file mappings", + ); + const translation = translations[interval]; + if (typeof translation !== "bigint" || (runEnd[interval] as bigint) < end) + throw new BinaryMappingFailure( + "unmapped", + "Virtual address or string range is not file mapped", + ); + const zeroFill = lastAtOrBelow(zeroFilled.starts, end - 1n); + if (zeroFill >= 0 && (zeroFilled.reach[zeroFill] as bigint) > address) + throw new BinaryMappingFailure( + "ambiguous", + "Virtual address has ambiguous file and zero-fill mappings", + ); + return Number(address + translation); + }; + const access: MappedStringAccess = { + offset, + mappingAt: (address) => { + const start = offset(address, 1); + const interval = lastAtOrBelow(boundaries, address); + return { + address, + offset: start, + size: Number((runEnd[interval] as bigint) - address), + }; + }, + }; + return { + offset, + readCString: (bytes, address, maximumBytes) => + readMappedString(bytes, access, address, maximumBytes), + }; +}; + +/** One immutable image's indexed mappings and bounded string access. */ +export interface FileMappingIndex { + readonly offset: (address: bigint, size: number) => number; + readonly readCString: ( + bytes: Buffer, + address: bigint, + maximumBytes: number, + ) => Buffer; +} + +interface MappedStringAccess { + readonly mappingAt: (address: bigint) => FileMapping | undefined; + readonly offset: (address: bigint, size: number) => number; +} + /** Read a bounded NUL-terminated string while retaining mapping/alias semantics. */ export const readMappedCString = ( bytes: Buffer, @@ -323,16 +524,36 @@ export const readMappedCString = ( maximumBytes: number, ): Buffer => { const mappings = imageFileMappings(image); + return readMappedString( + bytes, + { + mappingAt: (cursor) => + mappings.find( + (range) => + range.address <= cursor && + cursor < range.address + BigInt(range.size), + ), + offset: (cursor, size) => + mappedFileOffset(mappings, cursor, size, image.zero_fills), + }, + address, + maximumBytes, + ); +}; + +const readMappedString = ( + bytes: Buffer, + access: MappedStringAccess, + address: bigint, + maximumBytes: number, +): Buffer => { const chunks: Buffer[] = []; let length = 0; while (length <= maximumBytes) { const cursor = address + BigInt(length); - const mapping = mappings.find( - (range) => - range.address <= cursor && cursor < range.address + BigInt(range.size), - ); + const mapping = access.mappingAt(cursor); if (mapping === undefined) { - mappedFileOffset(mappings, cursor, 1, image.zero_fills); + access.offset(cursor, 1); throw new BinaryMappingFailure( "unmapped", "String range is not file mapped", @@ -346,7 +567,7 @@ export const readMappedCString = ( const chunk = bytes.subarray(offset, offset + size); const terminator = chunk.indexOf(0); const consumed = terminator < 0 ? size : terminator + 1; - mappedFileOffset(mappings, cursor, consumed, image.zero_fills); + access.offset(cursor, consumed); if (terminator >= 0) { const tail = chunk.subarray(0, terminator); if (chunks.length === 0) return tail; diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index 5c89dc9a20..dc807b2d28 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -61,8 +61,29 @@ const optionalHeaderMagic = ( return reader.u16(offset); }; +/** Declared PE fields retained by their parsing owner for format consumers. */ +export type PeImageSection = Extract< + ImageSection, + { readonly format: "pe" } +> & { + readonly raw_offset: number; +}; + +export type PeImage = BinaryContainer & { + readonly format: "pe"; + readonly image_base: bigint; + readonly pe_machine: number; + readonly pe_section_alignment: number; + readonly pe_file_alignment: number; + readonly pe_header_mapping: FileMapping; + readonly pe_data_directories: NonNullable< + BinaryContainer["pe_data_directories"] + >; + readonly sections: readonly PeImageSection[]; +}; + /** Read PE32/PE32+ native image mappings and section facts. */ -export const readPeImage = (bytes: Buffer): BinaryContainer => { +export const readPeImage = (bytes: Buffer): PeImage => { const reader = new BinaryReader(bytes, true); const pe = peHeaderOffset(reader); const optional = pe + 24; @@ -107,7 +128,7 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { const mappings: FileMapping[] = []; const executableMappings: FileMapping[] = []; const zeroFills: ZeroFillMapping[] = []; - const sections: ImageSection[] = []; + const sections: PeImageSection[] = []; for (let index = 0; index < count; index++) { const entry = start + index * 40; const { @@ -167,6 +188,9 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { return { format: "pe", image_base: imageBase, + pe_machine: machine, + pe_section_alignment: reader.u32(optional + 32), + pe_file_alignment: fileAlignment, pe_data_directories: dataDirectories, pe_header_mapping: headerMapping, architecture: names.get(machine) ?? `pe-machine-${machine}`, @@ -199,7 +223,7 @@ const peImageSection = ({ readonly virtualSize: number; readonly rawSize: number; readonly fileAlignment: number; -}): ImageSection => { +}): PeImageSection => { const range = declaredFileRange( reader.bytes.length, BigInt(offset), @@ -217,6 +241,7 @@ const peImageSection = ({ virtual_address: virtualAddress, virtual_size: virtualSize, raw_size: rawSize, + raw_offset: offset, file_alignment: fileAlignment, characteristics, ...range, diff --git a/src/native/pe/PeImportParser.test.ts b/src/native/pe/PeImportParser.test.ts new file mode 100644 index 0000000000..2db2f1cacc --- /dev/null +++ b/src/native/pe/PeImportParser.test.ts @@ -0,0 +1,650 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + inspectPeImportsInputSchema, + peImportsSchema, +} from "../../domain/native/peImports.js"; +import { + peImportFixtureOffset, + peImportImage, + peImportLeadingSectionImage, + peImportManySectionsImage, + peImportDirectoryAtRva, + peImportDirectoryMappingStops, + peImportNameAtRva, + peWithoutImportTableFixture, +} from "../../../tests/fixtures/binaryDiagnostics/peImports.js"; +import { parsePeImports, PeImportLimitFailure } from "./PeImportParser.js"; + +const input = inspectPeImportsInputSchema.parse({ + path: "/artifacts/app.exe", +}); + +const artifact = (bytes: Buffer) => ({ + path: "/artifacts/app.exe", + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, +}); + +it("reads named PE32+ imports and a pefile-compatible import hash", async () => { + const bytes = peImportImage({ bits: 64 }); + const report = await parsePeImports(bytes, artifact(bytes), input); + expect(report.format).toBe("pe32-plus"); + expect(report.bits).toBe(64); + expect(report.imports).toHaveLength(1); + expect(report.imports[0]?.name).toBe("KERNEL32.dll"); + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([ + "GetProcAddress", + "ExitProcess", + ]); + expect(report.imphash.md5).toBe("92cef6cc91a85825f3c8a0516517c66f"); + expect(report.coverage.status).toBe("complete"); + expect(report.delay_imports).toEqual([]); +}); + +const parse = (bytes: Buffer) => parsePeImports(bytes, artifact(bytes), input); + +it.each([ + ["standard", 0], + ["standard", 1], + ["delay", 0], + ["delay", 1], +] as const)( + "retains %s rows before a descriptor mapping gap after %s rows", + async (kind, retained) => { + const report = await parse(peImportDirectoryMappingStops(kind, retained)); + const directory = + kind === "standard" + ? report.import_directory + : report.delay_import_directory; + const rows = kind === "standard" ? report.imports : report.delay_imports; + const width = kind === "standard" ? 20 : 32; + expect(rows).toHaveLength(retained); + expect(directory).toMatchObject({ + rva: 0x40000000, + locations: retained === 0 ? [] : [{ offset: 0x1000, bytes: width }], + }); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toContainEqual( + expect.objectContaining({ + code: "descriptor_unreadable", + location: `0x${(0x40000000 + retained * width).toString(16)}`, + }), + ); + expect(report.imphash.md5).toBe( + kind === "delay" ? "92cef6cc91a85825f3c8a0516517c66f" : null, + ); + expect(peImportsSchema.safeParse(report).success).toBe(true); + }, +); + +it.each([ + ["standard", 0xffffffec, 1, "0x100000000"], + ["standard", 0xfffffff0, 0, "0xfffffff0"], + ["delay", 0xffffffe0, 1, "0x100000000"], + ["delay", 0xfffffff0, 0, "0xfffffff0"], +] as const)( + "stops the %s descriptor walk at RVA %s", + async (kind, rva, retained, invalid) => { + const report = await parse(peImportDirectoryAtRva(kind, rva)); + const directory = + kind === "standard" + ? report.import_directory + : report.delay_import_directory; + const rows = kind === "standard" ? report.imports : report.delay_imports; + expect(rows).toHaveLength(retained); + expect(directory?.rva).toBe(rva); + expect(directory?.locations).toHaveLength(retained); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "malformed_entry", location: invalid }), + ]), + ); + expect(peImportsSchema.safeParse(report).success).toBe(true); + expect(report.imphash.md5).toBe( + kind === "delay" ? "92cef6cc91a85825f3c8a0516517c66f" : null, + ); + }, +); + +it("does not consume a DLL name past the uint32 RVA end", async () => { + const report = await parse(peImportNameAtRva(0xfffffff8)); + expect(report.imports[0]?.name).toBeNull(); + expect(report.imports[0]?.functions).toHaveLength(2); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "name_unreadable", + location: "0xfffffff8", + }), + ]), + ); + expect(report.imphash.md5).toBeNull(); +}); + +it("accepts a DLL name whose NUL is the final uint32 RVA byte", async () => { + const report = await parse(peImportNameAtRva(0xfffffff3)); + expect(report.imports[0]?.name).toBe("KERNEL32.dll"); + expect(report.coverage.status).toBe("complete"); + expect(report.imphash.md5).toBe("92cef6cc91a85825f3c8a0516517c66f"); +}); + +it.each([ + [0x8000000100000001n, "72d9cd67504bb3b9f29c37a226bf4331"], + [0x8000000000010001n, null], + [0x8000000000000001n, "72d9cd67504bb3b9f29c37a226bf4331"], +] as const)( + "matches pefile 2024.8.26 for ordinal thunk %s", + async (thunk, expected) => { + const bytes = peImportImage(); + for (const rva of [0x1028, 0x1040]) + bytes.writeBigUInt64LE(thunk, peImportFixtureOffset(rva)); + const report = await parse(bytes); + expect(report.imports[0]?.functions[0]).toMatchObject({ + ordinal: 1, + thunk_hex: thunk.toString(16), + }); + expect(report.imphash.md5).toBe(expected); + }, +); + +it("withholds the hash beyond pefile's section parser capacity", async () => { + const report = await parse(peImportManySectionsImage(2049, 2)); + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([ + "Function0000", + "Function0001", + ]); + expect(report.coverage.status).toBe("complete"); + expect(report.imphash.md5).toBeNull(); + expect(report.imphash.reason).toMatch(/mapping|compatibility/u); +}); + +it.each([false, true])( + "withholds the hash when pefile stops at an earlier section (suspicious: %s)", + async (suspicious) => { + const report = await parse(peImportLeadingSectionImage(suspicious)); + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([ + "GetProcAddress", + "ExitProcess", + ]); + expect(report.coverage.status).toBe("complete"); + expect(report.imphash.md5).toBeNull(); + expect(report.imphash.reason).toMatch(/mapping|compatibility/u); + }, +); + +/** Default PE32+ KERNEL32 image with one descriptor field rewritten. */ +const withDescriptorField = ( + field: number, + value: number, + options: Parameters[0] = {}, +) => { + const bytes = peImportImage(options); + bytes.writeUInt32LE(value, peImportFixtureOffset(0x1000) + field); + return bytes; +}; + +it("hashes unmapped ordinals as pefile's ord fallback", async () => { + const bytes = peImportImage({ + bits: 32, + dlls: [ + { + name: "USER32.dll", + functions: [{ ordinal: 42 }], + }, + ], + }); + const report = await parsePeImports(bytes, artifact(bytes), input); + expect(report.format).toBe("pe32"); + expect(report.imports[0]?.functions[0]).toMatchObject({ + name: null, + ordinal: 42, + hint: null, + }); + // pefile 2024.8.26 get_imphash() over this image: MD5("user32.ord42"). + expect(report.imphash).toEqual({ + algorithm: "pefile", + md5: "f39f7ee67c122319a74d4270d3e5c36c", + reason: null, + }); +}); + +it("falls back to ord for DLL names that shadow object members", async () => { + const bytes = peImportImage({ + bits: 32, + dlls: [ + { name: "constructor", functions: [{ ordinal: 1 }] }, + { name: "__proto__", functions: [{ ordinal: 2 }] }, + ], + }); + // pefile: "constructor.ord1,__proto__.ord2". + expect((await parse(bytes)).imphash).toEqual({ + algorithm: "pefile", + md5: "8a4fd3988e11f8e29b11a7edc60886e6", + reason: null, + }); +}); + +it("uses pefile's complete ws2_32 and wsock32 ordinal tables", async () => { + const bytes = peImportImage({ + bits: 32, + dlls: [ + { + name: "WS2_32.dll", + functions: [{ ordinal: 23 }, { ordinal: 999 }, { name: "WSAStartup" }], + }, + { name: "wsock32.dll", functions: [{ ordinal: 1155 }, { ordinal: 1 }] }, + ], + }); + // pefile: "ws2_32.socket,ws2_32.ord999,ws2_32.wsastartup,wsock32.ord1155,wsock32.accept". + expect((await parse(bytes)).imphash.md5).toBe( + "79b3bdee998e474e75390fac55093cb0", + ); +}); + +it("withholds the import hash when a standard import name is unreadable", async () => { + const bytes = peImportImage(); + const lookup = (await parse(bytes)).imports[0]?.original_first_thunk_rva ?? 0; + bytes.writeBigUInt64LE(0x7fff0000n, peImportFixtureOffset(lookup + 8)); + const report = await parse(bytes); + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([ + "GetProcAddress", + ]); + expect(report.issues).toMatchObject([ + { code: "name_unreadable", location: "0x7fff0000" }, + ]); + expect(report.coverage.status).toBe("partial"); + expect(report.imphash.md5).toBeNull(); + expect(report.imphash.reason).toMatch(/standard import table is incomplete/u); +}); + +it("withholds the import hash when a standard thunk is unreadable", async () => { + const bytes = withDescriptorField(0, 0x7fff0000); + const report = await parse(bytes); + expect(report.imports[0]?.functions).toEqual([]); + expect(report.issues[0]?.code).toBe("thunk_unreadable"); + expect(report.coverage.status).toBe("partial"); + expect(report.imphash.md5).toBeNull(); +}); + +it("reports a zero FirstThunk beside a populated lookup table as malformed", async () => { + const report = await parse(withDescriptorField(16, 0)); + expect(report.imports[0]).toMatchObject({ + name: "KERNEL32.dll", + first_thunk_rva: 0, + functions: [], + }); + expect(report.issues).toMatchObject([ + { code: "malformed_entry", message: expect.stringMatching(/FirstThunk/u) }, + ]); + expect(report.coverage.status).toBe("partial"); + expect(report.imphash.md5).toBeNull(); + expect(report.imphash.reason).toMatch(/incomplete/u); +}); + +it.each([ + ["import address table", 12], + ["import name table", 16], +])( + "reports a delay-load descriptor with a zero %s as malformed without withholding the standard hash", + async (table, field) => { + const bytes = peImportImage({ + delayDlls: [ + { name: "ADVAPI32.dll", functions: [{ name: "RegOpenKeyExW" }] }, + ], + }); + const location = (await parse(bytes)).delay_imports[0]?.location; + if (location === undefined) throw new Error("Expected a delay descriptor"); + bytes.writeUInt32LE(0, location.offset + field); + const report = await parse(bytes); + expect(report.delay_imports[0]?.functions).toEqual([]); + expect(report.issues).toMatchObject([ + { + code: "malformed_entry", + message: expect.stringContaining(`zero ${table} RVA`), + }, + ]); + expect(report.coverage.status).toBe("partial"); + expect(report.imphash.md5).toBe("92cef6cc91a85825f3c8a0516517c66f"); + }, +); + +it("reports each discontiguous file span a descriptor array consumes", async () => { + const options = { + delayDlls: [ + { name: "ADVAPI32.dll", functions: [{ name: "RegOpenKeyExW" }] }, + ], + }; + const whole = await parse(peImportImage(options)); + expect(whole.import_directory?.locations).toEqual([ + { offset: 0x400, bytes: 40 }, + ]); + expect(whole.delay_import_directory?.locations).toEqual([ + { offset: 0x428, bytes: 64 }, + ]); + // Second section bytes move to file offset 0x400 + 0x800 + 0x200. + const importSplit = await parse(peImportImage({ ...options, splitAt: 20 })); + expect(importSplit.import_directory?.locations).toEqual([ + { offset: 0x400, bytes: 20 }, + { offset: 0xe00, bytes: 20 }, + ]); + expect(importSplit.delay_import_directory?.locations).toEqual([ + { offset: 0xe14, bytes: 64 }, + ]); + const delaySplit = await parse(peImportImage({ ...options, splitAt: 72 })); + expect(delaySplit.import_directory?.locations).toEqual([ + { offset: 0x400, bytes: 40 }, + ]); + expect(delaySplit.delay_import_directory?.locations).toEqual([ + { offset: 0x428, bytes: 32 }, + { offset: 0xe00, bytes: 32 }, + ]); + for (const report of [importSplit, delaySplit]) { + expect(report.coverage.status).toBe("complete"); + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([ + "GetProcAddress", + "ExitProcess", + ]); + expect(report.delay_imports[0]?.functions[0]?.name).toBe("RegOpenKeyExW"); + expect(report.imphash.md5).toBe("92cef6cc91a85825f3c8a0516517c66f"); + } +}); + +it.each([ + [ + "a function name pefile treats as invalid", + { name: "Get-Proc" }, + /invalid/u, + ], + ["ordinal 0", { ordinal: 0 }, /ordinal 0/u], + ["a name pefile truncates", { name: "A".repeat(512) }, /invalid/u], +] as const)( + "withholds the import hash for %s rather than diverge from pefile", + async (_label, entry, reason) => { + const report = await parse( + peImportImage({ + sectionBytes: 0x1000, + dlls: [{ name: "KERNEL32.dll", functions: [entry] }], + }), + ); + expect(report.coverage.status).toBe("complete"); + expect(report.imphash.md5).toBeNull(); + expect(report.imphash.reason).toMatch(reason); + }, +); + +it("reads delay-load descriptors separately from the import hash", async () => { + const bytes = peImportImage({ + delayDlls: [ + { + name: "ADVAPI32.dll", + functions: [{ name: "RegOpenKeyExW", hint: 3 }], + }, + ], + }); + const report = await parsePeImports(bytes, artifact(bytes), input); + expect(report.delay_imports).toHaveLength(1); + expect(report.delay_imports[0]?.name).toBe("ADVAPI32.dll"); + expect(report.delay_imports[0]?.functions[0]?.name).toBe("RegOpenKeyExW"); + expect(report.imphash.md5).toBe("92cef6cc91a85825f3c8a0516517c66f"); +}); + +it("reports an image without an import directory as importing nothing", async () => { + const bytes = peWithoutImportTableFixture(); + const report = await parsePeImports(bytes, artifact(bytes), input); + expect(report.import_directory).toBeNull(); + expect(report.imports).toEqual([]); + expect(report.coverage.status).toBe("complete"); + expect(report.limitations[0]).toContain( + "IMAGE_DIRECTORY_ENTRY_IMPORT is absent", + ); +}); + +it("fails closed when the DLL budget is exhausted", async () => { + const bytes = peImportImage({ + dlls: [ + { name: "A.dll", functions: [{ name: "a" }] }, + { name: "B.dll", functions: [{ name: "b" }] }, + ], + }); + await expect( + parsePeImports( + bytes, + artifact(bytes), + inspectPeImportsInputSchema.parse({ + path: "/artifacts/app.exe", + max_dlls: 1, + }), + ), + ).rejects.toThrow( + expect.objectContaining({ limit: "dlls", maximum_bytes: 1 }), + ); +}); + +it("fails closed when the function budget is exhausted", async () => { + const bytes = peImportImage({ + dlls: [ + { + name: "KERNEL32.dll", + functions: [{ name: "GetProcAddress" }, { name: "ExitProcess" }], + }, + ], + }); + await expect( + parsePeImports( + bytes, + artifact(bytes), + inspectPeImportsInputSchema.parse({ + path: "/artifacts/app.exe", + max_functions: 1, + }), + ), + ).rejects.toThrow( + expect.objectContaining({ limit: "functions", maximum_bytes: 1 }), + ); +}); + +it("fails closed with a structured tag when retained name text is exhausted", async () => { + const name = "A".repeat(4096); + const bytes = peImportImage({ + sectionBytes: 0x1200000, + dlls: [ + { + name: "KERNEL32.dll", + functions: Array.from({ length: 4097 }, () => ({ name })), + }, + ], + }); + const failure = parse(bytes); + await expect(failure).rejects.toThrow(PeImportLimitFailure); + await expect(failure).rejects.toThrow( + expect.objectContaining({ + limit: "retained_text", + maximum_bytes: 16 * 1024 * 1024, + }), + ); +}); + +it("computes a pefile import hash for oleaut32 ordinal names", async () => { + const bytes = peImportImage({ + dlls: [ + { + name: "OLEAUT32.dll", + functions: [{ ordinal: 2 }], + }, + ], + }); + const report = await parsePeImports(bytes, artifact(bytes), input); + expect(report.imports[0]?.functions[0]).toMatchObject({ + name: null, + ordinal: 2, + }); + expect(report.imphash.md5).toBe("3dc76082b322e4bf41120e4657c7f5c6"); +}); + +it("records delay-load virtual-address descriptors as unsupported", async () => { + const bytes = peImportImage({ + delayDlls: [ + { + name: "ADVAPI32.dll", + functions: [{ name: "RegOpenKeyExW" }], + }, + ], + delayAttributes: 0, + }); + const report = await parsePeImports(bytes, artifact(bytes), input); + expect(report.delay_imports).toEqual([]); + expect(report.coverage.status).toBe("partial"); + expect(report.issues[0]?.code).toBe("unsupported_form"); + expect(report.imphash.md5).toBe("92cef6cc91a85825f3c8a0516517c66f"); +}); + +it("withholds the import hash exactly where pefile's import symbol cap truncates", async () => { + const image = (sizes: readonly number[]) => + peImportImage({ + sectionBytes: 0x100000, + dlls: sizes.map((count, dll) => ({ + name: `D${String(dll)}.dll`, + functions: Array.from({ length: count }, (_, index) => ({ + name: `F${String(dll)}_${String(index)}`, + })), + })), + }); + // pefile 2024.8.26 hashes all 4096 rows here... + expect((await parse(image([4095, 1]))).imphash.md5).toBe( + "2217c72699a462a8ca9980afe6a852aa", + ); + // ...but drops D1.dll here because its 8192-thunk cap is reached first. + const capped = await parse(image([4096, 1])); + expect(capped.coverage.status).toBe("complete"); + expect(capped.imphash.md5).toBeNull(); + expect(capped.imphash.reason).toMatch(/descriptor 1 .*symbol cap/u); +}); + +it("yields during long table walks and stops when the caller cancels", async () => { + const bytes = peImportImage({ + sectionBytes: 0x20000, + dlls: [ + { + name: "KERNEL32.dll", + functions: Array.from({ length: 4000 }, (_, index) => ({ + name: `F${String(index)}`, + })), + }, + ], + }); + const controller = new AbortController(); + const pending = parsePeImports( + bytes, + artifact(bytes), + input, + controller.signal, + ); + let ticks = 0; + const tick = setImmediate(() => { + ticks += 1; + controller.abort(); + }); + await expect(pending).rejects.toMatchObject({ + name: "ArtifactReaderFailure", + reason: "cancelled", + }); + clearImmediate(tick); + expect(ticks).toBe(1); +}); + +it("withholds the import hash once pefile would discard a repetitive lookup table", async () => { + const repeated = async (repeats: number) => { + const bytes = peImportImage({ + dlls: [ + { + name: "KERNEL32.dll", + functions: Array.from({ length: repeats + 1 }, (_, index) => ({ + name: `F${String(index)}`, + })), + }, + ], + }); + const lookup = (await parse(bytes)).imports[0]?.original_first_thunk_rva; + if (lookup === undefined) throw new Error("Expected a lookup table"); + const first = bytes.readBigUInt64LE(peImportFixtureOffset(lookup)); + for (let index = 1; index <= repeats; index++) + bytes.writeBigUInt64LE(first, peImportFixtureOffset(lookup + index * 8)); + return parse(bytes); + }; + // pefile 2024.8.26 keeps 14 repeats and hashes the lookup rows... + expect((await repeated(14)).imphash.md5).toBe( + "a849c88f8733c2f2171c4130e925bca6", + ); + // ...but discards the table at 15 and hashes the import address table instead. + const discarded = await repeated(15); + expect(discarded.coverage.status).toBe("complete"); + expect(discarded.imphash.md5).toBeNull(); + expect(discarded.imphash.reason).toMatch(/repeats hint\/name addresses 15/u); +}); + +it.each([32, 64] as const)( + "retains valid rows before a PE%s IAT RVA overflows", + async (bits) => { + const width = bits / 8; + const bytes = withDescriptorField(16, 0x100000000 - width, { bits }); + const report = await parse(bytes); + expect(peImportsSchema.safeParse(report).success).toBe(true); + expect(report.imports[0]?.functions).toHaveLength(1); + expect(report.imports[0]?.functions[0]?.iat_rva).toBe(0x100000000 - width); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "malformed_entry", + location: "0x100000000", + }), + ]), + ); + expect(report.coverage.status).toBe("partial"); + expect(report.imphash.md5).toBeNull(); + }, +); + +it("preserves an invalid wide named thunk without rounding its source value", async () => { + const bytes = peImportImage(); + bytes.writeBigUInt64LE(0x20000000000001n, peImportFixtureOffset(0x1028)); + const report = await parse(bytes); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "malformed_entry", + location: "0x20000000000001", + }), + ]), + ); + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([ + "ExitProcess", + ]); + expect(report.coverage.status).toBe("partial"); + expect(report.imphash.md5).toBeNull(); +}); + +it.each(["function", "DLL"])( + "retains a cross-section %s name but withholds an unproven pefile hash", + async (kind) => { + const count = kind === "function" ? 140 : 134; + const functions = Array.from({ length: count }, (_, i) => ({ + name: `Function${String(i).padStart(3, "0")}${kind === "DLL" && i === 0 ? "A".repeat(10) : ""}`, + })); + const bytes = peImportImage({ + sectionBytes: 0x4000, + splitAt: 0x1000, + dlls: [{ name: "KERNEL32.dll", functions }], + }); + const report = await parse(bytes); + expect(report.imports[0]?.name).toBe("KERNEL32.dll"); + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual( + functions.map((row) => row.name), + ); + expect(report.coverage.status).toBe("complete"); + expect(report.imphash.md5).toBeNull(); + expect(report.imphash.reason).toMatch(/mapping|compatibility/u); + }, +); diff --git a/src/native/pe/PeImportParser.ts b/src/native/pe/PeImportParser.ts new file mode 100644 index 0000000000..9366274204 --- /dev/null +++ b/src/native/pe/PeImportParser.ts @@ -0,0 +1,574 @@ +import { createPefileReadCompatibility } from "./PefileReadCompatibility.js"; +import { setImmediate } from "node:timers/promises"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { + BinaryFormatFailure, + BinaryMappingFailure, + BinaryReader, + BinaryResourceFailure, + createFileMappingIndex, + imageFileMappings, +} from "../metadata/BinaryContainer.js"; +import { readPeImage } from "../metadata/PeImage.js"; +import { + MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES, + type InspectPeImportsInput, + type PeDelayImportDll, + type PeImportDll, + type PeImportFunction, + type PeImportIssue, + type PeImports, +} from "../../domain/native/peImports.js"; +import { + IMPORT_DESCRIPTOR_BYTES, + PEFILE_MAX_IMPORT_SYMBOLS, + pefileImphash, +} from "./pefileImphash.js"; + +type Artifact = PeImports["artifact"]; + +const PE_IMPORT_DIRECTORY = 1; +const PE_DELAY_IMPORT_DIRECTORY = 13; +const DELAY_DESCRIPTOR_BYTES = 32; +const MAXIMUM_NAME_BYTES = 4096; +/** Table entries decoded between event-loop yields and cancellation checks. */ +const ENTRIES_PER_YIELD = 1024; +const ORDINAL_FLAG32 = 0x80000000n; +const ORDINAL_FLAG64 = 0x8000000000000000n; +const PE_RVA_END = 0x100000000; + +/** PE32+ keeps 32-bit RVAs even though preferred virtual addresses are 64-bit. */ +const fitsRva = (rva: number, size: number): boolean => + Number.isSafeInteger(rva) && + rva >= 0 && + Number.isSafeInteger(size) && + size > 0 && + rva <= PE_RVA_END - size; + +const requireRva = (rva: number, size: number): void => { + if (!fitsRva(rva, size)) + throw new BinaryFormatFailure( + "malformed", + `PE RVA ${hex(rva)} span of ${String(size)} bytes extends beyond the 32-bit RVA space`, + ); +}; + +const PE_LIMITATIONS = [ + "Rows reproduce the PE import and delay-load directories; they do not establish that an import is bound, forwarded or reachable at runtime.", + "Bound import snapshots, the import address table as loaded, and export-forward resolution in another DLL remain unobserved.", +]; + +/** The parser-owned allowance an image exhausted. */ +export type PeImportLimit = "dlls" | "functions" | "retained_text"; + +/** Structured parser allowance refusal; consumers branch on `limit`, never on prose. */ +export class PeImportLimitFailure extends BinaryResourceFailure { + constructor( + readonly limit: PeImportLimit, + readonly allowance: number, + message: string, + ) { + super("structure", allowance, message); + this.name = "PeImportLimitFailure"; + } +} + +const hex = (value: bigint | number): string => `0x${value.toString(16)}`; + +const dllBudget = (count: number, max: number): void => { + if (count > max) + throw new PeImportLimitFailure( + "dlls", + max, + `Import inventory declares ${String(count)} descriptors, above the ${String(max)}-entry DLL allowance`, + ); +}; + +const functionBudget = (count: number, max: number): void => { + if (count > max) + throw new PeImportLimitFailure( + "functions", + max, + `Import inventory declares ${String(count)} functions, above the ${String(max)}-entry function allowance`, + ); +}; + +const createRetention = () => { + let text = 0; + return (value: string): void => { + text += value.length; + if (text > MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES) + throw new PeImportLimitFailure( + "retained_text", + MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES, + `Import names exceed the fixed ${String(MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES)}-byte retained-text allowance`, + ); + }; +}; + +const failureMessage = (cause: unknown): string => { + if (cause instanceof BinaryFormatFailure) return cause.message; + throw cause; +}; + +/** Append one consumed file span, merging it into the previous span when contiguous. */ +const consume = ( + spans: { offset: number; bytes: number }[], + offset: number, + bytes: number, +): void => { + const last = spans.at(-1); + if (last !== undefined && last.offset + last.bytes === offset) + last.bytes += bytes; + else spans.push({ offset, bytes }); +}; + +const coverage = ( + issues: readonly PeImportIssue[], + examinedDlls: number, + imports: number, + delayImports: number, +): PeImports["coverage"] => + issues.length === 0 + ? { + status: "complete", + reason: null, + examined_dlls: examinedDlls, + imports, + delay_imports: delayImports, + } + : { + status: "partial", + reason: `${String(issues.length)} import table issue(s) left entries unreported; see issues`, + examined_dlls: examinedDlls, + imports, + delay_imports: delayImports, + }; + +/** + * Read PE import and delay-load directories without executing the image. + * Yields to the event loop and honors cancellation every + * `ENTRIES_PER_YIELD` descriptors or thunks. + */ +export const parsePeImports = async ( + bytes: Buffer, + artifact: Artifact, + input: InspectPeImportsInput, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const image = readPeImage(bytes); + const bits = image.bits; + const base = image.image_base; + const reader = new BinaryReader(bytes, true); + const lookup = createFileMappingIndex( + imageFileMappings(image), + image.zero_fills, + ); + const compatibleRead = createPefileReadCompatibility(image, bytes.length); + let mappingCompatibility = true; + const map = (rva: number, size: number): number => { + requireRva(rva, size); + const offset = lookup.offset(base + BigInt(rva), size); + mappingCompatibility &&= compatibleRead(rva, size, offset); + return offset; + }; + const format = bits === 64 ? ("pe32-plus" as const) : ("pe32" as const); + const result = { + artifact, + format, + machine: image.pe_machine, + bits, + architecture: image.architecture, + }; + const issues: PeImportIssue[] = []; + const admitRva = (rva: number, size: number, label: string): boolean => { + if (fitsRva(rva, size)) return true; + issues.push({ + code: "malformed_entry", + location: hex(rva), + message: `${label} span of ${String(size)} bytes extends beyond the 32-bit RVA space`, + }); + return false; + }; + const mapDescriptor = ( + rva: number, + size: number, + label: string, + ): number | null => { + if (!admitRva(rva, size, label)) return null; + try { + return map(rva, size); + } catch (cause) { + if (!(cause instanceof BinaryMappingFailure)) throw cause; + issues.push({ + code: "descriptor_unreadable", + location: hex(rva), + message: `${label}: ${cause.message}`, + }); + return null; + } + }; + const retain = createRetention(); + let examinedDlls = 0; + let examinedFunctions = 0; + let entries = 0; + const checkpoint = async (): Promise => { + entries += 1; + if (entries % ENTRIES_PER_YIELD !== 0) return; + await setImmediate(); + assertArtifactReadActive(signal); + }; + + const cString = (rva: number): string => { + requireRva(rva, 1); + const raw = lookup.readCString( + bytes, + base + BigInt(rva), + Math.min(MAXIMUM_NAME_BYTES, PE_RVA_END - rva - 1), + ); + if (raw.length === 0) + throw new BinaryFormatFailure( + "malformed", + `Import name at RVA ${hex(rva)} is empty`, + ); + // CString decoding may span physically discontiguous sections. A pefile + // hash is only justified when its single-section read sees the whole name. + if (mappingCompatibility) { + try { + const size = raw.length + 1; + const offset = lookup.offset(base + BigInt(rva), size); + mappingCompatibility = compatibleRead(rva, size, offset); + } catch (cause) { + if (!(cause instanceof BinaryFormatFailure)) throw cause; + mappingCompatibility = false; + } + } + return raw.toString("latin1"); + }; + + const readName = (rva: number, label: string): string | null => { + try { + const name = cString(rva); + retain(name); + return name; + } catch (cause) { + issues.push({ + code: "name_unreadable", + location: hex(rva), + message: `${label}: ${failureMessage(cause)}`, + }); + return null; + } + }; + + const readFunctions = async ( + nameTableRva: number, + addressTableRva: number, + ): Promise => { + const thunkWidth = bits === 64 ? 8 : 4; + const ordinalFlag = bits === 64 ? ORDINAL_FLAG64 : ORDINAL_FLAG32; + const functions: PeImportFunction[] = []; + for (let index = 0; ; index++) { + await checkpoint(); + const nameRva = nameTableRva + index * thunkWidth; + const iatRva = addressTableRva + index * thunkWidth; + if (!admitRva(nameRva, thunkWidth, "Import lookup table")) break; + let thunk: bigint; + try { + thunk = reader.word(map(nameRva, thunkWidth), bits); + } catch (cause) { + issues.push({ + code: "thunk_unreadable", + location: hex(nameRva), + message: failureMessage(cause), + }); + break; + } + if (thunk === 0n) break; + if (!admitRva(iatRva, thunkWidth, "Import address table")) break; + examinedFunctions += 1; + functionBudget(examinedFunctions, input.max_functions); + const thunkHex = thunk.toString(16); + if ((thunk & ordinalFlag) !== 0n) { + functions.push({ + hint: null, + name: null, + ordinal: Number(thunk & 0xffffn), + ilt_rva: nameTableRva === addressTableRva ? null : nameRva, + iat_rva: iatRva, + thunk_hex: thunkHex, + }); + continue; + } + if (thunk > 0x7fffffffn) { + issues.push({ + code: "malformed_entry", + location: `0x${thunkHex}`, + message: + "A named import thunk uses bits outside its 31-bit hint/name RVA", + }); + continue; + } + const hintNameRva = Number(thunk); + try { + const hintAt = map(hintNameRva, 3); + const hint = reader.u16(hintAt); + const name = cString(hintNameRva + 2); + retain(name); + functions.push({ + hint, + name, + ordinal: null, + ilt_rva: nameTableRva === addressTableRva ? null : nameRva, + iat_rva: iatRva, + thunk_hex: thunkHex, + }); + } catch (cause) { + issues.push({ + code: "name_unreadable", + location: hex(hintNameRva), + message: failureMessage(cause), + }); + } + } + return functions; + }; + + // pefile counts every ILT and IAT thunk iteration against one image-wide cap + // and silently truncates tables past it. Replay that count (reading IAT + // entries only until the cap) to know when its import hash covers a prefix. + let pefileThunkReads = 0; + let pefileTruncatedDescriptor: number | null = null; + const pefileTableWalk = (rva: number, entries: number | null): number => { + const width = bits === 64 ? 8 : 4; + let processed = 0; + for (let index = 0; ; index++) { + if (pefileThunkReads > PEFILE_MAX_IMPORT_SYMBOLS) break; + pefileThunkReads += 1; + if (entries !== null) { + if (index === entries) break; + } else { + let word: bigint; + try { + word = reader.word(map(rva + index * width, width), bits); + } catch (cause) { + if (!(cause instanceof BinaryFormatFailure)) throw cause; + break; + } + if (word === 0n) break; + } + processed += 1; + } + return processed; + }; + const replayPefileSymbolCap = ( + index: number, + originalFirstThunk: number, + firstThunk: number, + entries: number, + ): void => { + const table = + originalFirstThunk === 0 + ? pefileTableWalk(firstThunk, entries) + : pefileTableWalk(originalFirstThunk, entries); + if (originalFirstThunk !== 0) pefileTableWalk(firstThunk, null); + if (table < entries) pefileTruncatedDescriptor ??= index; + }; + + const missingTable = (descriptorRva: number, message: string): [] => { + issues.push({ + code: "malformed_entry", + location: hex(descriptorRva), + message, + }); + return []; + }; + + const importDirectory = image.pe_data_directories?.[PE_IMPORT_DIRECTORY]; + const delayDirectory = image.pe_data_directories?.[PE_DELAY_IMPORT_DIRECTORY]; + const imports: PeImportDll[] = []; + let importDirectoryResult: PeImports["import_directory"] = null; + if (importDirectory !== undefined && importDirectory.virtual_address !== 0) { + const spans: { offset: number; bytes: number }[] = []; + for (let index = 0; ; index++) { + await checkpoint(); + const descriptorRva = + importDirectory.virtual_address + index * IMPORT_DESCRIPTOR_BYTES; + const descriptor = mapDescriptor( + descriptorRva, + IMPORT_DESCRIPTOR_BYTES, + "Import descriptor", + ); + if (descriptor === null) break; + consume(spans, descriptor, IMPORT_DESCRIPTOR_BYTES); + const originalFirstThunk = reader.u32(descriptor); + const timeDateStamp = reader.u32(descriptor + 4); + const forwarderChain = reader.u32(descriptor + 8); + const nameRva = reader.u32(descriptor + 12); + const firstThunk = reader.u32(descriptor + 16); + if ( + originalFirstThunk === 0 && + timeDateStamp === 0 && + forwarderChain === 0 && + nameRva === 0 && + firstThunk === 0 + ) + break; + examinedDlls += 1; + dllBudget(examinedDlls, input.max_dlls); + const name = readName(nameRva, "Import descriptor DLL name"); + const functions = + firstThunk === 0 + ? missingTable( + descriptorRva, + originalFirstThunk === 0 + ? "Import descriptor declares neither a lookup table nor an import address table (FirstThunk is zero); its functions are unknown" + : `Import descriptor has a zero FirstThunk (import address table RVA); its lookup table at ${hex(originalFirstThunk)} is not decoded`, + ) + : await readFunctions( + originalFirstThunk === 0 ? firstThunk : originalFirstThunk, + firstThunk, + ); + if (firstThunk !== 0) + replayPefileSymbolCap( + index, + originalFirstThunk, + firstThunk, + functions.length, + ); + imports.push({ + index, + name, + name_rva: nameRva, + original_first_thunk_rva: originalFirstThunk, + first_thunk_rva: firstThunk, + time_date_stamp: timeDateStamp, + forwarder_chain: forwarderChain, + location: { + offset: descriptor, + bytes: IMPORT_DESCRIPTOR_BYTES, + }, + functions, + }); + } + importDirectoryResult = { + rva: importDirectory.virtual_address, + size: importDirectory.size, + locations: spans, + }; + } + const standardIssues = issues.length; + const standardMappingCompatibility = mappingCompatibility; + + const delayImports: PeDelayImportDll[] = []; + let delayDirectoryResult: PeImports["delay_import_directory"] = null; + if (delayDirectory !== undefined && delayDirectory.virtual_address !== 0) { + const spans: { offset: number; bytes: number }[] = []; + for (let index = 0; ; index++) { + await checkpoint(); + const descriptorRva = + delayDirectory.virtual_address + index * DELAY_DESCRIPTOR_BYTES; + const descriptor = mapDescriptor( + descriptorRva, + DELAY_DESCRIPTOR_BYTES, + "Delay-load descriptor", + ); + if (descriptor === null) break; + consume(spans, descriptor, DELAY_DESCRIPTOR_BYTES); + const attributes = reader.u32(descriptor); + const nameRva = reader.u32(descriptor + 4); + const moduleHandleRva = reader.u32(descriptor + 8); + const importAddressTableRva = reader.u32(descriptor + 12); + const importNameTableRva = reader.u32(descriptor + 16); + const boundImportRva = reader.u32(descriptor + 20); + const unloadInformationTableRva = reader.u32(descriptor + 24); + const timeDateStamp = reader.u32(descriptor + 28); + if ( + attributes === 0 && + nameRva === 0 && + moduleHandleRva === 0 && + importAddressTableRva === 0 && + importNameTableRva === 0 && + boundImportRva === 0 && + unloadInformationTableRva === 0 && + timeDateStamp === 0 + ) + break; + examinedDlls += 1; + dllBudget(examinedDlls, input.max_dlls); + if ((attributes & 1) === 0) { + issues.push({ + code: "unsupported_form", + location: hex(descriptorRva), + message: + "Delay-load descriptor uses virtual addresses rather than RVAs; those entries are not decoded", + }); + continue; + } + const name = readName(nameRva, "Delay-load DLL name"); + delayImports.push({ + index, + name, + name_rva: nameRva, + attributes, + import_address_table_rva: importAddressTableRva, + import_name_table_rva: importNameTableRva, + bound_import_rva: boundImportRva, + unload_information_table_rva: unloadInformationTableRva, + module_handle_rva: moduleHandleRva, + time_date_stamp: timeDateStamp, + location: { offset: descriptor, bytes: DELAY_DESCRIPTOR_BYTES }, + functions: + importAddressTableRva === 0 + ? missingTable( + descriptorRva, + "Delay-load descriptor has a zero import address table RVA; its functions are not decoded", + ) + : importNameTableRva === 0 + ? missingTable( + descriptorRva, + "Delay-load descriptor has a zero import name table RVA; its address table holds loader thunks rather than names, so its functions are not decoded", + ) + : await readFunctions(importNameTableRva, importAddressTableRva), + }); + } + delayDirectoryResult = { + rva: delayDirectory.virtual_address, + size: delayDirectory.size, + locations: spans, + }; + } + + const limitations = + importDirectoryResult === null + ? [ + "IMAGE_DIRECTORY_ENTRY_IMPORT is absent. That is not proof the image never imported APIs through another table or overlay.", + ...PE_LIMITATIONS, + ] + : PE_LIMITATIONS; + + return { + ...result, + import_directory: importDirectoryResult, + delay_import_directory: delayDirectoryResult, + imports, + delay_imports: delayImports, + imphash: pefileImphash(imports, { + standardIssues, + mappingCompatibility: standardMappingCompatibility, + truncatedDescriptor: pefileTruncatedDescriptor, + bits, + directoryRva: importDirectory?.virtual_address ?? 0, + fileBytes: bytes.length, + }), + coverage: coverage( + issues, + examinedDlls, + imports.reduce((count, dll) => count + dll.functions.length, 0), + delayImports.reduce((count, dll) => count + dll.functions.length, 0), + ), + issues, + limitations, + }; +}; diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts new file mode 100644 index 0000000000..94d1b013bb --- /dev/null +++ b/src/native/pe/PeImportsProvider.ts @@ -0,0 +1,259 @@ +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, + assertArtifactReadActive, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import type { PeImportsPort } from "../../application/binaryDiagnostics/PeImportsPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, + type ResourceConstraintRecovery, +} from "../../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; +import { + MAX_INSPECT_PE_IMPORT_DLLS, + MAX_INSPECT_PE_IMPORT_FILE_BYTES, + MAX_INSPECT_PE_IMPORT_FUNCTIONS, + MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES, + type InspectPeImportsInput, +} from "../../domain/native/peImports.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { err, ok } from "../../domain/result.js"; +import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { + parsePeImports, + PeImportLimitFailure, + type PeImportLimit, +} from "./PeImportParser.js"; + +const OPERATION = "inspect_pe_imports"; + +/** Portable import-table reader over one bounded, identity-checked regular file. */ +export class PeImportsProvider implements PeImportsPort { + readonly identity = PE_IMPORTS_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + PE_IMPORTS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect(input: InspectPeImportsInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + assertArtifactReadActive(options?.signal); + const report = await parsePeImports( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + options?.signal, + ); + assertArtifactReadActive(options?.signal); + return ok(report); + }); + } catch (cause) { + return err(peImportsFailure(cause, input)); + } + } +} + +const fileSizeRemediation = (input: InspectPeImportsInput): string => + input.max_file_bytes >= MAX_INSPECT_PE_IMPORT_FILE_BYTES + ? `The image exceeds the ${String(MAX_INSPECT_PE_IMPORT_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + +const dllRemediation = (input: InspectPeImportsInput): string => + input.max_dlls >= MAX_INSPECT_PE_IMPORT_DLLS + ? `The image exceeds the ${String(MAX_INSPECT_PE_IMPORT_DLLS)}-entry import-descriptor ceiling this operation accepts; select a smaller artifact.` + : `Raise max_dlls above ${String(input.max_dlls)} or select a smaller artifact; the limit is the caller-selected DLL budget.`; + +const functionRemediation = (input: InspectPeImportsInput): string => + input.max_functions >= MAX_INSPECT_PE_IMPORT_FUNCTIONS + ? `The image exceeds the ${String(MAX_INSPECT_PE_IMPORT_FUNCTIONS)}-entry imported-function ceiling this operation accepts; select a smaller artifact.` + : `Raise max_functions above ${String(input.max_functions)} or select a smaller artifact; the limit is the caller-selected function budget.`; + +/** Recovery for one exhausted allowance, chosen from the parser's structured tag. */ +const limitRecovery = ( + limit: PeImportLimit | null, + input: InspectPeImportsInput, +): ResourceConstraintRecovery => { + switch (limit) { + case "dlls": + return { retryable: false, remediationAction: dllRemediation(input) }; + case "functions": + return { + retryable: false, + remediationAction: functionRemediation(input), + }; + case "retained_text": + return fixedAllowanceOptions( + `The ${String(MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES)}-byte PE import retained-text allowance (this release has no runtime override; max_file_bytes, max_dlls and max_functions do not raise it)`, + "bytes", + ); + case null: + return fixedAllowanceOptions( + "The PE structural decoding allowance", + "parser", + ); + } +}; + +const peImportsFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectPeImportsInput, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof BinaryResourceFailure) { + const limit = cause instanceof PeImportLimitFailure ? cause.limit : null; + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `Selected artifact ${input.path}: ${cause.message}`, + { boundary: cause.boundary, maximum: cause.maximum_bytes, limit }, + { cause, ...limitRecovery(limit, input) }, + ); + } + if (cause instanceof BinaryFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${input.path}: ${cause.message}`, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: fileSizeRemediation(input), + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ProviderAdapterError(PE_IMPORTS_PROVIDER.id, OPERATION, { + cause, + diagnostics: { + path: input.path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const peImportsFailure = ( + cause: unknown, + input: InspectPeImportsInput, +): AnalysisError => { + const primary = peImportsFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + PE_IMPORTS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/src/native/pe/PefileReadCompatibility.ts b/src/native/pe/PefileReadCompatibility.ts new file mode 100644 index 0000000000..bf4def80c7 --- /dev/null +++ b/src/native/pe/PefileReadCompatibility.ts @@ -0,0 +1,78 @@ +import type { PeImage } from "../metadata/PeImage.js"; + +/** + * Prove that a successful native read sees the same bytes as pefile 2024.8.26. + * pefile adjusts section addresses/raw pointers and clips each read at its + * selected section's raw end. Native mappings can legitimately span sections. + * Unsupported layout geometry leaves hash compatibility unknown, without + * discarding the native parser's observations. + */ +export const createPefileReadCompatibility = ( + image: PeImage, + fileBytes: number, +): ((rva: number, size: number, offset: number) => boolean) => { + // pefile's section loop stops at MAX_SECTIONS, even when later mappings + // are structurally readable. It may also stop at a null/suspicious header. + if (image.sections.length > 0x800) return () => false; + const sections = [...image.sections].sort( + (left, right) => left.virtual_address - right.virtual_address, + ); + const alignment = + image.pe_section_alignment < 0x1000 + ? image.pe_file_alignment + : image.pe_section_alignment; + let layoutAgrees = true; + let previousEnd = 0; + for (const section of sections) { + const start = section.virtual_address; + const emptyHeader = + section.name === "" && + start === 0 && + section.virtual_size === 0 && + section.raw_size === 0 && + section.raw_offset === 0 && + section.characteristics === 0; + const sectionWarnings = + Number(section.raw_offset + section.raw_size > fileBytes) + + Number(Math.floor(section.raw_offset / 512) * 512 > fileBytes) + + Number(section.virtual_size > 0x10000000) + + Number(start > 0x10000000) + + Number( + image.pe_file_alignment !== 0 && + section.raw_offset % image.pe_file_alignment !== 0, + ); + if ( + emptyHeader || + sectionWarnings >= 3 || + (alignment > 0 && start % alignment !== 0) || + section.raw_offset % 0x200 !== 0 || + start < previousEnd + ) + layoutAgrees = false; + previousEnd = start + Math.max(section.raw_size, section.virtual_size); + } + return (rva, size, offset) => { + if (!layoutAgrees) return false; + let low = 0; + let high = sections.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if ((sections[middle]?.virtual_address ?? Infinity) <= rva) + low = middle + 1; + else high = middle; + } + const section = sections[low - 1]; + if ( + section === undefined || + rva >= + section.virtual_address + + Math.max(section.raw_size, section.virtual_size) + ) + return offset === rva; + const relative = rva - section.virtual_address; + return ( + offset === section.raw_offset + relative && + size <= section.raw_size - relative + ); + }; +}; diff --git a/src/native/pe/pefileImphash.ts b/src/native/pe/pefileImphash.ts new file mode 100644 index 0000000000..ca11f981f5 --- /dev/null +++ b/src/native/pe/pefileImphash.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import type { + PeImportDll, + PeImportFunction, + PeImports, +} from "../../domain/native/peImports.js"; +import { PEFILE_ORDINAL_NAMES } from "./pefileOrdinalNames.js"; + +/** IMAGE_IMPORT_DESCRIPTOR size. */ +export const IMPORT_DESCRIPTOR_BYTES = 20; + +/** pefile 2024.8.26 parsing constants that change which rows its import hash sees. */ +const PEFILE_MAX_NAME_BYTES = 0x200; +export const PEFILE_MAX_IMPORT_SYMBOLS = 0x2000; +const PEFILE_MAX_REPEATED_ADDRESSES = 15; +const PEFILE_MAX_ADDRESS_SPREAD = 128n * 1024n * 1024n; +const PEFILE_DLL_NAME = /^[A-Za-z0-9!#$%&'()\-@^_`{}~+,.;=[\]:\\/]+$/u; +const PEFILE_FUNCTION_NAME = /^[A-Za-z0-9._?@$()<>]+$/u; + +const stripImportExtension = (name: string): string => { + const lower = name.toLowerCase(); + for (const extension of [".dll", ".sys", ".ocx"]) { + if (lower.endsWith(extension)) return lower.slice(0, -extension.length); + } + return lower; +}; + +/** pefile `ordlookup.ordLookup(dll, ordinal, make_name=True)`. */ +const pefileOrdinalName = (dll: string, ordinal: number): string => { + const key = dll.toLowerCase(); + // Own keys only: "constructor" and "__proto__" are valid DLL names. + const names = Object.hasOwn(PEFILE_ORDINAL_NAMES, key) + ? PEFILE_ORDINAL_NAMES[key] + : undefined; + return names?.get(ordinal) ?? `ord${String(ordinal)}`; +}; + +const withheld = (reason: string): PeImports["imphash"] => ({ + algorithm: "pefile", + md5: null, + reason, +}); + +/** + * Why pefile would read a different row sequence from this complete + * descriptor than the one reported, or null when both agree. Mirrors + * `PE.get_import_table` and `PE.parse_imports` bounds and validity rules. + */ +const pefileTableDivergence = ( + dll: PeImportDll, + bits: 32 | 64, + directoryRva: number, + fileBytes: number, +): string | null => { + const label = `Standard import descriptor ${String(dll.index)}`; + if ( + dll.name === null || + dll.name.length >= PEFILE_MAX_NAME_BYTES || + !PEFILE_DLL_NAME.test(dll.name) + ) + return `${label} DLL name is unreadable or has a length or characters pefile replaces with *invalid*.`; + if (dll.functions.length === 0) + return `${label} lists no functions; pefile then falls back to the import address table or drops the descriptor.`; + const width = bits / 8; + const firstThunk = dll.first_thunk_rva; + const originalFirstThunk = dll.original_first_thunk_rva; + const tableRva = originalFirstThunk === 0 ? firstThunk : originalFirstThunk; + const next = directoryRva + (dll.index + 1) * IMPORT_DESCRIPTOR_BYTES; + const maxLength = + next > originalFirstThunk || next > firstThunk + ? Math.max(next - originalFirstThunk, next - firstThunk) + : fileBytes - dll.location.offset; + const named: bigint[] = []; + for (const [index, row] of dll.functions.entries()) { + if (index * width >= maxLength) + return `${label} lookup table runs past pefile's descriptor-relative bound.`; + const thunk = BigInt(`0x${row.thunk_hex}`); + const divergence = rowDivergence( + row, + thunk, + BigInt(tableRva), + BigInt(tableRva + index * width), + ); + if (divergence !== null) return `${label} ${divergence}`; + if (row.ordinal === null) named.push(thunk); + } + const addresses = addressDivergence(named); + return addresses === null ? null : `${label} ${addresses}`; +}; + +/** One lookup entry pefile would drop, truncate at or discard its table for. */ +const rowDivergence = ( + row: PeImportFunction, + thunk: bigint, + tableRva: bigint, + entryRva: bigint, +): string | null => { + if (thunk >= tableRva && thunk <= entryRva) + return "has a thunk that points into its own lookup table; pefile truncates there."; + if (row.ordinal !== null) { + // Pinned pefile 2024.8.26 masks only 31 bits for both widths. In PE32+ + // it ignores reserved bits 31–62; retain that compatibility behavior and + // the source thunk, without asserting that a loader accepts those bits. + if ((thunk & 0x7fffffffn) > 0xffffn) + return "has an ordinal thunk whose low 31-bit payload exceeds 16 bits; pefile discards the table."; + return row.ordinal === 0 ? "imports ordinal 0, which pefile omits." : null; + } + return row.name === null || + row.name.length >= PEFILE_MAX_NAME_BYTES || + !PEFILE_FUNCTION_NAME.test(row.name) + ? "has a function name with a length or characters pefile treats as invalid and omits." + : null; +}; + +/** pefile's repeated-address and address-spread table rejection heuristics. */ +const addressDivergence = (named: readonly bigint[]): string | null => { + const sets = { low: new Set(), high: new Set() }; + let repeated = 0; + for (const thunk of named) { + const set = thunk >= 1n << 32n ? sets.high : sets.low; + if (set.has(thunk)) repeated += 1; + set.add(thunk); + } + if (repeated >= PEFILE_MAX_REPEATED_ADDRESSES) + return `repeats hint/name addresses ${String(repeated)} times; pefile discards such tables.`; + for (const set of [sets.low, sets.high]) { + if (set.size === 0) continue; + const values = [...set]; + const low = values.reduce((left, right) => (right < left ? right : left)); + const high = values.reduce((left, right) => (right > left ? right : left)); + if (high - low > PEFILE_MAX_ADDRESS_SPREAD) + return "hint/name addresses spread beyond 128 MiB; pefile discards such tables."; + } + return null; +}; + +/** Parser facts pefile's row selection depends on beyond the reported rows. */ +export interface PefileImphashFacts { + readonly mappingCompatibility: boolean; + /** Issues recorded while reading the standard import table. */ + readonly standardIssues: number; + /** First descriptor pefile's import symbol cap truncates, if any. */ + readonly truncatedDescriptor: number | null; + readonly bits: 32 | 64; + readonly directoryRva: number; + readonly fileBytes: number; +} + +/** + * pefile `PE.get_imphash()` over a complete standard import table, or an + * explicit reason when the reported rows are not the sequence pefile hashes. + */ +export const pefileImphash = ( + imports: readonly PeImportDll[], + { + standardIssues, + mappingCompatibility, + truncatedDescriptor, + bits, + directoryRva, + fileBytes, + }: PefileImphashFacts, +): PeImports["imphash"] => { + if (standardIssues > 0) + return withheld( + `The standard import table is incomplete (${String(standardIssues)} issue(s)); a hash of the retained rows would not be this image's import hash.`, + ); + if (imports.length === 0) + return withheld("IMAGE_DIRECTORY_ENTRY_IMPORT is absent or empty."); + if (truncatedDescriptor !== null) + return withheld( + `Standard import descriptor ${String(truncatedDescriptor)} lies past pefile's ${String(PEFILE_MAX_IMPORT_SYMBOLS)}-thunk import symbol cap, so pefile hashes only a prefix of the table.`, + ); + if (!mappingCompatibility) + return withheld( + "Byte mapping compatibility with pefile is not established for the standard import table or its names.", + ); + const parts: string[] = []; + for (const dll of imports) { + const divergence = pefileTableDivergence( + dll, + bits, + directoryRva, + fileBytes, + ); + if (divergence !== null) return withheld(divergence); + const dllName = dll.name as string; + const library = stripImportExtension(dllName); + for (const imported of dll.functions) { + const functionName = + imported.name ?? pefileOrdinalName(dllName, imported.ordinal as number); + parts.push(`${library}.${functionName.toLowerCase()}`); + } + } + return { + algorithm: "pefile", + md5: createHash("md5").update(parts.join(","), "utf8").digest("hex"), + reason: null, + }; +}; diff --git a/src/native/pe/pefileOrdinalNames.ts b/src/native/pe/pefileOrdinalNames.ts new file mode 100644 index 0000000000..46c9307916 --- /dev/null +++ b/src/native/pe/pefileOrdinalNames.ts @@ -0,0 +1,713 @@ +/** + * Derived from pefile (c) 2004-2024 Ero Carrera, MIT. + * Retain licenses/pefile-2024.8.26-LICENSE.txt when redistributing these tables. + * Ordinal-to-name tables pefile 2024.8.26 consults in `ordlookup.ords` for + * its import hash. Ordinals missing here hash as `ord`, matching + * `ordlookup.formatOrdString`. Regenerate from pefile when it changes. + */ +export const PEFILE_ORDINAL_NAMES: Readonly< + Record> +> = { + "oleaut32.dll": new Map([ + [2, "SysAllocString"], + [3, "SysReAllocString"], + [4, "SysAllocStringLen"], + [5, "SysReAllocStringLen"], + [6, "SysFreeString"], + [7, "SysStringLen"], + [8, "VariantInit"], + [9, "VariantClear"], + [10, "VariantCopy"], + [11, "VariantCopyInd"], + [12, "VariantChangeType"], + [13, "VariantTimeToDosDateTime"], + [14, "DosDateTimeToVariantTime"], + [15, "SafeArrayCreate"], + [16, "SafeArrayDestroy"], + [17, "SafeArrayGetDim"], + [18, "SafeArrayGetElemsize"], + [19, "SafeArrayGetUBound"], + [20, "SafeArrayGetLBound"], + [21, "SafeArrayLock"], + [22, "SafeArrayUnlock"], + [23, "SafeArrayAccessData"], + [24, "SafeArrayUnaccessData"], + [25, "SafeArrayGetElement"], + [26, "SafeArrayPutElement"], + [27, "SafeArrayCopy"], + [28, "DispGetParam"], + [29, "DispGetIDsOfNames"], + [30, "DispInvoke"], + [31, "CreateDispTypeInfo"], + [32, "CreateStdDispatch"], + [33, "RegisterActiveObject"], + [34, "RevokeActiveObject"], + [35, "GetActiveObject"], + [36, "SafeArrayAllocDescriptor"], + [37, "SafeArrayAllocData"], + [38, "SafeArrayDestroyDescriptor"], + [39, "SafeArrayDestroyData"], + [40, "SafeArrayRedim"], + [41, "SafeArrayAllocDescriptorEx"], + [42, "SafeArrayCreateEx"], + [43, "SafeArrayCreateVectorEx"], + [44, "SafeArraySetRecordInfo"], + [45, "SafeArrayGetRecordInfo"], + [46, "VarParseNumFromStr"], + [47, "VarNumFromParseNum"], + [48, "VarI2FromUI1"], + [49, "VarI2FromI4"], + [50, "VarI2FromR4"], + [51, "VarI2FromR8"], + [52, "VarI2FromCy"], + [53, "VarI2FromDate"], + [54, "VarI2FromStr"], + [55, "VarI2FromDisp"], + [56, "VarI2FromBool"], + [57, "SafeArraySetIID"], + [58, "VarI4FromUI1"], + [59, "VarI4FromI2"], + [60, "VarI4FromR4"], + [61, "VarI4FromR8"], + [62, "VarI4FromCy"], + [63, "VarI4FromDate"], + [64, "VarI4FromStr"], + [65, "VarI4FromDisp"], + [66, "VarI4FromBool"], + [67, "SafeArrayGetIID"], + [68, "VarR4FromUI1"], + [69, "VarR4FromI2"], + [70, "VarR4FromI4"], + [71, "VarR4FromR8"], + [72, "VarR4FromCy"], + [73, "VarR4FromDate"], + [74, "VarR4FromStr"], + [75, "VarR4FromDisp"], + [76, "VarR4FromBool"], + [77, "SafeArrayGetVartype"], + [78, "VarR8FromUI1"], + [79, "VarR8FromI2"], + [80, "VarR8FromI4"], + [81, "VarR8FromR4"], + [82, "VarR8FromCy"], + [83, "VarR8FromDate"], + [84, "VarR8FromStr"], + [85, "VarR8FromDisp"], + [86, "VarR8FromBool"], + [87, "VarFormat"], + [88, "VarDateFromUI1"], + [89, "VarDateFromI2"], + [90, "VarDateFromI4"], + [91, "VarDateFromR4"], + [92, "VarDateFromR8"], + [93, "VarDateFromCy"], + [94, "VarDateFromStr"], + [95, "VarDateFromDisp"], + [96, "VarDateFromBool"], + [97, "VarFormatDateTime"], + [98, "VarCyFromUI1"], + [99, "VarCyFromI2"], + [100, "VarCyFromI4"], + [101, "VarCyFromR4"], + [102, "VarCyFromR8"], + [103, "VarCyFromDate"], + [104, "VarCyFromStr"], + [105, "VarCyFromDisp"], + [106, "VarCyFromBool"], + [107, "VarFormatNumber"], + [108, "VarBstrFromUI1"], + [109, "VarBstrFromI2"], + [110, "VarBstrFromI4"], + [111, "VarBstrFromR4"], + [112, "VarBstrFromR8"], + [113, "VarBstrFromCy"], + [114, "VarBstrFromDate"], + [115, "VarBstrFromDisp"], + [116, "VarBstrFromBool"], + [117, "VarFormatPercent"], + [118, "VarBoolFromUI1"], + [119, "VarBoolFromI2"], + [120, "VarBoolFromI4"], + [121, "VarBoolFromR4"], + [122, "VarBoolFromR8"], + [123, "VarBoolFromDate"], + [124, "VarBoolFromCy"], + [125, "VarBoolFromStr"], + [126, "VarBoolFromDisp"], + [127, "VarFormatCurrency"], + [128, "VarWeekdayName"], + [129, "VarMonthName"], + [130, "VarUI1FromI2"], + [131, "VarUI1FromI4"], + [132, "VarUI1FromR4"], + [133, "VarUI1FromR8"], + [134, "VarUI1FromCy"], + [135, "VarUI1FromDate"], + [136, "VarUI1FromStr"], + [137, "VarUI1FromDisp"], + [138, "VarUI1FromBool"], + [139, "VarFormatFromTokens"], + [140, "VarTokenizeFormatString"], + [141, "VarAdd"], + [142, "VarAnd"], + [143, "VarDiv"], + [144, "BSTR_UserFree64"], + [145, "BSTR_UserMarshal64"], + [146, "DispCallFunc"], + [147, "VariantChangeTypeEx"], + [148, "SafeArrayPtrOfIndex"], + [149, "SysStringByteLen"], + [150, "SysAllocStringByteLen"], + [151, "BSTR_UserSize64"], + [152, "VarEqv"], + [153, "VarIdiv"], + [154, "VarImp"], + [155, "VarMod"], + [156, "VarMul"], + [157, "VarOr"], + [158, "VarPow"], + [159, "VarSub"], + [160, "CreateTypeLib"], + [161, "LoadTypeLib"], + [162, "LoadRegTypeLib"], + [163, "RegisterTypeLib"], + [164, "QueryPathOfRegTypeLib"], + [165, "LHashValOfNameSys"], + [166, "LHashValOfNameSysA"], + [167, "VarXor"], + [168, "VarAbs"], + [169, "VarFix"], + [170, "OaBuildVersion"], + [171, "ClearCustData"], + [172, "VarInt"], + [173, "VarNeg"], + [174, "VarNot"], + [175, "VarRound"], + [176, "VarCmp"], + [177, "VarDecAdd"], + [178, "VarDecDiv"], + [179, "VarDecMul"], + [180, "CreateTypeLib2"], + [181, "VarDecSub"], + [182, "VarDecAbs"], + [183, "LoadTypeLibEx"], + [184, "SystemTimeToVariantTime"], + [185, "VariantTimeToSystemTime"], + [186, "UnRegisterTypeLib"], + [187, "VarDecFix"], + [188, "VarDecInt"], + [189, "VarDecNeg"], + [190, "VarDecFromUI1"], + [191, "VarDecFromI2"], + [192, "VarDecFromI4"], + [193, "VarDecFromR4"], + [194, "VarDecFromR8"], + [195, "VarDecFromDate"], + [196, "VarDecFromCy"], + [197, "VarDecFromStr"], + [198, "VarDecFromDisp"], + [199, "VarDecFromBool"], + [200, "GetErrorInfo"], + [201, "SetErrorInfo"], + [202, "CreateErrorInfo"], + [203, "VarDecRound"], + [204, "VarDecCmp"], + [205, "VarI2FromI1"], + [206, "VarI2FromUI2"], + [207, "VarI2FromUI4"], + [208, "VarI2FromDec"], + [209, "VarI4FromI1"], + [210, "VarI4FromUI2"], + [211, "VarI4FromUI4"], + [212, "VarI4FromDec"], + [213, "VarR4FromI1"], + [214, "VarR4FromUI2"], + [215, "VarR4FromUI4"], + [216, "VarR4FromDec"], + [217, "VarR8FromI1"], + [218, "VarR8FromUI2"], + [219, "VarR8FromUI4"], + [220, "VarR8FromDec"], + [221, "VarDateFromI1"], + [222, "VarDateFromUI2"], + [223, "VarDateFromUI4"], + [224, "VarDateFromDec"], + [225, "VarCyFromI1"], + [226, "VarCyFromUI2"], + [227, "VarCyFromUI4"], + [228, "VarCyFromDec"], + [229, "VarBstrFromI1"], + [230, "VarBstrFromUI2"], + [231, "VarBstrFromUI4"], + [232, "VarBstrFromDec"], + [233, "VarBoolFromI1"], + [234, "VarBoolFromUI2"], + [235, "VarBoolFromUI4"], + [236, "VarBoolFromDec"], + [237, "VarUI1FromI1"], + [238, "VarUI1FromUI2"], + [239, "VarUI1FromUI4"], + [240, "VarUI1FromDec"], + [241, "VarDecFromI1"], + [242, "VarDecFromUI2"], + [243, "VarDecFromUI4"], + [244, "VarI1FromUI1"], + [245, "VarI1FromI2"], + [246, "VarI1FromI4"], + [247, "VarI1FromR4"], + [248, "VarI1FromR8"], + [249, "VarI1FromDate"], + [250, "VarI1FromCy"], + [251, "VarI1FromStr"], + [252, "VarI1FromDisp"], + [253, "VarI1FromBool"], + [254, "VarI1FromUI2"], + [255, "VarI1FromUI4"], + [256, "VarI1FromDec"], + [257, "VarUI2FromUI1"], + [258, "VarUI2FromI2"], + [259, "VarUI2FromI4"], + [260, "VarUI2FromR4"], + [261, "VarUI2FromR8"], + [262, "VarUI2FromDate"], + [263, "VarUI2FromCy"], + [264, "VarUI2FromStr"], + [265, "VarUI2FromDisp"], + [266, "VarUI2FromBool"], + [267, "VarUI2FromI1"], + [268, "VarUI2FromUI4"], + [269, "VarUI2FromDec"], + [270, "VarUI4FromUI1"], + [271, "VarUI4FromI2"], + [272, "VarUI4FromI4"], + [273, "VarUI4FromR4"], + [274, "VarUI4FromR8"], + [275, "VarUI4FromDate"], + [276, "VarUI4FromCy"], + [277, "VarUI4FromStr"], + [278, "VarUI4FromDisp"], + [279, "VarUI4FromBool"], + [280, "VarUI4FromI1"], + [281, "VarUI4FromUI2"], + [282, "VarUI4FromDec"], + [283, "BSTR_UserSize"], + [284, "BSTR_UserMarshal"], + [285, "BSTR_UserUnmarshal"], + [286, "BSTR_UserFree"], + [287, "VARIANT_UserSize"], + [288, "VARIANT_UserMarshal"], + [289, "VARIANT_UserUnmarshal"], + [290, "VARIANT_UserFree"], + [291, "LPSAFEARRAY_UserSize"], + [292, "LPSAFEARRAY_UserMarshal"], + [293, "LPSAFEARRAY_UserUnmarshal"], + [294, "LPSAFEARRAY_UserFree"], + [295, "LPSAFEARRAY_Size"], + [296, "LPSAFEARRAY_Marshal"], + [297, "LPSAFEARRAY_Unmarshal"], + [298, "VarDecCmpR8"], + [299, "VarCyAdd"], + [300, "BSTR_UserUnmarshal64"], + [301, "DllCanUnloadNow"], + [302, "DllGetClassObject"], + [303, "VarCyMul"], + [304, "VarCyMulI4"], + [305, "VarCySub"], + [306, "VarCyAbs"], + [307, "VarCyFix"], + [308, "VarCyInt"], + [309, "VarCyNeg"], + [310, "VarCyRound"], + [311, "VarCyCmp"], + [312, "VarCyCmpR8"], + [313, "VarBstrCat"], + [314, "VarBstrCmp"], + [315, "VarR8Pow"], + [316, "VarR4CmpR8"], + [317, "VarR8Round"], + [318, "VarCat"], + [319, "VarDateFromUdateEx"], + [320, "DllRegisterServer"], + [321, "DllUnregisterServer"], + [322, "GetRecordInfoFromGuids"], + [323, "GetRecordInfoFromTypeInfo"], + [324, "LPSAFEARRAY_UserFree64"], + [325, "SetVarConversionLocaleSetting"], + [326, "GetVarConversionLocaleSetting"], + [327, "SetOaNoCache"], + [328, "LPSAFEARRAY_UserMarshal64"], + [329, "VarCyMulI8"], + [330, "VarDateFromUdate"], + [331, "VarUdateFromDate"], + [332, "GetAltMonthNames"], + [333, "VarI8FromUI1"], + [334, "VarI8FromI2"], + [335, "VarI8FromR4"], + [336, "VarI8FromR8"], + [337, "VarI8FromCy"], + [338, "VarI8FromDate"], + [339, "VarI8FromStr"], + [340, "VarI8FromDisp"], + [341, "VarI8FromBool"], + [342, "VarI8FromI1"], + [343, "VarI8FromUI2"], + [344, "VarI8FromUI4"], + [345, "VarI8FromDec"], + [346, "VarI2FromI8"], + [347, "VarI2FromUI8"], + [348, "VarI4FromI8"], + [349, "VarI4FromUI8"], + [350, "LPSAFEARRAY_UserSize64"], + [351, "LPSAFEARRAY_UserUnmarshal64"], + [352, "OACreateTypeLib2"], + [353, "SafeArrayAddRef"], + [354, "SafeArrayReleaseData"], + [355, "SafeArrayReleaseDescriptor"], + [356, "SysAddRefString"], + [357, "SysReleaseString"], + [358, "VARIANT_UserFree64"], + [359, "VARIANT_UserMarshal64"], + [360, "VarR4FromI8"], + [361, "VarR4FromUI8"], + [362, "VarR8FromI8"], + [363, "VarR8FromUI8"], + [364, "VarDateFromI8"], + [365, "VarDateFromUI8"], + [366, "VarCyFromI8"], + [367, "VarCyFromUI8"], + [368, "VarBstrFromI8"], + [369, "VarBstrFromUI8"], + [370, "VarBoolFromI8"], + [371, "VarBoolFromUI8"], + [372, "VarUI1FromI8"], + [373, "VarUI1FromUI8"], + [374, "VarDecFromI8"], + [375, "VarDecFromUI8"], + [376, "VarI1FromI8"], + [377, "VarI1FromUI8"], + [378, "VarUI2FromI8"], + [379, "VarUI2FromUI8"], + [380, "VARIANT_UserSize64"], + [381, "VARIANT_UserUnmarshal64"], + [401, "OleLoadPictureEx"], + [402, "OleLoadPictureFileEx"], + [411, "SafeArrayCreateVector"], + [412, "SafeArrayCopyData"], + [413, "VectorFromBstr"], + [414, "BstrFromVector"], + [415, "OleIconToCursor"], + [416, "OleCreatePropertyFrameIndirect"], + [417, "OleCreatePropertyFrame"], + [418, "OleLoadPicture"], + [419, "OleCreatePictureIndirect"], + [420, "OleCreateFontIndirect"], + [421, "OleTranslateColor"], + [422, "OleLoadPictureFile"], + [423, "OleSavePictureFile"], + [424, "OleLoadPicturePath"], + [425, "VarUI4FromI8"], + [426, "VarUI4FromUI8"], + [427, "VarI8FromUI8"], + [428, "VarUI8FromI8"], + [429, "VarUI8FromUI1"], + [430, "VarUI8FromI2"], + [431, "VarUI8FromR4"], + [432, "VarUI8FromR8"], + [433, "VarUI8FromCy"], + [434, "VarUI8FromDate"], + [435, "VarUI8FromStr"], + [436, "VarUI8FromDisp"], + [437, "VarUI8FromBool"], + [438, "VarUI8FromI1"], + [439, "VarUI8FromUI2"], + [440, "VarUI8FromUI4"], + [441, "VarUI8FromDec"], + [442, "RegisterTypeLibForUser"], + [443, "UnRegisterTypeLibForUser"], + [444, "OaEnablePerUserTLibRegistration"], + [445, "HWND_UserFree"], + [446, "HWND_UserMarshal"], + [447, "HWND_UserSize"], + [448, "HWND_UserUnmarshal"], + [449, "HWND_UserFree64"], + [450, "HWND_UserMarshal64"], + [451, "HWND_UserSize64"], + [452, "HWND_UserUnmarshal64"], + [500, "OACleanup"], + ]), + "ws2_32.dll": new Map([ + [1, "accept"], + [2, "bind"], + [3, "closesocket"], + [4, "connect"], + [5, "getpeername"], + [6, "getsockname"], + [7, "getsockopt"], + [8, "htonl"], + [9, "htons"], + [10, "ioctlsocket"], + [11, "inet_addr"], + [12, "inet_ntoa"], + [13, "listen"], + [14, "ntohl"], + [15, "ntohs"], + [16, "recv"], + [17, "recvfrom"], + [18, "select"], + [19, "send"], + [20, "sendto"], + [21, "setsockopt"], + [22, "shutdown"], + [23, "socket"], + [24, "WSApSetPostRoutine"], + [25, "FreeAddrInfoEx"], + [26, "FreeAddrInfoExW"], + [27, "FreeAddrInfoW"], + [28, "GetAddrInfoExA"], + [29, "GetAddrInfoExCancel"], + [30, "GetAddrInfoExOverlappedResult"], + [31, "GetAddrInfoExW"], + [32, "GetAddrInfoW"], + [33, "GetHostNameW"], + [34, "GetNameInfoW"], + [35, "InetNtopW"], + [36, "InetPtonW"], + [37, "ProcessSocketNotifications"], + [38, "SetAddrInfoExA"], + [39, "SetAddrInfoExW"], + [40, "WPUCompleteOverlappedRequest"], + [41, "WPUGetProviderPathEx"], + [42, "WSAAccept"], + [43, "WSAAddressToStringA"], + [44, "WSAAddressToStringW"], + [45, "WSAAdvertiseProvider"], + [46, "WSACloseEvent"], + [47, "WSAConnect"], + [48, "WSAConnectByList"], + [49, "WSAConnectByNameA"], + [50, "WSAConnectByNameW"], + [51, "gethostbyaddr"], + [52, "gethostbyname"], + [53, "getprotobyname"], + [54, "getprotobynumber"], + [55, "getservbyname"], + [56, "getservbyport"], + [57, "gethostname"], + [58, "WSACreateEvent"], + [59, "WSADuplicateSocketA"], + [60, "WSADuplicateSocketW"], + [61, "WSAEnumNameSpaceProvidersA"], + [62, "WSAEnumNameSpaceProvidersExA"], + [63, "WSAEnumNameSpaceProvidersExW"], + [64, "WSAEnumNameSpaceProvidersW"], + [65, "WSAEnumNetworkEvents"], + [66, "WSAEnumProtocolsA"], + [67, "WSAEnumProtocolsW"], + [68, "WSAEventSelect"], + [69, "WSAGetOverlappedResult"], + [70, "WSAGetQOSByName"], + [71, "WSAGetServiceClassInfoA"], + [72, "WSAGetServiceClassInfoW"], + [73, "WSAGetServiceClassNameByClassIdA"], + [74, "WSAGetServiceClassNameByClassIdW"], + [75, "WSAHtonl"], + [76, "WSAHtons"], + [77, "WSAInstallServiceClassA"], + [78, "WSAInstallServiceClassW"], + [79, "WSAIoctl"], + [80, "WSAJoinLeaf"], + [81, "WSALookupServiceBeginA"], + [82, "WSALookupServiceBeginW"], + [83, "WSALookupServiceEnd"], + [84, "WSALookupServiceNextA"], + [85, "WSALookupServiceNextW"], + [86, "WSANSPIoctl"], + [87, "WSANtohl"], + [88, "WSANtohs"], + [89, "WSAPoll"], + [90, "WSAProviderCompleteAsyncCall"], + [91, "WSAProviderConfigChange"], + [92, "WSARecv"], + [93, "WSARecvDisconnect"], + [94, "WSARecvFrom"], + [95, "WSARemoveServiceClass"], + [96, "WSAResetEvent"], + [97, "WSASend"], + [98, "WSASendDisconnect"], + [99, "WSASendMsg"], + [100, "WSASendTo"], + [101, "WSAAsyncSelect"], + [102, "WSAAsyncGetHostByAddr"], + [103, "WSAAsyncGetHostByName"], + [104, "WSAAsyncGetProtoByNumber"], + [105, "WSAAsyncGetProtoByName"], + [106, "WSAAsyncGetServByPort"], + [107, "WSAAsyncGetServByName"], + [108, "WSACancelAsyncRequest"], + [109, "WSASetBlockingHook"], + [110, "WSAUnhookBlockingHook"], + [111, "WSAGetLastError"], + [112, "WSASetLastError"], + [113, "WSACancelBlockingCall"], + [114, "WSAIsBlocking"], + [115, "WSAStartup"], + [116, "WSACleanup"], + [117, "WSASetEvent"], + [118, "WSASetServiceA"], + [119, "WSASetServiceW"], + [120, "WSASocketA"], + [121, "WSASocketW"], + [122, "WSAStringToAddressA"], + [123, "WSAStringToAddressW"], + [124, "WSAUnadvertiseProvider"], + [125, "WSAWaitForMultipleEvents"], + [126, "WSCDeinstallProvider"], + [127, "WSCDeinstallProvider32"], + [128, "WSCDeinstallProviderEx"], + [129, "WSCEnableNSProvider"], + [130, "WSCEnableNSProvider32"], + [131, "WSCEnumNameSpaceProviders32"], + [132, "WSCEnumNameSpaceProvidersEx32"], + [133, "WSCEnumProtocols"], + [134, "WSCEnumProtocols32"], + [135, "WSCEnumProtocolsEx"], + [136, "WSCGetApplicationCategory"], + [137, "WSCGetApplicationCategoryEx"], + [138, "WSCGetProviderInfo"], + [139, "WSCGetProviderInfo32"], + [140, "WSCGetProviderPath"], + [141, "WSCGetProviderPath32"], + [142, "WSCInstallNameSpace"], + [143, "WSCInstallNameSpace32"], + [144, "WSCInstallNameSpaceEx"], + [145, "WSCInstallNameSpaceEx2"], + [146, "WSCInstallNameSpaceEx32"], + [147, "WSCInstallProvider"], + [148, "WSCInstallProvider64_32"], + [149, "WSCInstallProviderAndChains64_32"], + [150, "WSCInstallProviderEx"], + [151, "__WSAFDIsSet"], + [152, "WSCSetApplicationCategory"], + [153, "WSCSetApplicationCategoryEx"], + [154, "WSCSetProviderInfo"], + [155, "WSCSetProviderInfo32"], + [156, "WSCUnInstallNameSpace"], + [157, "WSCUnInstallNameSpace32"], + [158, "WSCUnInstallNameSpaceEx2"], + [159, "WSCUpdateProvider"], + [160, "WSCUpdateProvider32"], + [161, "WSCUpdateProviderEx"], + [162, "WSCWriteNameSpaceOrder"], + [163, "WSCWriteNameSpaceOrder32"], + [164, "WSCWriteProviderOrder"], + [165, "WSCWriteProviderOrder32"], + [166, "WSCWriteProviderOrderEx"], + [167, "WahCloseApcHelper"], + [168, "WahCloseHandleHelper"], + [169, "WahCloseNotificationHandleHelper"], + [170, "WahCloseSocketHandle"], + [171, "WahCloseThread"], + [172, "WahCompleteRequest"], + [173, "WahCreateHandleContextTable"], + [174, "WahCreateNotificationHandle"], + [175, "WahCreateSocketHandle"], + [176, "WahDestroyHandleContextTable"], + [177, "WahDisableNonIFSHandleSupport"], + [178, "WahEnableNonIFSHandleSupport"], + [179, "WahEnumerateHandleContexts"], + [180, "WahInsertHandleContext"], + [181, "WahNotifyAllProcesses"], + [182, "WahOpenApcHelper"], + [183, "WahOpenCurrentThread"], + [184, "WahOpenHandleHelper"], + [185, "WahOpenNotificationHandleHelper"], + [186, "WahQueueUserApc"], + [187, "WahReferenceContextByHandle"], + [188, "WahRemoveHandleContext"], + [189, "WahWaitForNotification"], + [190, "WahWriteLSPEvent"], + [191, "freeaddrinfo"], + [192, "getaddrinfo"], + [193, "getnameinfo"], + [194, "inet_ntop"], + [195, "inet_pton"], + [500, "WEP"], + ]), + "wsock32.dll": new Map([ + [1, "accept"], + [2, "bind"], + [3, "closesocket"], + [4, "connect"], + [5, "getpeername"], + [6, "getsockname"], + [7, "getsockopt"], + [8, "htonl"], + [9, "htons"], + [10, "inet_addr"], + [11, "inet_ntoa"], + [12, "ioctlsocket"], + [13, "listen"], + [14, "ntohl"], + [15, "ntohs"], + [16, "recv"], + [17, "recvfrom"], + [18, "select"], + [19, "send"], + [20, "sendto"], + [21, "setsockopt"], + [22, "shutdown"], + [23, "socket"], + [24, "MigrateWinsockConfiguration"], + [51, "gethostbyaddr"], + [52, "gethostbyname"], + [53, "getprotobyname"], + [54, "getprotobynumber"], + [55, "getservbyname"], + [56, "getservbyport"], + [57, "gethostname"], + [101, "WSAAsyncSelect"], + [102, "WSAAsyncGetHostByAddr"], + [103, "WSAAsyncGetHostByName"], + [104, "WSAAsyncGetProtoByNumber"], + [105, "WSAAsyncGetProtoByName"], + [106, "WSAAsyncGetServByPort"], + [107, "WSAAsyncGetServByName"], + [108, "WSACancelAsyncRequest"], + [109, "WSASetBlockingHook"], + [110, "WSAUnhookBlockingHook"], + [111, "WSAGetLastError"], + [112, "WSASetLastError"], + [113, "WSACancelBlockingCall"], + [114, "WSAIsBlocking"], + [115, "WSAStartup"], + [116, "WSACleanup"], + [151, "__WSAFDIsSet"], + [500, "WEP"], + [1000, "WSApSetPostRoutine"], + [1100, "inet_network"], + [1101, "getnetbyname"], + [1102, "rcmd"], + [1103, "rexec"], + [1104, "rresvport"], + [1105, "sethostname"], + [1106, "dn_expand"], + [1107, "WSARecvEx"], + [1108, "s_perror"], + [1109, "GetAddressByNameA"], + [1110, "GetAddressByNameW"], + [1111, "EnumProtocolsA"], + [1112, "EnumProtocolsW"], + [1113, "GetTypeByNameA"], + [1114, "GetTypeByNameW"], + [1115, "GetNameByTypeA"], + [1116, "GetNameByTypeW"], + [1117, "SetServiceA"], + [1118, "SetServiceW"], + [1119, "GetServiceA"], + [1120, "GetServiceW"], + [1130, "NPLoadNameSpaces"], + [1140, "TransmitFile"], + [1141, "AcceptEx"], + [1142, "GetAcceptExSockaddrs"], + ]), +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 1bf0b8938b..7f274b8296 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -25,15 +25,18 @@ import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createElfNotesService, createPeCertificatesService, + createPeImportsService, createNativeExportsService, createPeResourcesService, } from "../composition/binaryDiagnostics.js"; import type { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import type { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js"; +import type { PeImportsService } from "../application/binaryDiagnostics/PeImportsService.js"; import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import type { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { registerElfNotesTool } from "./registerElfNotesTool.js"; import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js"; +import { registerPeImportsTool } from "./registerPeImportsTool.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import { registerNativeExportsTool } from "./registerNativeExportsTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; @@ -468,6 +471,7 @@ export const createServer = ( const nativeExports = createNativeExportsService(); const elfNotes = createElfNotesService(); const peCertificates = createPeCertificatesService(); + const peImports = createPeImportsService(); const electronFuses = options.electronFuses ?? createElectronFuseService(); const pythonPyc = options.pythonPyc ?? createPythonPycService(); const webNetworkCapture = @@ -579,6 +583,7 @@ export const createServer = ( nativeExports.close(), elfNotes.close(), peCertificates.close(), + peImports.close(), electronFuses.close(), pythonPyc.close(), webNetworkCapture.close(), @@ -647,6 +652,7 @@ export const createServer = ( nativeExports, elfNotes, peCertificates, + peImports, electronFuses, pythonPyc, webNetworkCapture, @@ -884,6 +890,7 @@ const registerConfiguredAnalysisTools = ( nativeExports, elfNotes, peCertificates, + peImports, pythonPyc, webNetworkCapture, artifactResources, @@ -957,6 +964,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerPeImportsTool( + server, + peImports, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerGoTools( server, goBinary, @@ -1113,6 +1127,7 @@ interface ServerToolContext extends ReturnType { readonly nativeExports: NativeExportsService; readonly elfNotes: ElfNotesService; readonly peCertificates: PeCertificatesService; + readonly peImports: PeImportsService; readonly electronFuses: ElectronFuseService; readonly pythonPyc: PythonPycService; readonly webNetworkCapture: WebNetworkCaptureService; diff --git a/src/server/registerPeImportsTool.ts b/src/server/registerPeImportsTool.ts new file mode 100644 index 0000000000..1274957943 --- /dev/null +++ b/src/server/registerPeImportsTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { PeImportsService } from "../application/binaryDiagnostics/PeImportsService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file PE import-table workflow and retain its complete Evidence. */ +export const registerPeImportsTool = ( + server: EvidenceMcpServer, + service: PeImportsService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_pe_imports"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/peImportsCli.test.ts b/tests/acceptance/peImportsCli.test.ts new file mode 100644 index 0000000000..84c3fcfe2f --- /dev/null +++ b/tests/acceptance/peImportsCli.test.ts @@ -0,0 +1,191 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + peImportImage, + peImportFixtureOffset, + peImportLeadingSectionImage, + peImportManySectionsImage, + peImportDirectoryAtRva, + peImportDirectoryMappingStops, + peWithoutImportTableFixture, +} from "../fixtures/binaryDiagnostics/peImports.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest("reports PE import names through the public CLI", async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-imports-cli-"); + const path = join(root, "app.exe"); + await writeFile(path, peImportImage()); + const result = await cli.run({ + arguments: ["inspect-pe-imports", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_pe_imports").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.imports[0]?.name).toBe("KERNEL32.dll"); + expect(evidence.normalized_result.imphash.md5).toBe( + "92cef6cc91a85825f3c8a0516517c66f", + ); +}); + +cliTest( + "resolves a relative PE path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-imports-cli-rel-"); + await writeFile(join(root, "unsigned.exe"), peWithoutImportTableFixture()); + const result = await cli.run({ + arguments: ["inspect-pe-imports", "unsigned.exe", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_pe_imports").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.import_directory).toBeNull(); + expect(evidence.subject?.local_path).toBe(join(root, "unsigned.exe")); + }, +); + +cliTest("reports a truncated PE as invalid input", async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-imports-cli-bad-"); + const path = join(root, "broken.exe"); + await writeFile(path, Buffer.from("MZ")); + const result = await cli.run({ + arguments: ["inspect-pe-imports", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_pe_imports" }, + }); +}); + +cliTest( + "preserves directory declarations and partial rows at RVA and mapping boundaries", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-pe-imports-cli-directory-rva-", + ); + for (const [kind, rva, retained, mappingGap] of [ + ["standard", 0xffffffec, 1, false], + ["standard", 0xfffffff0, 0, false], + ["delay", 0xffffffe0, 1, false], + ["delay", 0xfffffff0, 0, false], + ["standard", 0x40000000, 0, true], + ["standard", 0x40000000, 1, true], + ["delay", 0x40000000, 0, true], + ["delay", 0x40000000, 1, true], + ] as const) { + const path = join(root, `${kind}-${String(rva)}-${String(retained)}.exe`); + await writeFile( + path, + mappingGap + ? peImportDirectoryMappingStops(kind, retained) + : peImportDirectoryAtRva(kind, rva), + ); + const result = await cli.run({ + arguments: ["inspect-pe-imports", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = toolContract("inspect_pe_imports").outputSchema.parse( + result.json, + ).normalized_result; + const directory = + kind === "standard" + ? report.import_directory + : report.delay_import_directory; + const rows = kind === "standard" ? report.imports : report.delay_imports; + expect(directory).toMatchObject({ rva }); + expect(directory?.locations).toHaveLength(retained); + expect(rows).toHaveLength(retained); + expect(report.coverage.status).toBe("partial"); + expect(report.imphash.md5).toBe( + kind === "delay" ? "92cef6cc91a85825f3c8a0516517c66f" : null, + ); + } + }, +); + +cliTest( + "retains a partial inventory when an IAT RVA overflows", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-imports-cli-overflow-"); + for (const bits of [32, 64] as const) { + const bytes = peImportImage({ bits }); + bytes.writeUInt32LE( + 0x100000000 - bits / 8, + peImportFixtureOffset(0x1000) + 16, + ); + const path = join(root, `app-${String(bits)}.exe`); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-pe-imports", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = toolContract("inspect_pe_imports").outputSchema.parse( + result.json, + ).normalized_result; + expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([ + "GetProcAddress", + ]); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "malformed_entry", + location: "0x100000000", + }), + ]), + ); + expect(report.imphash.md5).toBeNull(); + } + }, +); + +cliTest( + "keeps complete facts when pefile cannot see the same import table", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-pe-imports-cli-compatibility-", + ); + const functions = Array.from({ length: 140 }, (_, i) => ({ + name: `Function${String(i).padStart(3, "0")}`, + })); + const cases = [ + [ + "cross-section", + peImportImage({ + sectionBytes: 0x4000, + splitAt: 0x1000, + dlls: [{ name: "KERNEL32.dll", functions }], + }), + 140, + ], + ["section-cap", peImportManySectionsImage(2049, 2), 2], + ["null-section", peImportLeadingSectionImage(false), 2], + ] as const; + for (const [name, bytes, count] of cases) { + const path = join(root, `${name}.exe`); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-pe-imports", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = toolContract("inspect_pe_imports").outputSchema.parse( + result.json, + ).normalized_result; + expect(report.imports[0]?.name).toBe("KERNEL32.dll"); + expect(report.imports[0]?.functions).toHaveLength(count); + expect(report.coverage.status).toBe("complete"); + expect(report.imphash).toMatchObject({ + md5: null, + reason: expect.stringMatching(/mapping|compatibility/u), + }); + if (name === "cross-section") + expect(report.imports[0]?.functions[128]?.name).toBe("Function128"); + } + }, +); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 6cbb5d9c93..acf88f37e0 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -219,6 +219,7 @@ const pathRequests: readonly [string, Record][] = [ ], ["inspect_pe_resources", { path: nul }], ["inspect_pe_certificates", { path: nul }], + ["inspect_pe_imports", { path: nul }], ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], ["inspect_python_pyc", { path: nul }], diff --git a/tests/boundary/filesystem/peImports.test.ts b/tests/boundary/filesystem/peImports.test.ts new file mode 100644 index 0000000000..9c79798e35 --- /dev/null +++ b/tests/boundary/filesystem/peImports.test.ts @@ -0,0 +1,108 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createPeImportsService } from "../../../src/composition/binaryDiagnostics.js"; +import { peImportImage } from "../../fixtures/binaryDiagnostics/peImports.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("PE import stable filesystem boundary", () => { + it("reports non-file and malformed PE selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-pe-imports-files-"); + const path = join(root, "app.exe"); + const alias = join(root, "alias.exe"); + const malformed = join(root, "malformed.exe"); + await writeFile(path, peImportImage()); + await writeFile(malformed, Buffer.from("not a pe")); + await symlink(path, alias); + const service = createPeImportsService(); + for (const [candidate, reason] of [ + [root, "invalid_format"], + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing.exe"), "invalid_value"], + [join(path, "child.exe"), "invalid_value"], + ] as const) { + const result = await service.inspect({ path: candidate }); + if (result.ok) throw new Error("Expected file acquisition failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_pe_imports", + issues: [ + { + path: ["path"], + reason, + message: expect.any(String), + }, + ], + }); + } + }); + + it("returns typed resource and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-pe-imports-budget-"); + const path = join(root, "app.exe"); + await writeFile(path, peImportImage()); + const service = createPeImportsService(); + const tooSmall = await service.inspect({ path, max_file_bytes: 64 }); + if (tooSmall.ok) throw new Error("Expected resource budget failure"); + expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError"); + const cancelled = await service.inspect( + { path }, + { signal: AbortSignal.abort() }, + ); + if (cancelled.ok) throw new Error("Expected cancellation"); + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + }); + + it("derives limit recovery from the parser's structured limit tag", async () => { + const root = await createTestTempDirectory("rea-pe-imports-limits-"); + const twoDlls = join(root, "two.exe"); + const longNames = join(root, "names.exe"); + await writeFile( + twoDlls, + peImportImage({ + dlls: [ + { name: "A.dll", functions: [{ name: "a" }] }, + { name: "B.dll", functions: [{ name: "b" }] }, + ], + }), + ); + const name = "A".repeat(4096); + await writeFile( + longNames, + peImportImage({ + sectionBytes: 0x1200000, + dlls: [ + { + name: "KERNEL32.dll", + functions: Array.from({ length: 4097 }, () => ({ name })), + }, + ], + }), + ); + const service = createPeImportsService(); + const dlls = await service.inspect({ path: twoDlls, max_dlls: 1 }); + if (dlls.ok) throw new Error("Expected DLL budget failure"); + expect(dlls.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reportedLimits: { limit: "dlls", maximum: 1 }, + remediationAction: expect.stringContaining("Raise max_dlls above 1"), + retryable: false, + }); + const text = await service.inspect({ + path: longNames, + max_file_bytes: 536870912, + }); + if (text.ok) throw new Error("Expected retained-text failure"); + expect(text.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reportedLimits: { limit: "retained_text", maximum: 16777216 }, + remediationAction: undefined, + fixedAllowance: { + name: expect.stringContaining("16777216-byte PE import retained-text"), + kind: "bytes", + }, + retryable: false, + }); + }); +}); diff --git a/tests/boundary/mcp/peImportsMcp.test.ts b/tests/boundary/mcp/peImportsMcp.test.ts new file mode 100644 index 0000000000..c4efbc4f8d --- /dev/null +++ b/tests/boundary/mcp/peImportsMcp.test.ts @@ -0,0 +1,105 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { + peImportImage, + peImportDirectoryAtRva, + peImportDirectoryMappingStops, +} from "../../fixtures/binaryDiagnostics/peImports.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; + +const connect = async (mcp: TestMcp) => { + const session = createTestBinarySession(() => { + throw new Error("PE import inspection must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_pe_imports and reads named imports", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const { + toolDefinition: advertised, + ajv, + outputSchema, + } = await advertisedMcpToolSchemas(client, "inspect_pe_imports"); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-pe-imports-mcp-"); + const path = join(root, "app.exe"); + await writeFile(path, peImportImage({ bits: 64 })); + const response = await client.callTool({ + name: "inspect_pe_imports", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = toolContract("inspect_pe_imports").outputSchema.parse( + response.structuredContent, + ); + expect(evidence.normalized_result.format).toBe("pe32-plus"); + expect(evidence.normalized_result.imports[0]?.name).toBe("KERNEL32.dll"); + expect( + evidence.normalized_result.imports[0]?.functions.map((row) => row.name), + ).toEqual(["GetProcAddress", "ExitProcess"]); + for (const [label, bytes, rva] of [ + ["rva", peImportDirectoryAtRva("standard", 0xfffffff0), 0xfffffff0], + ["mapping", peImportDirectoryMappingStops("standard", 0), 0x40000000], + ] as const) { + const partialPath = join(root, `invalid-first-descriptor-${label}.exe`); + await writeFile(partialPath, bytes); + const partial = await client.callTool({ + name: "inspect_pe_imports", + arguments: { path: partialPath }, + }); + expect(partial.isError, JSON.stringify(partial.content)).not.toBe(true); + expect( + ajv.validate(outputSchema, partial.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const partialReport = toolContract( + "inspect_pe_imports", + ).outputSchema.parse(partial.structuredContent).normalized_result; + expect(partialReport.import_directory).toMatchObject({ + rva, + locations: [], + }); + expect(partialReport.coverage.status).toBe("partial"); + expect(partialReport.imphash.md5).toBeNull(); + } + } finally { + await session.close(); + } + }, +); + +mcpTest("requires an absolute path for inspect_pe_imports", async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_pe_imports", + arguments: { path: "app.exe" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_pe_imports" }, + }); + } finally { + await session.close(); + } +}); diff --git a/tests/fixtures/binaryDiagnostics/peImports.ts b/tests/fixtures/binaryDiagnostics/peImports.ts new file mode 100644 index 0000000000..bdbdce47b2 --- /dev/null +++ b/tests/fixtures/binaryDiagnostics/peImports.ts @@ -0,0 +1,314 @@ +const SECTION_RVA = 0x1000; +const SECTION_OFFSET = 0x400; +const SECTION_BYTES = 0x800; +const HEADER_BYTES = 0x400; +const SPLIT_GAP_BYTES = 0x200; + +/** File offset of an RVA in an unsplit fixture image. */ +export const peImportFixtureOffset = (rva: number): number => + rva - SECTION_RVA + SECTION_OFFSET; + +export type PeImportFixtureFunction = + | { readonly name: string; readonly hint?: number } + | { readonly ordinal: number }; + +export interface PeImportFixtureDll { + readonly name: string; + readonly functions: readonly PeImportFixtureFunction[]; +} + +/** + * Minimal PE32/PE32+ image whose `.idata` section holds IMAGE_IMPORT_DESCRIPTOR + * and optional IMAGE_DELAYLOAD_DESCRIPTOR tables at RVA 0x1000. + * + * `splitAt` keeps the RVA layout but moves section bytes from that section + * offset onward into a second section at a later, discontiguous file offset, + * filling the vacated file bytes with 0xcc. + */ +export const peImportImage = ( + options: { + readonly bits?: 32 | 64; + readonly dlls?: readonly PeImportFixtureDll[]; + readonly delayDlls?: readonly PeImportFixtureDll[]; + readonly delayAttributes?: number; + readonly importDirectory?: boolean; + readonly delayDirectory?: boolean; + readonly sectionBytes?: number; + readonly splitAt?: number; + } = {}, +): Buffer => { + const sectionBytes = options.sectionBytes ?? SECTION_BYTES; + const bits = options.bits ?? 64; + const dlls = options.dlls ?? [ + { + name: "KERNEL32.dll", + functions: [ + { name: "GetProcAddress", hint: 1 }, + { name: "ExitProcess", hint: 2 }, + ], + }, + ]; + const delayDlls = options.delayDlls ?? []; + const bytes = Buffer.alloc(SECTION_OFFSET + sectionBytes); + const wide = bits === 64; + const thunkSize = wide ? 8 : 4; + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt32LE(128, 60); + bytes.write("PE\0\0", 128, "ascii"); + bytes.writeUInt16LE(wide ? 0x8664 : 0x14c, 132); + bytes.writeUInt16LE(1, 134); + const directoriesAt = wide ? 112 : 96; + const optionalSize = directoriesAt + 16 * 8; + bytes.writeUInt16LE(optionalSize, 148); + const optional = 152; + bytes.writeUInt16LE(wide ? 0x20b : 0x10b, optional); + if (wide) bytes.writeBigUInt64LE(0x180000000n, optional + 24); + else bytes.writeUInt32LE(0x10000000, optional + 28); + bytes.writeUInt32LE(16, optional + directoriesAt - 4); + bytes.writeUInt32LE(HEADER_BYTES, optional + 60); + const section = optional + optionalSize; + bytes.write(".idata", section, "ascii"); + bytes.writeUInt32LE(sectionBytes, section + 8); + bytes.writeUInt32LE(SECTION_RVA, section + 12); + bytes.writeUInt32LE(sectionBytes, section + 16); + bytes.writeUInt32LE(SECTION_OFFSET, section + 20); + bytes.writeUInt32LE(0x40000040, section + 36); + + const at = (rva: number) => rva - SECTION_RVA + SECTION_OFFSET; + const writeThunk = (rva: number, value: bigint): void => { + if (wide) bytes.writeBigUInt64LE(value, at(rva)); + else bytes.writeUInt32LE(Number(value), at(rva)); + }; + let cursor = SECTION_RVA; + const place = (size: number): number => { + const rva = cursor; + cursor += size; + return rva; + }; + + const importCount = options.importDirectory === false ? 0 : dlls.length; + const delayCount = + options.delayDirectory === false || delayDlls.length === 0 + ? 0 + : delayDlls.length; + const importTable = importCount === 0 ? 0 : place((importCount + 1) * 20); + const delayTable = delayCount === 0 ? 0 : place((delayCount + 1) * 32); + + if (options.importDirectory !== false) { + bytes.writeUInt32LE(importTable, optional + directoriesAt + 8); + bytes.writeUInt32LE((importCount + 1) * 20, optional + directoriesAt + 12); + } + if (delayCount > 0) { + bytes.writeUInt32LE(delayTable, optional + directoriesAt + 13 * 8); + bytes.writeUInt32LE( + (delayCount + 1) * 32, + optional + directoriesAt + 13 * 8 + 4, + ); + } + + const writeFunctions = ( + functions: readonly PeImportFixtureFunction[], + ): { readonly ilt: number; readonly iat: number } => { + const ilt = place((functions.length + 1) * thunkSize); + const iat = place((functions.length + 1) * thunkSize); + functions.forEach((entry, index) => { + let thunk: bigint; + if ("ordinal" in entry) { + thunk = + (wide ? 0x8000000000000000n : 0x80000000n) | BigInt(entry.ordinal); + } else { + const hintName = place(2 + entry.name.length + 1); + bytes.writeUInt16LE(entry.hint ?? 0, at(hintName)); + bytes.write(`${entry.name}\0`, at(hintName) + 2, "latin1"); + thunk = BigInt(hintName); + } + writeThunk(ilt + index * thunkSize, thunk); + writeThunk(iat + index * thunkSize, thunk); + }); + return { ilt, iat }; + }; + + dlls.slice(0, importCount).forEach((dll, index) => { + const { ilt, iat } = writeFunctions(dll.functions); + const nameRva = place(dll.name.length + 1); + bytes.write(`${dll.name}\0`, at(nameRva), "latin1"); + const descriptor = at(importTable + index * 20); + bytes.writeUInt32LE(ilt, descriptor); + bytes.writeUInt32LE(nameRva, descriptor + 12); + bytes.writeUInt32LE(iat, descriptor + 16); + }); + + delayDlls.slice(0, delayCount).forEach((dll, index) => { + const { ilt, iat } = writeFunctions(dll.functions); + const nameRva = place(dll.name.length + 1); + bytes.write(`${dll.name}\0`, at(nameRva), "latin1"); + const descriptor = at(delayTable + index * 32); + bytes.writeUInt32LE(options.delayAttributes ?? 1, descriptor); + bytes.writeUInt32LE(nameRva, descriptor + 4); + bytes.writeUInt32LE(iat, descriptor + 12); + bytes.writeUInt32LE(ilt, descriptor + 16); + }); + + if (options.splitAt === undefined) return bytes; + const head = options.splitAt; + const tail = sectionBytes - head; + const moved = SECTION_OFFSET + sectionBytes + SPLIT_GAP_BYTES; + const split = Buffer.alloc(moved + tail); + bytes.copy(split, 0, 0, SECTION_OFFSET + head); + split.fill(0xcc, SECTION_OFFSET + head, moved); + bytes.copy(split, moved, SECTION_OFFSET + head); + split.writeUInt16LE(2, 134); + split.writeUInt32LE(head, section + 8); + split.writeUInt32LE(head, section + 16); + const second = section + 40; + split.write(".idata2", second, "ascii"); + split.writeUInt32LE(tail, second + 8); + split.writeUInt32LE(SECTION_RVA + head, second + 12); + split.writeUInt32LE(tail, second + 16); + split.writeUInt32LE(moved, second + 20); + split.writeUInt32LE(0x40000040, second + 36); + return split; +}; + +/** PE image whose optional header admits no import data directory. */ +export const peWithoutImportTableFixture = (bits: 32 | 64 = 64): Buffer => + peImportImage({ bits, importDirectory: false, delayDlls: [] }); + +/** Add a file-backed PE32+ range while preserving the ordinary low-RVA payload. */ +const withHighImportRange = ( + source: Buffer, + rva: number, + data: Buffer, +): Buffer => { + const offset = 0x1000; + const bytes = Buffer.alloc(offset + data.length); + source.copy(bytes); + data.copy(bytes, offset); + bytes.writeUInt16LE(2, 134); + const section = 152 + 240 + 40; + bytes.write(".high", section, "ascii"); + bytes.writeUInt32LE(data.length, section + 8); + bytes.writeUInt32LE(rva, section + 12); + bytes.writeUInt32LE(data.length, section + 16); + bytes.writeUInt32LE(offset, section + 20); + bytes.writeUInt32LE(0x40000040, section + 36); + return bytes; +}; + +/** Directory descriptor/terminator whose mapped range crosses the uint32 RVA end. */ +export const peImportDirectoryAtRva = ( + kind: "standard" | "delay", + rva: number, +): Buffer => { + const source = peImportImage({ + delayDlls: + kind === "delay" + ? [{ name: "ADVAPI32.dll", functions: [{ name: "RegOpenKeyExW" }] }] + : [], + }); + const width = kind === "standard" ? 20 : 32; + const directory = 152 + 112 + (kind === "standard" ? 1 : 13) * 8; + const oldOffset = peImportFixtureOffset(source.readUInt32LE(directory)); + const bytes = withHighImportRange( + source, + rva, + source.subarray(oldOffset, oldOffset + 2 * width), + ); + bytes.writeUInt32LE(rva, directory); + return bytes; +}; + +/** A directory starts in a gap, or its first descriptor is followed by a gap. */ +export const peImportDirectoryMappingStops = ( + kind: "standard" | "delay", + retained: 0 | 1, +): Buffer => { + const bytes = peImportDirectoryAtRva(kind, 0x40000000); + const section = 152 + 240 + 40; + const size = retained * (kind === "standard" ? 20 : 32); + bytes.writeUInt32LE(size, section + 8); + bytes.writeUInt32LE(size, section + 16); + return bytes; +}; + +/** A DLL name whose NUL lies outside the uint32 RVA space. */ +export const peImportNameAtRva = (rva: number): Buffer => { + const bytes = withHighImportRange( + peImportImage(), + rva, + Buffer.from("KERNEL32.dll\0", "latin1"), + ); + bytes.writeUInt32LE(rva, peImportFixtureOffset(0x1000) + 12); + return bytes; +}; + +/** Later import section behind an empty or suspicious first section header. */ +export const peImportLeadingSectionImage = (suspicious: boolean): Buffer => { + const bytes = peImportImage(); + const section = 152 + 240; + bytes.copy(bytes, section + 40, section, section + 40); + bytes.fill(0, section, section + 40); + bytes.writeUInt16LE(2, 134); + if (suspicious) { + bytes.writeUInt32LE(0x20000000, section + 8); + bytes.writeUInt32LE(0x20000000, section + 12); + bytes.writeUInt32LE(0xfffffe00, section + 20); + } + return bytes; +}; + +/** Independent PE32+ mappings with imports in the last section, for capacity checks. */ +export const peImportManySectionsImage = ( + sectionCount: number, + functionCount: number, +): Buffer => { + const rawSize = 0x20000; + const original = peImportImage({ + sectionBytes: rawSize, + dlls: [ + { + name: "KERNEL32.dll", + functions: Array.from({ length: functionCount }, (_, i) => ({ + name: `Function${String(i).padStart(4, "0")}`, + })), + }, + ], + }); + const optional = 152; + const sectionStart = optional + 240; + const headerBytes = Math.ceil((sectionStart + sectionCount * 40) / 512) * 512; + const finalRaw = headerBytes + (sectionCount - 1) * 512; + const firstRva = Math.ceil(headerBytes / 0x1000) * 0x1000; + const finalRva = firstRva + (sectionCount - 1) * 0x1000; + const delta = finalRva - SECTION_RVA; + const bytes = Buffer.alloc(finalRaw + rawSize); + original.copy(bytes, 0, 0, sectionStart); + bytes.writeUInt16LE(sectionCount, 134); + bytes.writeUInt32LE(0x1000, optional + 32); + bytes.writeUInt32LE(512, optional + 36); + bytes.writeUInt32LE(headerBytes, optional + 60); + bytes.writeUInt32LE(finalRva, optional + 112 + 8); + bytes.writeUInt32LE(finalRva + rawSize, optional + 56); + for (let i = 0; i < sectionCount; i++) { + const at = sectionStart + i * 40; + const size = i === sectionCount - 1 ? rawSize : 512; + bytes.write(i === sectionCount - 1 ? ".idata" : ".dummy", at, "ascii"); + bytes.writeUInt32LE(size, at + 8); + bytes.writeUInt32LE(firstRva + i * 0x1000, at + 12); + bytes.writeUInt32LE(size, at + 16); + bytes.writeUInt32LE(headerBytes + i * 512, at + 20); + bytes.writeUInt32LE(0x40000040, at + 36); + } + const data = Buffer.from(original.subarray(SECTION_OFFSET)); + const ilt = data.readUInt32LE(0); + const iat = data.readUInt32LE(16); + for (const field of [0, 12, 16]) + data.writeUInt32LE(data.readUInt32LE(field) + delta, field); + for (const table of [ilt, iat]) + for (let i = 0; i < functionCount; i++) { + const at = table - SECTION_RVA + i * 8; + data.writeBigUInt64LE(data.readBigUInt64LE(at) + BigInt(delta), at); + } + data.copy(bytes, finalRaw); + return bytes; +}; diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index ef6205e1b4..5ef18a45a1 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -97,10 +97,12 @@ import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js" import { Il2cppMetadataProvider } from "../../src/il2cpp/Il2cppMetadataProvider.js"; import { ElfNotesProvider } from "../../src/native/elf/ElfNotesProvider.js"; import { PeCertificatesProvider } from "../../src/native/pe/PeCertificatesProvider.js"; +import { PeImportsProvider } from "../../src/native/pe/PeImportsProvider.js"; import { NativeExportsProvider } from "../../src/native/exports/NativeExportsProvider.js"; import { BeamProvider } from "../../src/erlang/BeamProvider.js"; import { inspectElfNotesInputSchema } from "../../src/domain/native/elfNotes.js"; import { inspectPeCertificatesInputSchema } from "../../src/domain/native/peCertificates.js"; +import { inspectPeImportsInputSchema } from "../../src/domain/native/peImports.js"; import { inspectNativeExportsInputSchema } from "../../src/domain/native/nativeExports.js"; import { inspectErlangBeamInputSchema } from "../../src/domain/erlang/beam.js"; import { ModelArtifactProvider } from "../../src/model/ModelArtifactProvider.js"; @@ -855,6 +857,7 @@ const stableArtifactProviders = [ ...staticProviders, ["ELF notes", () => new ElfNotesProvider()], ["PE certificates", () => new PeCertificatesProvider()], + ["PE imports", () => new PeImportsProvider()], ["native exports", () => new NativeExportsProvider()], ["Python pyc", () => new PythonPycProvider()], ] as const; @@ -885,6 +888,11 @@ const inspectFileFailure = ( inspectPeCertificatesInputSchema.parse({ path }), options, ); + if (provider instanceof PeImportsProvider) + return provider.inspect( + inspectPeImportsInputSchema.parse({ path }), + options, + ); if (provider instanceof NativeExportsProvider) return provider.inspect( inspectNativeExportsInputSchema.parse({ path }),