From ca4ca65c5e581af71d63863a5b0bd3e1d17dd2c7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 22 Oct 2026 02:20:07 -0700 Subject: [PATCH] feat(pe): inspect Authenticode certificate tables (#4830) * feat(pe): inspect Authenticode certificate tables Add inspect_pe_certificates for IMAGE_DIRECTORY_ENTRY_SECURITY WIN_CERTIFICATE entries and structurally decoded PKCS#7 signer and X.509 identity fields. The workflow reads a stable PE image without executing it or verifying signatures, chains, timestamps, or Authenticode digest coverage. * fix(pe): bound and validate certificate DER decoding - Cap decoded DER values with a shared node budget so sibling fan-out cannot exhaust memory before the PKCS#7 check - Require SignedData version, encapContentInfo and a final signerInfos SET in RFC 5652 order, and a signature OCTET STRING in each SignerInfo - Decode OBJECT IDENTIFIER arcs as unbounded base-128 values, including a multi-byte first combined arc - Decode UniversalString names as UTF-32BE and reject invalid values - Share PE header admission with the resource layout so a section table that does not fit SizeOfHeaders is rejected * docs: list PE certificates in the translated READMEs * fix(pe): cap OBJECT IDENTIFIER length and arc size A crafted type-2 certificate with a 262,144-byte contentType OID stalled decodeOid for about 20 seconds, because one arc kept growing a BigInt before the signedData check ran. decodeOid now rejects an OID longer than 256 bytes and an arc longer than 32 bytes before decoding it. Certificate OIDs, including 2.25 UUID arcs, fit under both caps. * fix(pe): reject truncated certificate tables and PKCS#7 SignedData now requires one SignerInfo with a non-empty signature and a complete issuerAndSerialNumber. Certificate names share a 64 KiB decoded-byte budget and are copied once. A security directory that ends in a short WIN_CERTIFICATE header fails instead of reporting a complete table. The certificate CLI forwards the caller abort signal into cleanup. Co-authored-by: morluto * chore(ci): keep fork workflow files unchanged The certificate restack follows current main. Workflow files stay at the fork tip so this branch update does not rewrite .github/workflows. Co-authored-by: morluto * fix(pe): charge serial and validity text to the decoded budget Certificate and signer serial numbers and validity times now count against the shared decoded-text budget before they are converted, so a crafted certificate cannot push megabytes of text into the hashed evidence. The budget error now names certificate text rather than only names. Restore the CI workflow to match main: the CI_TIMING_TIER input, the tier-named timing artifacts and their publication conditions, the Windows typecheck, and the path and snapshot suites in the curated Windows lane. * fix(pe): require certificate fields and preserve signer key ids Reject X.509 records that omit subjectPublicKeyInfo or signatureValue, return subjectKeyIdentifier bytes as bounded hex, and advertise an absolute path in the PE certificate input schema. Co-authored-by: morluto * fix(pe): reject extra PKCS#7 wrappers and invalid UTF-8 names ContentInfo and its signedData wrapper no longer ignore trailing values, and directory strings must round-trip as UTF-8 instead of becoming U+FFFD. Co-authored-by: morluto --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: morluto Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 8 + .../references/native-and-artifacts.md | 14 + README.md | 1 + docs/.vitepress/config.ts | 1 + docs/cli.md | 12 + docs/index.md | 2 +- docs/pe-certificates.md | 54 ++ docs/readme/README_ar.md | 1 + docs/readme/README_de.md | 1 + docs/readme/README_el.md | 1 + docs/readme/README_es.md | 1 + docs/readme/README_fa.md | 1 + docs/readme/README_fr.md | 1 + docs/readme/README_id.md | 1 + docs/readme/README_it.md | 1 + docs/readme/README_ja.md | 1 + docs/readme/README_ko.md | 1 + docs/readme/README_ml.md | 1 + docs/readme/README_pl.md | 1 + docs/readme/README_pt-BR.md | 1 + docs/readme/README_ru.md | 1 + docs/readme/README_th.md | 1 + docs/readme/README_tr.md | 1 + docs/readme/README_uk.md | 1 + docs/readme/README_vi.md | 1 + docs/readme/README_zh-TW.md | 55 +- docs/readme/README_zh.md | 55 +- scripts/lib/catalog-builders.mjs | 7 + src/application/CapabilityInventory.ts | 2 + src/application/InvestigationProviders.ts | 7 + .../binaryDiagnostics/PeCertificatesPort.ts | 20 + .../PeCertificatesService.test.ts | 76 +++ .../PeCertificatesService.ts | 146 +++++ src/cli/binaryDiagnosticsCommands.ts | 35 + src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 6 + .../native/binaryDiagnosticsToolContracts.ts | 19 + src/contracts/toolEffects.test.ts | 1 + src/contracts/toolEffects.ts | 1 + src/domain/artifactOperationError.ts | 1 + src/domain/native/peCertificates.test.ts | 52 ++ src/domain/native/peCertificates.ts | 152 +++++ src/native/pe/PeCertificateDer.ts | 620 ++++++++++++++++++ src/native/pe/PeCertificateParser.test.ts | 530 +++++++++++++++ src/native/pe/PeCertificateParser.ts | 204 ++++++ src/native/pe/PeCertificates.fixture.ts | 156 +++++ src/native/pe/PeCertificatesProvider.ts | 201 ++++++ src/native/pe/PeResourceLayout.ts | 28 +- src/server/createServer.ts | 15 + src/server/registerPeCertificatesTool.ts | 31 + tests/acceptance/peCertificatesCli.test.ts | 64 ++ .../tools/localToolContracts.test.ts | 1 + .../filesystem/peCertificates.test.ts | 57 ++ tests/boundary/mcp/peCertificatesMcp.test.ts | 72 ++ 54 files changed, 2668 insertions(+), 57 deletions(-) create mode 100644 docs/pe-certificates.md create mode 100644 src/application/binaryDiagnostics/PeCertificatesPort.ts create mode 100644 src/application/binaryDiagnostics/PeCertificatesService.test.ts create mode 100644 src/application/binaryDiagnostics/PeCertificatesService.ts create mode 100644 src/domain/native/peCertificates.test.ts create mode 100644 src/domain/native/peCertificates.ts create mode 100644 src/native/pe/PeCertificateDer.ts create mode 100644 src/native/pe/PeCertificateParser.test.ts create mode 100644 src/native/pe/PeCertificateParser.ts create mode 100644 src/native/pe/PeCertificates.fixture.ts create mode 100644 src/native/pe/PeCertificatesProvider.ts create mode 100644 src/server/registerPeCertificatesTool.ts create mode 100644 tests/acceptance/peCertificatesCli.test.ts create mode 100644 tests/boundary/filesystem/peCertificates.test.ts create mode 100644 tests/boundary/mcp/peCertificatesMcp.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 8391d3704d..118b56af9c 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -219,6 +219,14 @@ explicit path or endpoint and do not need it. delivery requires `--evidence_output`. See the [Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md). +- PE Authenticode certificate table: `inspect_pe_certificates` with the explicit + local PE32/PE32+ file. This built-in target-free inspection requires no + Windows SDK. Preserve WIN_CERTIFICATE ranges, types, payload SHA-256, and + PKCS#7 signer/X.509 identity fields, including `subject_key_identifier_hex` + when the signer uses that form. A missing security directory is observed + 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). - 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 5bb2956e8d..09da10a3d4 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -96,6 +96,20 @@ dependencies. An omitted package kind stays null. A rustc commit hash is inferred from a read-only `/rustc/<40-hex>/` path and does not by itself identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md). +## PE Authenticode certificate tables + +Use `inspect_pe_certificates` for an explicit local PE32/PE32+ image when the +question concerns the Authenticode certificate table, WIN_CERTIFICATE types, or +embedded PKCS#7 signer and X.509 identity fields. No `open_binary`, Windows SDK, +or target execution is required. The CLI equivalent is +`inspect-pe-certificates PATH`. Preserve artifact SHA-256, table file ranges, +payload hashes, original validity-time spellings, and +`subject_key_identifier_hex` when the signer uses that form. A missing +`IMAGE_DIRECTORY_ENTRY_SECURITY` directory is observed absence of that table, +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). + ## 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 0a8cd49929..70aefe6255 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ Additional tools and host support depend on the target: | IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required | | 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) | | 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) | | WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e043d56db7..4640b8e66b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -32,6 +32,7 @@ export default defineConfig({ { text: "IL2CPP metadata", link: "/il2cpp-metadata" }, { text: "Model artifacts", link: "/model-artifacts" }, { text: "Rust dependencies", link: "/rust-binaries" }, + { text: "PE certificates", link: "/pe-certificates" }, { text: "ELF notes", link: "/elf-notes" }, { text: "JavaScript and Electron", diff --git a/docs/cli.md b/docs/cli.md index 60496ebe42..b4774ee725 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -155,6 +155,18 @@ dependency edges, and the section's file range. A missing `.dep-v0` section is null metadata, not proof that the binary has no dependencies. See [Rust binaries](rust-binaries.md). +Inspect a PE Authenticode certificate table without executing the image or +verifying signatures: + +```bash +rea inspect-pe-certificates /absolute/path/to/application.exe --json +``` + +The workflow reports `WIN_CERTIFICATE` entries and, for PKCS#7 signedData, +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 ELF PT_NOTE and SHT_NOTE tables without executing the image: ```bash diff --git a/docs/index.md b/docs/index.md index 0980ad312d..f9fc109618 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), [ELF notes](elf-notes.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), [PE certificates](pe-certificates.md), [ELF notes](elf-notes.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-certificates.md b/docs/pe-certificates.md new file mode 100644 index 0000000000..2d542567b1 --- /dev/null +++ b/docs/pe-certificates.md @@ -0,0 +1,54 @@ +# PE Authenticode certificate tables + +`inspect_pe_certificates` / `inspect-pe-certificates` answers whether one +selected local PE32/PE32+ image declares an `IMAGE_DIRECTORY_ENTRY_SECURITY` +table, which `WIN_CERTIFICATE` entries that table stores, and which PKCS#7 +signer and X.509 fields those entries carry. It reads the file without +executing it and does not require a Windows SDK, catalog file, or disassembler. + +```bash +rea inspect-pe-certificates /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_certificates", + "arguments": { "path": "/artifacts/application.exe" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, and byte length. +`table` is the file-offset security directory, or `null` when the directory is +absent or zero. Each `entries[]` item reports `WIN_CERTIFICATE` length, +revision, type, and the payload SHA-256. + +For `WIN_CERTIFICATE` type 2 (PKCS#7 signedData) `pkcs7` contains digest +algorithm OIDs, embedded X.509 serial/issuer/subject/validity fields, and +signer identifier facts. A signer identified by subject key identifier reports +that value as `subject_key_identifier_hex`. Other certificate types keep +`pkcs7` null. + +A missing table is observed absence of that directory. It is not proof the +image is unsigned in every catalog or overlay sense. + +## Limits and failures + +The selected file is limited to 64 MiB by default (512 MiB maximum). At most +eight `WIN_CERTIFICATE` entries are examined by default. Malformed PE headers, +truncated certificate entries, and PKCS#7 that is not structurally signedData +fail without partial success. A security directory that ends in fewer than +eight header bytes, including zeros, is a truncated `WIN_CERTIFICATE` and +fails the same way. SignedData must contain at least one `SignerInfo`, and +each `SignerInfo` must carry a non-empty signature value. Each embedded X.509 +certificate must include `subjectPublicKeyInfo` and `signatureValue`. Decoded +certificate name text, serials, validity spellings, and subject key identifiers +share a 64 KiB budget per PKCS#7 payload. + +This workflow does not verify signatures, certificate chains, timestamps, or +Authenticode digest coverage of image bytes. diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index bc489aff62..6deef30673 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -143,6 +143,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) | | شيفرة 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 cb5c7aaf1c..3441eab8e4 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -143,6 +143,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | 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) | | 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 330504ace1..3b30d720a1 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -149,6 +149,7 @@ npx rea-agents@latest setup | Δομή ELF χωρίς εκτέλεση | Ενότητες, τμήματα, αρχικά σύμβολα/εγγραφές μετεγκατάστασης και ενδείξεις πιθανών μέτρων προστασίας από στατική ανάλυση | pwntools που παρέχει ο χρήστης σε Linux x64· [διαγνωστικά δυαδικών αρχείων](../binary-diagnostics.md) | | Δυαδικά αρχεία 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) | | 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) | | Αρχείο WASM | Έλεγχο εγκυρότητας, ενότητες, ακριβείς μορφές εισαγωγών/εξαγωγών σε WAT και αποκωδικοποιημένο WAT | WABT 1.0.42 που παρέχει ο χρήστης· `inspect_wasm_artifact` | | Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index f21d11224a..81a0955756 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -143,6 +143,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | 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) | | 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 a0b4ea0c3f..bab6e0bb0a 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -135,6 +135,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | 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) | | بایت‌کد 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 b5b0011798..9ef6da0fb1 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -146,6 +146,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | ---------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | 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) | | 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 d06eb7d251..e1d0d5f533 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -149,6 +149,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) | | 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 7d746d434f..9b1621a20b 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -146,6 +146,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | ---------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | 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) | | 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 8764cec73a..0927cb629f 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -143,6 +143,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | -------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | 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) | | 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 2a75886fc5..c07f7b7aa5 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -153,6 +153,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | | 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) | | 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) | | WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` | | EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index c0b756dcc9..bf3267e86e 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -151,6 +151,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.19), 24.x (>=24.11), അല്ലെ | Offline ELF layout | Sections, segments, യഥാർത്ഥ symbols/relocations, static mitigation candidates | Linux x64-ൽ caller നൽകുന്ന pwntools; [binary diagnostics](../binary-diagnostics.md) | | 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) | | 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) | | WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, അനുമാനിച്ച arguments, mutability | Linux x64-ൽ local raw/hex carrier; [offline EVM guide](../evm-bytecode.md) | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index d7a10010bd..99bda0bbc3 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -143,6 +143,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | 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) | | 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 3a1558406a..0b5ed9d94c 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -143,6 +143,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | 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) | | 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 65935f2d58..00f0ddbbaf 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -145,6 +145,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) | | Байт-код 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) | | 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, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | | Установленные настольные приложения | Возможные среды выполнения, файлы с доказательствами, компоненты и дальнейшие шаги | Ограниченная проверка каталогов и заголовков;[Настольные приложения](../desktop-applications.md) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 4e3838a7d1..c268e4fd11 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -143,6 +143,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 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) | | ไบต์โค้ด 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 f55ccf71fe..0494323202 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -147,6 +147,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) | | 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) | | 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) | | 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` | | EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 56a3fd884f..7ab55b1376 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -143,6 +143,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 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) | | Байт-код 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_vi.md b/docs/readme/README_vi.md index a528d4f0e3..3ae8ec85a1 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -145,6 +145,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) | | 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) | | 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) | | 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) | | Thư mục cài đặt ứng dụng máy tính | Các runtime có khả năng được dùng, tệp bằng chứng, dấu hiệu thành phần và bước tiếp theo | Kiểm tra thư mục và phần đầu tệp có giới hạn;[Ứng dụng máy tính](../desktop-applications.md) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index cd2048bb90..0cc9f4903a 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -141,33 +141,34 @@ AI 代理通過 MCP 呼叫 REA,檢查目標並追蹤相關程式碼。REA 返 REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。額外工具和主機支援取決於分析目標: -| 目標 | REA 返回的內容 | 要求與指南 | -| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) | -| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) | -| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go | -| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` | -| 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) | -| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | -| 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) | -| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) | -| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) | -| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) | -| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | -| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) | -| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) | -| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) | -| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) | -| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) | -| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](../apktool-resource-analysis.md) | -| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](../flutter-analysis.md) | -| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](../firmware-analysis.md) | -| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](../packet-captures.md) | -| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](../signal-recordings.md) | -| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 應用](../apple-application-analysis.md) | -| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](../process-capture.md) | +| 目標 | REA 返回的內容 | 要求與指南 | +| --------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) | +| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) | +| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go | +| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` | +| 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) | +| 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) | +| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) | +| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) | +| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) | +| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | +| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) | +| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) | +| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) | +| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) | +| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) | +| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](../apktool-resource-analysis.md) | +| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](../flutter-analysis.md) | +| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](../firmware-analysis.md) | +| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](../packet-captures.md) | +| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](../signal-recordings.md) | +| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 應用](../apple-application-analysis.md) | +| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](../process-capture.md) | 靜態 JavaScript 和 .NET 檢查讀取提供的檔案,不執行應用。執行階段擷取會以你的使用者權限執行目標或與之互動;各執行階段指南說明瞭具體影響。 diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index b5f7a09bf3..007de80875 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -141,33 +141,34 @@ npx rea-agents@latest setup REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。额外工具和主机支持取决于分析目标: -| 目标 | REA 返回的内容 | 要求与指南 | -| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) | -| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) | -| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go | -| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` | -| 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) | -| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | -| 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) | -| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) | -| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) | -| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) | -| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | -| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) | -| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) | -| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) | -| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) | -| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) | -| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](../apktool-resource-analysis.md) | -| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](../flutter-analysis.md) | -| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](../firmware-analysis.md) | -| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](../packet-captures.md) | -| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](../signal-recordings.md) | -| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 应用](../apple-application-analysis.md) | -| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](../process-capture.md) | +| 目标 | REA 返回的内容 | 要求与指南 | +| --------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) | +| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) | +| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go | +| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` | +| 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) | +| 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) | +| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) | +| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) | +| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) | +| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | +| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) | +| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) | +| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) | +| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) | +| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) | +| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](../apktool-resource-analysis.md) | +| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](../flutter-analysis.md) | +| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](../firmware-analysis.md) | +| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](../packet-captures.md) | +| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](../signal-recordings.md) | +| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 应用](../apple-application-analysis.md) | +| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](../process-capture.md) | 静态 JavaScript 和 .NET 检查读取提供的文件,不运行应用。运行时捕获会以你的用户权限运行目标或与之交互;各运行时指南说明了具体影响。 diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 69bc92b73e..fc79e165a4 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -250,6 +250,13 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_pe_resources", ), }, + { + identity: sources.artifactProviders.PE_CERTIFICATES_PROVIDER, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_pe_certificates", + ), + }, { identity: sources.artifactProviders.ELF_NOTES_PROVIDER, contracts: diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index f110b005a8..f24b6fd2d7 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -500,6 +500,8 @@ const workflowAvailabilityFor = ({ }; if (name === "inspect_pe_resources" || name === "inspect_native_exports") return { reason: "available", remediation: null }; + if (name === "inspect_pe_certificates") + return { reason: "available", remediation: null }; if (name === "inspect_elf_notes") return { reason: "available", remediation: null }; if (name === "inspect_evm_interface") { diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 88b3bb0520..e14fd68b9a 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -48,6 +48,13 @@ export const PE_RESOURCES_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free PE Authenticode table decoding. */ +export const PE_CERTIFICATES_PROVIDER = Object.freeze({ + id: "rea-pe-certificates", + name: "REA PE certificate 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/application/binaryDiagnostics/PeCertificatesPort.ts b/src/application/binaryDiagnostics/PeCertificatesPort.ts new file mode 100644 index 0000000000..755ea3baca --- /dev/null +++ b/src/application/binaryDiagnostics/PeCertificatesPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectPeCertificatesInput, + PeCertificates, +} from "../../domain/native/peCertificates.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and PE certificate-table decoding, independent of a disassembler session. */ +export interface PeCertificatesPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectPeCertificatesInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/PeCertificatesService.test.ts b/src/application/binaryDiagnostics/PeCertificatesService.test.ts new file mode 100644 index 0000000000..30fa4b88f0 --- /dev/null +++ b/src/application/binaryDiagnostics/PeCertificatesService.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from "vitest"; +import { PeCertificatesService } from "./PeCertificatesService.js"; +import { PE_CERTIFICATES_PROVIDER } from "../InvestigationProviders.js"; +import type { PeCertificates } from "../../domain/native/peCertificates.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; + +const identity = PE_CERTIFICATES_PROVIDER; +const report: PeCertificates = { + artifact: { + path: "/artifacts/app.exe", + sha256: "a".repeat(64), + bytes: 512, + }, + format: "pe32", + machine: 0x14c, + table: null, + entries: [], + coverage: { status: "complete", examined_entries: 0, certificates: 0 }, + limitations: ["IMAGE_DIRECTORY_ENTRY_SECURITY is absent."], +}; + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new PeCertificatesService({ + 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 certificate table", async () => { + const result = await new PeCertificatesService({ + 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" }, + normalized_result: report, + }); +}); + +it("discards an observation if the caller cancels while the provider completes", async () => { + const controller = new AbortController(); + const service = new PeCertificatesService({ + 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); +}); diff --git a/src/application/binaryDiagnostics/PeCertificatesService.ts b/src/application/binaryDiagnostics/PeCertificatesService.ts new file mode 100644 index 0000000000..0ce7aa9e18 --- /dev/null +++ b/src/application/binaryDiagnostics/PeCertificatesService.ts @@ -0,0 +1,146 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { PeCertificatesPort } from "./PeCertificatesPort.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 { + inspectPeCertificatesInputSchema, + peCertificatesSchema, + type PeCertificates, +} from "../../domain/native/peCertificates.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { + decodePeMachine, + peOptionalHeaderMagic, + type PeArchitecture, +} from "../../domain/peMachine.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_pe_certificates"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class PeCertificatesService { + constructor(readonly provider: PeCertificatesPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectPeCertificatesInputSchema.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 = peCertificatesSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + !validReport( + report.data, + input.data.max_entries, + input.data.max_file_bytes, + ) + ) + return err( + new AnalysisOutputError( + OPERATION, + "PE certificate 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 }, + ...(value.table === null + ? [] + : [ + { + kind: "file-offset-range" as const, + start: value.table.location.offset, + end: value.table.location.offset + value.table.location.bytes, + }, + ]), + ]; + 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: PeCertificates, +): 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 validReport = ( + report: PeCertificates, + maxEntries: number, + maxBytes: number, +): boolean => { + const inside = ({ offset, bytes }: { offset: number; bytes: number }) => + offset <= report.artifact.bytes - bytes; + if ( + report.artifact.bytes > maxBytes || + report.coverage.examined_entries > maxEntries || + report.coverage.certificates !== + report.entries.reduce( + (count, entry) => count + (entry.pkcs7?.certificates.length ?? 0), + 0, + ) || + report.entries.length > maxEntries + ) + return false; + if (report.table !== null && !inside(report.table.location)) return false; + return report.entries.every( + (entry) => inside(entry.location) && inside(entry.payload.location), + ); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 45cf8921db..2af1e4e090 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -6,6 +6,7 @@ import { createBinaryLayoutService, createElfNotesService, createPeResourcesService, + createPeCertificatesService, createNativeExportsService, } from "../composition/binaryDiagnostics.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -16,6 +17,7 @@ import { withCommandCancellation } from "./commandCancellation.js"; 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 { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -99,6 +101,39 @@ export const registerBinaryDiagnosticsCommands = ( }), ), }); + cli.command(CLI_COMMANDS.inspectPeCertificates, { + description: + "Inspect PE Authenticode certificate table identities without verifying signatures", + args: z.object({ path: z.string().describe("Local PE32/PE32+ image") }), + options: z.object({ + maxFileBytes: inspectPeCertificatesInputSchema.shape.max_file_bytes, + maxEntries: inspectPeCertificatesInputSchema.shape.max_entries, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const pe = createPeCertificatesService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectPeCertificates, + async () => + finishAnalysisCommand( + () => + pe.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_entries: options.maxEntries, + }, + { signal }, + ), + pe, + "rea-pe-certificates", + "inspect_pe_certificates", + 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 f9b549ea1f..c00f0393dc 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -66,6 +66,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", inspectPeResources: "inspect-pe-resources", + inspectPeCertificates: "inspect-pe-certificates", 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 ff7f519312..81be8de875 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -5,6 +5,8 @@ import { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayo import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvider.js"; import { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js"; +import { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js"; +import { PeCertificatesProvider } from "../native/pe/PeCertificatesProvider.js"; import { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import { ElfNotesProvider } from "../native/elf/ElfNotesProvider.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; @@ -15,6 +17,10 @@ import { NativeExportsProvider } from "../native/exports/NativeExportsProvider.j export const createPeResourcesService = (): PeResourcesService => new PeResourcesService(new PeResourcesProvider()); +/** Portable Authenticode table inspection with no engine or catalog verification. */ +export const createPeCertificatesService = (): PeCertificatesService => + new PeCertificatesService(new PeCertificatesProvider()); + /** 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 1c7728f2a0..7d58bed172 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -25,6 +25,10 @@ import { inspectPeResourcesInputSchema, peResourcesSchema, } from "../../domain/native/peResources.js"; +import { + inspectPeCertificatesInputSchema, + peCertificatesSchema, +} from "../../domain/native/peCertificates.js"; import { inspectElfNotesInputSchema, elfNotesSchema, @@ -84,6 +88,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_pe_certificates", + ...toolContractMetadata("inspect_pe_certificates"), + kind: "native-provider", + description: + "Inspect one explicit local PE32/PE32+ image's IMAGE_DIRECTORY_ENTRY_SECURITY table without executing it, verifying signatures or opening a disassembler. Returns WIN_CERTIFICATE file ranges, types and payload SHA-256, plus structurally decoded PKCS#7 signedData digest algorithms, embedded X.509 serial/issuer/subject/validity fields and signer identifiers, including a subject key identifier, when type 2 is present. A missing directory is observed absence of that table, not proof the image is unsigned in every catalog or overlay sense. Authenticode digest coverage, chain validity and timestamp trust remain unobserved. Reads a stable regular file; malformed PE headers, truncated WIN_CERTIFICATE entries, X.509 certificates missing subjectPublicKeyInfo or signatureValue, and non-signedData PKCS#7 return no partial success. Portable on supported Node hosts; no Windows SDK or engine installation required.", + inputSchema: inspectPeCertificatesInputSchema, + outputSchema: evidenceResultOf(peCertificatesSchema), + examples: [ + { + title: "Inspect PE Authenticode certificate 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 8cd30a7be0..9fb9bd7078 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -31,6 +31,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_elf_notes: "states that it does not execute the image", inspect_desktop_application: "states that directory triage performs no target execution", + inspect_pe_certificates: "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 6c3b21d5ce..6c1bf529a1 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -253,6 +253,7 @@ export const TOOL_EFFECTS: Readonly> = { }), inspect_byte_entropy: evidence, inspect_pe_resources: evidence, + inspect_pe_certificates: evidence, inspect_native_exports: evidence, inspect_elf_notes: evidence, inspect_analysis_view: evidence, diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index a4dce9a6fa..59ac2d6adf 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -26,6 +26,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_artifact" | "inspect_desktop_application" | "inspect_pe_resources" + | "inspect_pe_certificates" | "inspect_elf_notes" | "extract_artifact" | "decode_interface_builder" diff --git a/src/domain/native/peCertificates.test.ts b/src/domain/native/peCertificates.test.ts new file mode 100644 index 0000000000..519581b42e --- /dev/null +++ b/src/domain/native/peCertificates.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "vitest"; +import { + inspectPeCertificatesInputSchema, + peCertificateTypeName, + peCertificatesSchema, +} from "./peCertificates.js"; + +it("maps WIN_CERTIFICATE type numbers without inventing unknown names", () => { + expect(peCertificateTypeName(1)).toBe("x509"); + expect(peCertificateTypeName(2)).toBe("pkcs7"); + expect(peCertificateTypeName(3)).toBe("reserved"); + expect(peCertificateTypeName(4)).toBe("ts_stack_signed"); + expect(peCertificateTypeName(99)).toBe("unknown"); +}); + +it("rejects a relative path in the advertised input schema", () => { + const relative = inspectPeCertificatesInputSchema.safeParse({ + path: "app.exe", + }); + expect(relative.success).toBe(false); + if (relative.success) throw new Error("relative path was accepted"); + expect(relative.error.issues[0]?.message).toMatch(/absolute/u); + expect( + inspectPeCertificatesInputSchema.safeParse({ + path: "/artifacts/app.exe", + }).success, + ).toBe(true); +}); + +it("rejects extra caller arguments and admits an absent certificate table", () => { + expect( + inspectPeCertificatesInputSchema.safeParse({ + path: "/artifacts/app.exe", + extra: true, + }).success, + ).toBe(false); + const report = peCertificatesSchema.parse({ + artifact: { + path: "/artifacts/app.exe", + sha256: "a".repeat(64), + bytes: 512, + }, + format: "pe32", + machine: 0x14c, + table: null, + entries: [], + coverage: { status: "complete", examined_entries: 0, certificates: 0 }, + limitations: ["IMAGE_DIRECTORY_ENTRY_SECURITY is absent."], + }); + expect(report.table).toBeNull(); + expect(report.entries).toEqual([]); +}); diff --git a/src/domain/native/peCertificates.ts b/src/domain/native/peCertificates.ts new file mode 100644 index 0000000000..ee24ff984a --- /dev/null +++ b/src/domain/native/peCertificates.ts @@ -0,0 +1,152 @@ +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 }); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES = 536870912; +/** Hard ceiling on concatenated WIN_CERTIFICATE entries. */ +export const MAX_INSPECT_PE_CERTIFICATE_ENTRIES = 64; + +export const PE_CERTIFICATE_TYPES = [ + "x509", + "pkcs7", + "reserved", + "ts_stack_signed", + "unknown", +] as const; +export type PeCertificateType = (typeof PE_CERTIFICATE_TYPES)[number]; + +/** Map WIN_CERTIFICATE wCertificateType to a named kind; unknown stays unknown. */ +export const peCertificateTypeName = (type: number): PeCertificateType => { + switch (type) { + case 0x0001: + return "x509"; + case 0x0002: + return "pkcs7"; + case 0x0003: + return "reserved"; + case 0x0004: + return "ts_stack_signed"; + default: + return "unknown"; + } +}; + +/** Complete bounded inspection of one PE Authenticode certificate table. */ +export const inspectPeCertificatesInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a PE image", + ), + max_file_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES) + .default(67108864) + .describe("Maximum stable artifact bytes to read (default 64 MiB)"), + max_entries: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_PE_CERTIFICATE_ENTRIES) + .default(8) + .describe( + "Maximum WIN_CERTIFICATE entries to examine; exceeding the budget fails without partial success", + ), +}); + +const distinguishedNameSchema = z.strictObject({ + text: z.string(), + attributes: z.array( + z.strictObject({ + oid: z.string(), + oid_name: z.string().nullable(), + value: z.string(), + }), + ), +}); + +const x509CertificateSchema = z.strictObject({ + location: range, + der_sha256: digestSchema, + serial_hex: z.string().regex(/^[0-9a-f]*$/), + issuer: distinguishedNameSchema, + subject: distinguishedNameSchema, + not_before: z.string(), + not_after: z.string(), + signature_algorithm_oid: z.string(), + signature_algorithm_name: z.string().nullable(), +}); + +const signerInfoSchema = z.strictObject({ + digest_algorithm_oid: z.string(), + digest_algorithm_name: z.string().nullable(), + signature_algorithm_oid: z.string(), + signature_algorithm_name: z.string().nullable(), + issuer: distinguishedNameSchema.nullable(), + serial_hex: z + .string() + .regex(/^[0-9a-f]*$/) + .nullable(), + subject_key_identifier_hex: z + .string() + .regex(/^[0-9a-f]*$/) + .nullable(), + authenticated_attributes: z.boolean(), +}); + +const pkcs7Schema = z.strictObject({ + content_type_oid: z.string(), + content_type_name: z.string().nullable(), + digest_algorithm_oids: z.array(z.string()), + certificates: z.array(x509CertificateSchema), + signers: z.array(signerInfoSchema), +}); + +const certificateEntrySchema = z.strictObject({ + index: uint, + location: range, + length: uint, + revision: uint, + certificate_type: uint, + certificate_type_name: z.enum(PE_CERTIFICATE_TYPES), + payload: z.strictObject({ + location: range, + bytes: uint, + sha256: digestSchema, + }), + pkcs7: pkcs7Schema.nullable(), +}); + +/** Observed IMAGE_DIRECTORY_ENTRY_SECURITY table; absence is null, not unsigned-everywhere. */ +export const peCertificatesSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, + bytes: uint, + }), + format: z.enum(["pe32", "pe32-plus"]), + machine: uint, + table: z + .strictObject({ + location: range, + data_directory_location: range, + }) + .nullable(), + entries: z.array(certificateEntrySchema), + coverage: z.strictObject({ + status: z.literal("complete"), + examined_entries: uint, + certificates: uint, + }), + limitations: z.array(z.string()), +}); + +export type PeCertificates = z.infer; +export type InspectPeCertificatesInput = z.infer< + typeof inspectPeCertificatesInputSchema +>; diff --git a/src/native/pe/PeCertificateDer.ts b/src/native/pe/PeCertificateDer.ts new file mode 100644 index 0000000000..6a31522a8e --- /dev/null +++ b/src/native/pe/PeCertificateDer.ts @@ -0,0 +1,620 @@ +const OID_NAMES: Readonly> = { + "1.2.840.113549.1.7.1": "data", + "1.2.840.113549.1.7.2": "signedData", + "1.2.840.113549.1.1.1": "rsaEncryption", + "1.2.840.113549.1.1.5": "sha1WithRSAEncryption", + "1.2.840.113549.1.1.11": "sha256WithRSAEncryption", + "1.2.840.113549.1.1.12": "sha384WithRSAEncryption", + "1.2.840.113549.1.1.13": "sha512WithRSAEncryption", + "1.2.840.113549.1.9.3": "contentType", + "1.2.840.113549.1.9.4": "messageDigest", + "1.2.840.113549.1.9.5": "signingTime", + "1.3.14.3.2.26": "sha1", + "2.5.4.3": "commonName", + "2.5.4.6": "countryName", + "2.5.4.10": "organizationName", + "2.5.4.11": "organizationalUnitName", + "2.16.840.1.101.3.4.2.1": "sha256", + "2.16.840.1.101.3.4.2.2": "sha384", + "2.16.840.1.101.3.4.2.3": "sha512", +}; + +export interface DerNode { + readonly tag: number; + readonly constructed: boolean; + readonly start: number; + readonly end: number; + readonly content: Buffer; + readonly children: readonly DerNode[]; +} + +export class PeCertificateDerFailure extends Error { + constructor(message: string) { + super(message); + this.name = "PeCertificateDerFailure"; + } +} + +const fail = (message: string): never => { + throw new PeCertificateDerFailure(message); +}; + +const required = (value: T | undefined, message: string): T => + value === undefined ? fail(message) : value; + +// Authenticode signatures with full chains and countersignatures decode to a +// few thousand nodes; the budget bounds sibling fan-out, which depth does not. +export const DER_NODE_BUDGET = 16_384; + +interface DerBudget { + remaining: number; +} + +/** Decode one DER/BER TLV; constructed nodes own their child sequence. */ +export const readDer = ( + bytes: Buffer, + offset = 0, + end = bytes.length, + depth = 0, + budget: DerBudget = { remaining: DER_NODE_BUDGET }, +): DerNode => { + if (depth > 32) return fail("DER nesting exceeds 32 levels."); + if (budget.remaining <= 0) + return fail( + `DER value count exceeds the ${String(DER_NODE_BUDGET)}-node budget.`, + ); + budget.remaining -= 1; + if (offset >= end || offset + 2 > end) + return fail("Truncated DER tag or length."); + const tag = required(bytes[offset], "Truncated DER tag."); + const constructed = (tag & 0x20) !== 0; + let cursor = offset + 1; + const lengthByte = required(bytes[cursor], "Truncated DER length."); + cursor += 1; + let length: number; + if ((lengthByte & 0x80) === 0) length = lengthByte; + else { + const width = lengthByte & 0x7f; + if (width === 0 || width > 4) return fail("Unsupported DER length form."); + if (cursor + width > end) return fail("Truncated DER long length."); + length = 0; + for (let index = 0; index < width; index += 1) { + length = (length << 8) | required(bytes[cursor], "Truncated DER length."); + cursor += 1; + } + } + const contentEnd = cursor + length; + if (length < 0 || contentEnd > end) + return fail("DER value exceeds its parent."); + const content = bytes.subarray(cursor, contentEnd); + const children: DerNode[] = []; + if (constructed) { + let at = cursor; + while (at < contentEnd) { + const child = readDer(bytes, at, contentEnd, depth + 1, budget); + children.push(child); + at = child.end; + } + } + return { + tag, + constructed, + start: offset, + end: contentEnd, + content, + children, + }; +}; + +export const oidName = (oid: string): string | null => OID_NAMES[oid] ?? null; + +// One arc grows a BigInt by a shift per byte. Certificate OIDs, including a +// 2.25 UUID arc, fit under both caps; the caps stop that growth. +export const MAX_OID_BYTES = 256; +export const MAX_OID_ARC_BYTES = 32; + +// One PKCS#7 payload shares this ceiling. Real issuer and subject names are +// far smaller; the cap stops one wide string from occupying the file budget. +export const MAX_DECODED_NAME_BYTES = 65_536; + +interface DecodedNameBudget { + remaining: number; +} + +const chargeDecodedName = (budget: DecodedNameBudget, bytes: number): void => { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > budget.remaining) + fail( + `Certificate text exceeds the ${String(MAX_DECODED_NAME_BYTES)}-byte decoded budget.`, + ); + budget.remaining -= bytes; +}; + +/** Decode an OBJECT IDENTIFIER content buffer as dotted decimal. */ +export const decodeOid = (content: Buffer): string => { + if (content.length === 0) return fail("Empty OBJECT IDENTIFIER."); + if (content.length > MAX_OID_BYTES) + return fail(`OBJECT IDENTIFIER exceeds ${String(MAX_OID_BYTES)} bytes.`); + // Arcs are unbounded base-128 integers; the first encoded value combines + // the first two arcs and can itself span several bytes (2.999 -> 1079). + const values: bigint[] = []; + let value = 0n; + let arcBytes = 0; + for (let index = 0; index < content.length; index += 1) { + arcBytes += 1; + if (arcBytes > MAX_OID_ARC_BYTES) + return fail( + `OBJECT IDENTIFIER arc exceeds ${String(MAX_OID_ARC_BYTES)} bytes.`, + ); + const byte = required(content[index], "Truncated OBJECT IDENTIFIER."); + value = (value << 7n) | BigInt(byte & 0x7f); + if ((byte & 0x80) === 0) { + values.push(value); + value = 0n; + arcBytes = 0; + } else if (index === content.length - 1) + return fail("Truncated OBJECT IDENTIFIER component."); + } + const [combined, ...rest] = values; + if (combined === undefined) return fail("Empty OBJECT IDENTIFIER."); + const first = combined < 40n ? 0n : combined < 80n ? 1n : 2n; + return [first, combined - first * 40n, ...rest].join("."); +}; + +const decodeUtf16Be = (bytes: Buffer, budget: DecodedNameBudget): string => { + if (bytes.length % 2 !== 0) return fail("BMPString length is odd."); + // Charge before the copy. Appending one character at a time retains a + // cons-string chain of about sixteen heap bytes per source byte. + chargeDecodedName(budget, bytes.length); + if (bytes.length === 0) return ""; + const decoded = Buffer.allocUnsafe(bytes.length); + for (let index = 0; index < bytes.length; index += 2) { + decoded[index] = bytes[index + 1] ?? 0; + decoded[index + 1] = bytes[index] ?? 0; + } + return decoded.toString("utf16le"); +}; + +const decodeUtf32Be = (bytes: Buffer, budget: DecodedNameBudget): string => { + if (bytes.length % 4 !== 0) + return fail("UniversalString length is not a multiple of 4."); + // Four source bytes become at most four UTF-16 bytes, so the source length + // bounds the decoded string before any code point is copied. + chargeDecodedName(budget, bytes.length); + const decoded = Buffer.allocUnsafe(bytes.length); + let at = 0; + for (let index = 0; index < bytes.length; index += 4) { + const codePoint = bytes.readUInt32BE(index); + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) + return fail("UniversalString holds an invalid code point."); + if (codePoint > 0xffff) { + const shifted = codePoint - 0x10000; + decoded.writeUInt16LE(0xd800 + (shifted >> 10), at); + decoded.writeUInt16LE(0xdc00 + (shifted & 0x3ff), at + 2); + at += 4; + } else { + decoded.writeUInt16LE(codePoint, at); + at += 2; + } + } + return decoded.subarray(0, at).toString("utf16le"); +}; + +const decodeUtf8 = (bytes: Buffer, budget: DecodedNameBudget): string => { + chargeDecodedName(budget, bytes.length); + const decoded = bytes.toString("utf8"); + // U+FFFD would hide the original bytes and collapse distinct invalid names. + if (!Buffer.from(decoded, "utf8").equals(bytes)) + return fail("DirectoryString is not valid UTF-8."); + return decoded; +}; + +const decodeDirectoryString = ( + node: DerNode, + budget: DecodedNameBudget, +): string => { + switch (node.tag) { + case 0x1c: + return decodeUtf32Be(node.content, budget); + case 0x1e: + return decodeUtf16Be(node.content, budget); + case 0x0c: + case 0x13: + case 0x16: + case 0x17: + case 0x18: + case 0x1b: + return decodeUtf8(node.content, budget); + default: + chargeDecodedName(budget, node.content.length); + return node.content.toString("latin1"); + } +}; + +const integerHex = (content: Buffer, budget: DecodedNameBudget): string => { + let start = 0; + while (start < content.length - 1 && content[start] === 0) start += 1; + chargeDecodedName(budget, (content.length - start) * 2); + return content.subarray(start).toString("hex"); +}; + +// Key identifiers are octet strings, not integers: leading zero bytes stay. +// The hex form is evidence text, so it shares the decoded-text budget. +const octetHex = (content: Buffer, budget: DecodedNameBudget): string => { + chargeDecodedName(budget, content.length * 2); + return content.toString("hex"); +}; + +const requireBitStringContent = (content: Buffer, label: string): void => { + const unused = content[0]; + if (unused === undefined || unused > 7) + return fail(`${label} BIT STRING is truncated or has invalid unused bits.`); +}; + +const requireBitString = (node: DerNode | undefined, label: string): void => { + if (node === undefined || node.tag !== 0x03) + return fail(`${label} is not a BIT STRING.`); + requireBitStringContent(node.content, label); +}; + +const sequence = (node: DerNode, label: string): readonly DerNode[] => { + if (node.tag !== 0x30) return fail(`Expected SEQUENCE for ${label}.`); + return node.children; +}; + +const algorithmOid = (node: DerNode): string => { + const children = sequence(node, "AlgorithmIdentifier"); + const oid = children[0]; + if (oid === undefined || oid.tag !== 0x06) + return fail("AlgorithmIdentifier is missing an OBJECT IDENTIFIER."); + return decodeOid(oid.content); +}; + +interface Attribute { + readonly oid: string; + readonly oid_name: string | null; + readonly value: string; +} + +const decodeName = ( + node: DerNode, + budget: DecodedNameBudget, +): { + readonly text: string; + readonly attributes: readonly Attribute[]; +} => { + const attributes: Attribute[] = []; + for (const rdn of sequence(node, "Name")) { + if (rdn.tag !== 0x31) return fail("Name RDN is not a SET."); + for (const attribute of rdn.children) { + const parts = sequence(attribute, "AttributeTypeAndValue"); + const oidNode = parts[0]; + const valueNode = parts[1]; + if ( + oidNode === undefined || + oidNode.tag !== 0x06 || + valueNode === undefined + ) + return fail("AttributeTypeAndValue is truncated."); + const oid = decodeOid(oidNode.content); + attributes.push({ + oid, + oid_name: oidName(oid), + value: decodeDirectoryString(valueNode, budget), + }); + } + } + const text = attributes + .map((attribute) => { + const key = attribute.oid_name ?? attribute.oid; + return `${key}=${attribute.value}`; + }) + .join(", "); + return { text, attributes }; +}; + +const validityTime = (node: DerNode, budget: DecodedNameBudget): string => { + if (node.tag !== 0x17 && node.tag !== 0x18) + return fail("Validity instant is not UTCTime or GeneralizedTime."); + // A zero byte becomes "\u0000" (six characters) in the hashed evidence JSON. + chargeDecodedName(budget, node.content.length * 6); + return node.content.toString("utf8"); +}; + +export interface ParsedCertificate { + readonly start: number; + readonly end: number; + readonly serial_hex: string; + readonly issuer: ReturnType; + readonly subject: ReturnType; + readonly not_before: string; + readonly not_after: string; + readonly signature_algorithm_oid: string; +} + +const requireSubjectPublicKeyInfo = (node: DerNode): void => { + const children = sequence(node, "SubjectPublicKeyInfo"); + const algorithm = children[0]; + const key = children[1]; + if (algorithm === undefined || key === undefined || children.length !== 2) + return fail( + "SubjectPublicKeyInfo is missing algorithm or subjectPublicKey.", + ); + algorithmOid(algorithm); + requireBitString(key, "SubjectPublicKeyInfo subjectPublicKey"); +}; + +// issuerUniqueID [1], subjectUniqueID [2], and extensions [3] are optional +// and ordered. Anything else is not a TBSCertificate field. +const finishTbsCertificate = ( + children: readonly DerNode[], + start: number, +): void => { + let cursor = start; + const issuerUniqueId = children[cursor]; + if (issuerUniqueId?.tag === 0x81) { + requireBitStringContent( + issuerUniqueId.content, + "TBSCertificate issuerUniqueID", + ); + cursor += 1; + } + const subjectUniqueId = children[cursor]; + if (subjectUniqueId?.tag === 0x82) { + requireBitStringContent( + subjectUniqueId.content, + "TBSCertificate subjectUniqueID", + ); + cursor += 1; + } + const extensions = children[cursor]; + if (extensions?.tag === 0xa3) { + const body = extensions.children[0]; + if ( + extensions.children.length !== 1 || + body === undefined || + body.tag !== 0x30 + ) + return fail("TBSCertificate extensions are not a SEQUENCE."); + cursor += 1; + } + if (cursor !== children.length) + return fail("TBSCertificate has unexpected trailing fields."); +}; + +/** Decode one X.509 Certificate SEQUENCE at a known file-relative DER node. */ +export const parseCertificate = ( + node: DerNode, + names: DecodedNameBudget = { remaining: MAX_DECODED_NAME_BYTES }, +): ParsedCertificate => { + const children = sequence(node, "Certificate"); + const tbs = children[0]; + const signatureAlgorithm = children[1]; + const signatureValue = children[2]; + // Not verifying the signature does not make the required BIT STRING optional. + if ( + tbs === undefined || + signatureAlgorithm === undefined || + signatureValue === undefined + ) + return fail( + "Certificate is missing TBSCertificate, signatureAlgorithm, or signatureValue.", + ); + if (children.length !== 3) + return fail("Certificate has unexpected trailing fields."); + requireBitString(signatureValue, "Certificate signatureValue"); + const tbsChildren = sequence(tbs, "TBSCertificate"); + let index = 0; + const version = tbsChildren[0]; + if (version?.tag === 0xa0) { + const versionNumber = version.children[0]; + if ( + version.children.length !== 1 || + versionNumber === undefined || + versionNumber.tag !== 0x02 + ) + return fail("TBSCertificate version is not an INTEGER."); + index = 1; + } + const serial = tbsChildren[index]; + const tbsSignature = tbsChildren[index + 1]; + const issuer = tbsChildren[index + 2]; + const validity = tbsChildren[index + 3]; + const subject = tbsChildren[index + 4]; + const subjectPublicKeyInfo = tbsChildren[index + 5]; + if ( + serial === undefined || + serial.tag !== 0x02 || + tbsSignature === undefined || + issuer === undefined || + validity === undefined || + subject === undefined || + subjectPublicKeyInfo === undefined + ) + return fail( + "TBSCertificate is missing serial, signature, issuer, validity, subject, or subjectPublicKeyInfo.", + ); + algorithmOid(tbsSignature); + requireSubjectPublicKeyInfo(subjectPublicKeyInfo); + finishTbsCertificate(tbsChildren, index + 6); + const validityChildren = sequence(validity, "Validity"); + const notBefore = validityChildren[0]; + const notAfter = validityChildren[1]; + if ( + validityChildren.length !== 2 || + notBefore === undefined || + notAfter === undefined + ) + return fail("Validity is missing notBefore or notAfter."); + return { + start: node.start, + end: node.end, + serial_hex: integerHex(serial.content, names), + issuer: decodeName(issuer, names), + subject: decodeName(subject, names), + not_before: validityTime(notBefore, names), + not_after: validityTime(notAfter, names), + signature_algorithm_oid: algorithmOid(signatureAlgorithm), + }; +}; + +export interface ParsedSigner { + readonly digest_algorithm_oid: string; + readonly signature_algorithm_oid: string; + readonly issuer: ReturnType | null; + readonly serial_hex: string | null; + readonly subject_key_identifier_hex: string | null; + readonly authenticated_attributes: boolean; +} + +const signerIdentifier = ( + sid: DerNode, + names: DecodedNameBudget, +): { + readonly issuer: ReturnType | null; + readonly serial_hex: string | null; + readonly subject_key_identifier_hex: string | null; +} => { + if (sid.tag === 0x30) { + const name = sid.children[0]; + const serial = sid.children[1]; + if ( + sid.children.length !== 2 || + name === undefined || + name.tag !== 0x30 || + serial === undefined || + serial.tag !== 0x02 + ) + return fail("SignerInfo issuerAndSerialNumber is truncated."); + return { + issuer: decodeName(name, names), + serial_hex: integerHex(serial.content, names), + subject_key_identifier_hex: null, + }; + } + if (sid.tag === 0x80) { + if (sid.content.length === 0) + return fail("SignerInfo subjectKeyIdentifier is empty."); + return { + issuer: null, + serial_hex: null, + subject_key_identifier_hex: octetHex(sid.content, names), + }; + } + return fail( + "SignerInfo sid is not issuerAndSerialNumber or subjectKeyIdentifier.", + ); +}; + +const parseSigner = (node: DerNode, names: DecodedNameBudget): ParsedSigner => { + const children = sequence(node, "SignerInfo"); + const version = children[0]; + const sid = children[1]; + const digestAlg = children[2]; + if (version === undefined || version.tag !== 0x02) + return fail("SignerInfo is missing version."); + if (sid === undefined || digestAlg === undefined) + return fail("SignerInfo is missing sid or digestAlgorithm."); + const { issuer, serial_hex, subject_key_identifier_hex } = signerIdentifier( + sid, + names, + ); + let cursor = 3; + const authenticated = children[cursor]?.tag === 0xa0; + if (authenticated) cursor += 1; + const signatureAlg = children[cursor]; + if (signatureAlg === undefined) + return fail("SignerInfo is missing signatureAlgorithm."); + const signature = children[cursor + 1]; + if (signature === undefined || signature.tag !== 0x04) + return fail("SignerInfo is missing its signature OCTET STRING."); + if (signature.content.length === 0) + return fail("SignerInfo signature OCTET STRING is empty."); + cursor += 2; + if (children[cursor]?.tag === 0xa1) cursor += 1; + if (cursor !== children.length) + return fail("SignerInfo has unexpected trailing fields."); + return { + digest_algorithm_oid: algorithmOid(digestAlg), + signature_algorithm_oid: algorithmOid(signatureAlg), + issuer, + serial_hex, + subject_key_identifier_hex, + authenticated_attributes: authenticated, + }; +}; + +export interface ParsedSignedData { + readonly content_type_oid: string; + readonly digest_algorithm_oids: readonly string[]; + readonly certificates: readonly ParsedCertificate[]; + readonly signers: readonly ParsedSigner[]; +} + +const parseSignedData = ( + node: DerNode, + names: DecodedNameBudget, +): ParsedSignedData => { + const children = sequence(node, "SignedData"); + const [version, digestSet, encapContentInfo] = children; + if (version === undefined || version.tag !== 0x02) + return fail("SignedData is missing version."); + if (digestSet === undefined || digestSet.tag !== 0x31) + return fail("SignedData is missing digestAlgorithms."); + if ( + encapContentInfo === undefined || + encapContentInfo.tag !== 0x30 || + encapContentInfo.children[0]?.tag !== 0x06 + ) + return fail("SignedData is missing encapContentInfo."); + const digest_algorithm_oids = digestSet.children.map(algorithmOid); + // RFC 5652 order: [0] certificates, [1] crls, both optional, then the + // required signerInfos SET as the final field. + let cursor = 3; + const certificateSet = + children[cursor]?.tag === 0xa0 ? children[cursor] : undefined; + if (certificateSet !== undefined) cursor += 1; + if (children[cursor]?.tag === 0xa1) cursor += 1; + const signerSet = children[cursor]; + if (signerSet === undefined || signerSet.tag !== 0x31) + return fail("SignedData is missing signerInfos."); + if (signerSet.children.length === 0) + return fail("SignedData signerInfos contains no SignerInfo."); + if (cursor !== children.length - 1) + return fail("SignedData has unexpected trailing fields."); + const certificates = (certificateSet?.children ?? []).map((certificate) => + parseCertificate(certificate, names), + ); + const signers = signerSet.children.map((signer) => + parseSigner(signer, names), + ); + return { + content_type_oid: "1.2.840.113549.1.7.2", + digest_algorithm_oids, + certificates, + signers, + }; +}; + +/** Decode ContentInfo wrapping SignedData; other content types remain unsupported. */ +export const parsePkcs7SignedData = (bytes: Buffer): ParsedSignedData => { + const root = readDer(bytes); + const children = sequence(root, "ContentInfo"); + const type = children[0]; + const content = children[1]; + if (type === undefined || type.tag !== 0x06) + return fail("ContentInfo is missing contentType."); + if (content === undefined || content.tag !== 0xa0) + return fail("signedData content is missing."); + if (children.length !== 2) + return fail("ContentInfo has unexpected trailing fields."); + const oid = decodeOid(type.content); + if (oid !== "1.2.840.113549.1.7.2") + return fail(`PKCS#7 contentType ${oid} is not signedData.`); + const signedData = content.children[0]; + if (signedData === undefined) return fail("signedData content is missing."); + if (content.children.length !== 1) + return fail("signedData content has unexpected trailing fields."); + return parseSignedData(signedData, { + remaining: MAX_DECODED_NAME_BYTES, + }); +}; diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts new file mode 100644 index 0000000000..12f15ca62c --- /dev/null +++ b/src/native/pe/PeCertificateParser.test.ts @@ -0,0 +1,530 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + inspectPeCertificatesInputSchema, + peCertificatesSchema, +} from "../../domain/native/peCertificates.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + peCertificateFixture, + peWithoutCertificateTableFixture, + pkcs7SignedDataFixture, +} from "./PeCertificates.fixture.js"; +import { + DER_NODE_BUDGET, + MAX_DECODED_NAME_BYTES, + MAX_OID_ARC_BYTES, + MAX_OID_BYTES, + decodeOid, + parseCertificate, + PeCertificateDerFailure, + readDer, +} from "./PeCertificateDer.js"; +import { parsePeCertificates } from "./PeCertificateParser.js"; + +const defaults = inspectPeCertificatesInputSchema.parse({ + path: "/artifacts/app.exe", +}); + +const parse = (bytes: Buffer) => + parsePeCertificates( + bytes, + { + path: defaults.path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }, + defaults, + ); + +it("reads PE32 and PE32+ PKCS#7 certificate tables", () => { + for (const plus of [false, true]) { + const bytes = peCertificateFixture(plus); + const report = parse(bytes); + expect(report.format).toBe(plus ? "pe32-plus" : "pe32"); + expect(report.table).not.toBeNull(); + expect(report.entries).toHaveLength(1); + expect(report.entries[0]).toMatchObject({ + revision: 0x0200, + certificate_type: 2, + certificate_type_name: "pkcs7", + }); + expect(report.entries[0]?.pkcs7).toMatchObject({ + content_type_oid: "1.2.840.113549.1.7.2", + content_type_name: "signedData", + digest_algorithm_oids: ["2.16.840.1.101.3.4.2.1"], + }); + const certificate = report.entries[0]?.pkcs7?.certificates[0]; + expect(certificate?.serial_hex).toBe("11223344"); + expect(certificate?.subject.text).toContain("commonName=REA Fixture"); + expect(certificate?.not_before).toBe("250101000000Z"); + expect(report.entries[0]?.pkcs7?.signers[0]).toMatchObject({ + serial_hex: "11223344", + subject_key_identifier_hex: null, + }); + expect(peCertificatesSchema.safeParse(report).success).toBe(true); + expect(report.coverage).toEqual({ + status: "complete", + examined_entries: 1, + certificates: 1, + }); + } +}); + +it("reports an absent security directory without claiming the image is unsigned", () => { + const report = parse(peWithoutCertificateTableFixture()); + expect(report.table).toBeNull(); + expect(report.entries).toEqual([]); + expect(report.limitations[0]).toMatch( + /IMAGE_DIRECTORY_ENTRY_SECURITY is absent/u, + ); +}); + +it("fails truncated WIN_CERTIFICATE payloads without partial success", () => { + const bytes = peCertificateFixture(); + const tableOffset = bytes.readUInt32LE(0x80 + 24 + 96 + 32); + bytes.writeUInt32LE(4, tableOffset); + expect(() => parse(bytes)).toThrow(ArtifactReaderFailure); +}); + +const securityDirectoryAt = 0x98 + 96 + 4 * 8; + +it("rejects a short security table instead of reporting it complete", () => { + for (const size of [4, 7]) { + const bytes = peCertificateFixture(); + const tableOffset = bytes.readUInt32LE(securityDirectoryAt); + bytes.writeUInt32LE(size, securityDirectoryAt + 4); + bytes.fill(0, tableOffset, tableOffset + size); + expect(() => parse(bytes)).toThrow(/header is truncated/u); + } + const bytes = peCertificateFixture(); + const tableOffset = bytes.readUInt32LE(securityDirectoryAt); + const tableSize = bytes.readUInt32LE(securityDirectoryAt + 4); + const extended = Buffer.concat([bytes, Buffer.alloc(4)]); + extended.writeUInt32LE(tableSize + 4, securityDirectoryAt + 4); + expect(extended.readUInt32LE(tableOffset)).toBeGreaterThan(0); + expect(() => parse(extended)).toThrow(/header is truncated/u); +}); + +it("fails PKCS#7 payloads that are not signedData", () => { + const bytes = peCertificateFixture(false, Buffer.from("not-pkcs7"), 0x0002); + expect(() => parse(bytes)).toThrow(ArtifactReaderFailure); +}); + +it("keeps unknown certificate types as payload hashes without PKCS#7 projection", () => { + const payload = pkcs7SignedDataFixture(); + const report = parse(peCertificateFixture(false, payload, 0x0001)); + expect(report.entries[0]).toMatchObject({ + certificate_type_name: "x509", + pkcs7: null, + }); + expect(report.entries[0]?.payload.sha256).toBe( + createHash("sha256").update(payload).digest("hex"), + ); +}); + +const der = (tag: number, content: Buffer): Buffer => { + const length = content.length; + const header = + length < 0x80 + ? Buffer.from([tag, length]) + : length <= 0xff + ? Buffer.from([tag, 0x81, length]) + : length <= 0xffff + ? Buffer.from([tag, 0x82, length >> 8, length & 0xff]) + : Buffer.from([ + tag, + 0x83, + length >> 16, + (length >> 8) & 0xff, + length & 0xff, + ]); + return Buffer.concat([header, content]); +}; +const derSeq = (...children: Buffer[]) => der(0x30, Buffer.concat(children)); +const derSet = (...children: Buffer[]) => der(0x31, Buffer.concat(children)); +const derInt = (value: number) => der(0x02, Buffer.from([value])); +const SIGNED_DATA_OID = Buffer.from([ + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02, +]); +const DATA_OID = Buffer.from([ + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01, +]); +const SHA256_ALG = derSeq( + Buffer.from([ + 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, + ]), +); +const contentInfo = (signedData: Buffer) => + derSeq(SIGNED_DATA_OID, der(0xa0, signedData)); + +it("rejects extra ContentInfo and signedData wrapper values", () => { + const valid = pkcs7SignedDataFixture(); + const root = readDer(valid); + const type = root.children[0]; + const content = root.children[1]; + const signedData = content?.children[0]; + if (type === undefined || content === undefined || signedData === undefined) + throw new Error("fixture ContentInfo was not decoded"); + const typeBytes = valid.subarray(type.start, type.end); + const contentBytes = valid.subarray(content.start, content.end); + const signedBytes = valid.subarray(signedData.start, signedData.end); + const nul = der(0x05, Buffer.alloc(0)); + expect(() => + parse(peCertificateFixture(false, derSeq(typeBytes, contentBytes, nul))), + ).toThrow(/ContentInfo has unexpected trailing fields/u); + expect(() => + parse( + peCertificateFixture( + false, + derSeq(typeBytes, der(0xa0, Buffer.concat([signedBytes, nul]))), + ), + ), + ).toThrow(/signedData content has unexpected trailing fields/u); +}); + +it("fails signedData that omits encapContentInfo and signerInfos", () => { + const truncated = contentInfo(derSeq(derInt(1), derSet())); + expect(() => parse(peCertificateFixture(false, truncated))).toThrow( + /encapContentInfo/u, + ); +}); + +it("fails a SignerInfo without its signature", () => { + const signer = derSeq( + derInt(1), + derSeq(derSeq(), derInt(1)), + SHA256_ALG, + SHA256_ALG, + ); + const payload = contentInfo( + derSeq(derInt(1), derSet(SHA256_ALG), derSeq(DATA_OID), derSet(signer)), + ); + expect(() => parse(peCertificateFixture(false, payload))).toThrow( + /signature OCTET STRING/u, + ); +}); + +it("fails a SignerInfo whose signature bytes were removed", () => { + const signer = derSeq( + derInt(1), + derSeq(derSeq(), derInt(1)), + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.alloc(0)), + ); + const payload = contentInfo( + derSeq(derInt(1), derSet(SHA256_ALG), derSeq(DATA_OID), derSet(signer)), + ); + expect(() => parse(peCertificateFixture(false, payload))).toThrow( + /signature OCTET STRING is empty/u, + ); +}); + +it("fails signedData whose signerInfos set is empty", () => { + const payload = contentInfo( + derSeq(derInt(1), derSet(SHA256_ALG), derSeq(DATA_OID), derSet()), + ); + expect(() => parse(peCertificateFixture(false, payload))).toThrow( + /no SignerInfo/u, + ); +}); + +it("keeps subjectKeyIdentifier bytes on the signer", () => { + const ski = Buffer.from([0x00, 0xab, 0xcd]); + const signer = derSeq( + derInt(3), + der(0x80, ski), + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.from([1])), + ); + const payload = contentInfo( + derSeq(derInt(1), derSet(SHA256_ALG), derSeq(DATA_OID), derSet(signer)), + ); + const report = parse(peCertificateFixture(false, payload)); + expect(report.entries[0]?.pkcs7?.signers[0]).toMatchObject({ + issuer: null, + serial_hex: null, + subject_key_identifier_hex: "00abcd", + }); + expect(peCertificatesSchema.safeParse(report).success).toBe(true); + const empty = derSeq( + derInt(3), + der(0x80, Buffer.alloc(0)), + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.from([1])), + ); + expect(() => + parse( + peCertificateFixture( + false, + contentInfo( + derSeq( + derInt(1), + derSet(SHA256_ALG), + derSeq(DATA_OID), + derSet(empty), + ), + ), + ), + ), + ).toThrow(/subjectKeyIdentifier is empty/u); + const wide = der(0x80, Buffer.alloc(MAX_DECODED_NAME_BYTES / 2 + 1, 0x11)); + expect(() => + parse( + peCertificateFixture( + false, + contentInfo( + derSeq( + derInt(1), + derSet(SHA256_ALG), + derSeq(DATA_OID), + derSet( + derSeq( + derInt(3), + wide, + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.from([1])), + ), + ), + ), + ), + ), + ), + ).toThrow(/decoded budget/u); +}); + +it("fails a truncated issuerAndSerialNumber instead of dropping the signer", () => { + const signer = derSeq( + derInt(1), + derSeq(derSeq()), + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.from([1])), + ); + const payload = contentInfo( + derSeq(derInt(1), derSet(SHA256_ALG), derSeq(DATA_OID), derSet(signer)), + ); + expect(() => parse(peCertificateFixture(false, payload))).toThrow( + /issuerAndSerialNumber is truncated/u, + ); +}); + +it("stops decoding a certificate payload at the DER node budget", () => { + const values = Buffer.alloc(2 * (DER_NODE_BUDGET + 1)); + for (let at = 0; at < values.length; at += 2) values[at] = 0x05; + const payload = contentInfo(derSeq(derInt(1), der(0x31, values))); + expect(() => parse(peCertificateFixture(false, payload))).toThrow( + /node budget/u, + ); +}); + +it("rejects headers whose section table does not fit SizeOfHeaders", () => { + const bytes = peWithoutCertificateTableFixture(); + bytes.writeUInt16LE(0xffff, 0x86); + bytes.writeUInt32LE(0, 0x98 + 60); + expect(() => parse(bytes)).toThrow(ArtifactReaderFailure); +}); + +it("decodes OBJECT IDENTIFIER arcs without truncation", () => { + expect(decodeOid(Buffer.from([0x88, 0x37, 0x03]))).toBe("2.999.3"); + expect(decodeOid(Buffer.from([0x69, 0x90, 0x80, 0x80, 0x80, 0x00]))).toBe( + "2.25.4294967296", + ); + expect(decodeOid(Buffer.from([0x2a, 0x86, 0x48]))).toBe("1.2.840"); +}); + +it("rejects an oversized OBJECT IDENTIFIER before growing an arc", () => { + const oid = Buffer.alloc(MAX_OID_BYTES + 1, 0xff); + oid[oid.length - 1] = 0x7f; + const payload = derSeq(der(0x06, oid), der(0xa0, derSeq(derInt(1)))); + expect(() => parse(peCertificateFixture(false, payload))).toThrow( + /OBJECT IDENTIFIER exceeds/u, + ); + const arc = Buffer.alloc(MAX_OID_ARC_BYTES + 1, 0x80); + arc[arc.length - 1] = 0x00; + expect(() => decodeOid(Buffer.concat([Buffer.from([0x2a]), arc]))).toThrow( + /arc exceeds/u, + ); +}); + +const cn = Buffer.from([0x06, 0x03, 0x55, 0x04, 0x03]); +const directoryName = (value: Buffer) => derSeq(derSet(derSeq(cn, value))); +const spki = derSeq(SHA256_ALG, der(0x03, Buffer.from([0]))); +const validity = derSeq( + der(0x17, Buffer.from("250101000000Z")), + der(0x17, Buffer.from("260101000000Z")), +); +const namedCertificate = (issuer: Buffer, subject: Buffer) => + derSeq( + derSeq(derInt(1), SHA256_ALG, issuer, validity, subject, spki), + SHA256_ALG, + der(0x03, Buffer.from([0])), + ); + +it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () => { + const issuer = directoryName(der(0x0c, Buffer.from("A"))); + const subject = directoryName(der(0x0c, Buffer.from("B"))); + const tbs = (withSpki: boolean) => + derSeq( + derInt(1), + SHA256_ALG, + issuer, + validity, + subject, + ...(withSpki ? [spki] : []), + ); + expect(() => + parseCertificate( + readDer(derSeq(tbs(false), SHA256_ALG, der(0x03, Buffer.from([0, 1])))), + ), + ).toThrow(/subjectPublicKeyInfo/u); + expect(() => + parseCertificate(readDer(derSeq(tbs(true), SHA256_ALG))), + ).toThrow(/signatureValue/u); + const signer = derSeq( + derInt(1), + derSeq(derSeq(), derInt(1)), + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.from([1])), + ); + const payload = contentInfo( + derSeq( + derInt(1), + derSet(SHA256_ALG), + derSeq(DATA_OID), + der(0xa0, derSeq(tbs(false), SHA256_ALG)), + derSet(signer), + ), + ); + expect(() => parse(peCertificateFixture(false, payload))).toThrow( + /signatureValue/u, + ); + expect(() => + parseCertificate( + readDer( + derSeq( + derSeq( + derInt(1), + SHA256_ALG, + issuer, + validity, + subject, + spki, + der(0x04, Buffer.from([1])), + ), + SHA256_ALG, + der(0x03, Buffer.from([0])), + ), + ), + ), + ).toThrow(/trailing fields/u); +}); + +it("rejects a UTF8String name that is not valid UTF-8", () => { + const certificate = readDer( + namedCertificate( + directoryName(der(0x0c, Buffer.from([0x80]))), + directoryName(der(0x0c, Buffer.from("A"))), + ), + ); + expect(() => parseCertificate(certificate)).toThrow(/not valid UTF-8/u); + expect( + parseCertificate( + readDer( + namedCertificate( + directoryName(der(0x0c, Buffer.from([0xc3, 0xa9]))), + directoryName(der(0x0c, Buffer.from("A"))), + ), + ), + ).issuer.text, + ).toBe("commonName=é"); +}); + +it("decodes BMPString names once and shares the decoded-byte budget", () => { + const bmp = (text: string) => { + const content = Buffer.alloc(text.length * 2); + [...text].forEach((character, index) => + content.writeUInt16BE(character.charCodeAt(0), index * 2), + ); + return der(0x1e, content); + }; + const certificate = readDer( + namedCertificate(directoryName(bmp("REA")), directoryName(bmp("Fixture"))), + ); + expect(parseCertificate(certificate).subject.text).toBe("commonName=Fixture"); + const half = Math.floor((MAX_DECODED_NAME_BYTES - (2 + 13 * 6 * 2)) / 4) * 2; + const within = readDer( + namedCertificate( + directoryName(der(0x1e, Buffer.alloc(half))), + directoryName(der(0x1e, Buffer.alloc(half))), + ), + ); + expect(parseCertificate(within).issuer.text.startsWith("commonName=")).toBe( + true, + ); + const over = readDer( + namedCertificate( + directoryName(der(0x1e, Buffer.alloc(half + 4))), + directoryName(der(0x1e, Buffer.alloc(half))), + ), + ); + expect(() => parseCertificate(over)).toThrow(/decoded budget/u); + const universal = Buffer.alloc(MAX_DECODED_NAME_BYTES + 4); + expect(() => + parseCertificate( + readDer( + namedCertificate( + directoryName(der(0x1c, universal)), + directoryName(bmp("A")), + ), + ), + ), + ).toThrow(/decoded budget/u); +}); + +it("decodes UniversalString names as UTF-32BE", () => { + const universal = (text: string) => { + const content = Buffer.alloc(text.length * 4); + [...text].forEach((character, index) => + content.writeUInt32BE(character.codePointAt(0) ?? 0, index * 4), + ); + return der(0x1c, content); + }; + const cn = Buffer.from([0x06, 0x03, 0x55, 0x04, 0x03]); + const name = derSeq(derSet(derSeq(cn, universal("REA")))); + const tbs = derSeq(derInt(1), SHA256_ALG, name, validity, name, spki); + const certificate = readDer( + derSeq(tbs, SHA256_ALG, der(0x03, Buffer.from([0]))), + ); + expect(parseCertificate(certificate).subject.text).toBe("commonName=REA"); + const emoji = readDer( + namedCertificate( + directoryName(der(0x1c, Buffer.from([0x00, 0x01, 0xf6, 0x00]))), + directoryName(der(0x1c, Buffer.from([0x00, 0x01, 0xf6, 0x00]))), + ), + ); + expect(parseCertificate(emoji).subject.text).toBe("commonName=😀"); + const odd = derSeq(derSet(derSeq(cn, der(0x1c, Buffer.from([0, 0, 0]))))); + const broken = readDer( + derSeq( + derSeq( + derInt(1), + SHA256_ALG, + odd, + derSeq(der(0x17, Buffer.from("x")), der(0x17, Buffer.from("x"))), + odd, + spki, + ), + SHA256_ALG, + der(0x03, Buffer.from([0])), + ), + ); + expect(() => parseCertificate(broken)).toThrow( + /UniversalString length is not a multiple of 4/u, + ); +}); diff --git a/src/native/pe/PeCertificateParser.ts b/src/native/pe/PeCertificateParser.ts new file mode 100644 index 0000000000..e369a0094b --- /dev/null +++ b/src/native/pe/PeCertificateParser.ts @@ -0,0 +1,204 @@ +import { createHash } from "node:crypto"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import type { + InspectPeCertificatesInput, + PeCertificates, +} from "../../domain/native/peCertificates.js"; +import { peCertificateTypeName } from "../../domain/native/peCertificates.js"; +import { + oidName, + parsePkcs7SignedData, + PeCertificateDerFailure, +} from "./PeCertificateDer.js"; +import { readPeHeaders } from "./PeResourceLayout.js"; + +const fail = (message: string): never => { + throw new ArtifactReaderFailure("format", message); +}; + +const requireRange = (bytes: Buffer, offset: number, length: number): void => { + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(length) || + offset < 0 || + length < 0 || + offset > bytes.length - length + ) + fail( + `PE certificate range ${String(offset)} + ${String(length)} leaves the artifact.`, + ); +}; + +const align8 = (value: number): number => Math.ceil(value / 8) * 8; + +const sha256 = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +const LIMITATIONS = [ + "This workflow does not verify signatures, certificate chains, timestamps, or Authenticode digest coverage of image bytes.", + "PKCS#7 fields are decoded structurally; cryptographic validity remains unobserved.", +] as const; + +const ABSENT_LIMITATIONS = [ + "IMAGE_DIRECTORY_ENTRY_SECURITY is absent. That is not proof the image is unsigned in every catalog or overlay sense.", + "This workflow does not verify signatures, certificate chains, timestamps, or Authenticode digest coverage of image bytes.", +] as const; + +/** Decode IMAGE_DIRECTORY_ENTRY_SECURITY as a file-offset WIN_CERTIFICATE table. */ +export const parsePeCertificates = ( + bytes: Buffer, + artifact: PeCertificates["artifact"], + input: InspectPeCertificatesInput, +): PeCertificates => { + const { pe, optional, magic, directoriesAt, directoryCount, headerSize } = + readPeHeaders(bytes); + const machine = bytes.readUInt16LE(pe + 4); + const format = magic === 0x20b ? "pe32-plus" : "pe32"; + const securityIndex = 4; + if (directoryCount <= securityIndex) { + return { + artifact, + format, + machine, + table: null, + entries: [], + coverage: { status: "complete", examined_entries: 0, certificates: 0 }, + limitations: [...ABSENT_LIMITATIONS], + }; + } + const directoryAt = optional + directoriesAt + securityIndex * 8; + requireRange(bytes, directoryAt, 8); + const tableOffset = bytes.readUInt32LE(directoryAt); + const tableSize = bytes.readUInt32LE(directoryAt + 4); + if (tableOffset === 0 && tableSize === 0) { + return { + artifact, + format, + machine, + table: null, + entries: [], + coverage: { status: "complete", examined_entries: 0, certificates: 0 }, + limitations: [...ABSENT_LIMITATIONS], + }; + } + if (tableSize === 0 || tableOffset < headerSize) + fail("PE certificate table offset overlaps headers or has zero size."); + requireRange(bytes, tableOffset, tableSize); + const table = bytes.subarray(tableOffset, tableOffset + tableSize); + const entries: PeCertificates["entries"] = []; + let at = 0; + let examined = 0; + while (at < table.length) { + // Alignment padding sits inside the gap before the next 8-byte boundary. + // Bytes that remain after that boundary are a short header, including + // zeros at the start of the table. + if (table.length - at < 8) fail("WIN_CERTIFICATE header is truncated."); + examined += 1; + if (examined > input.max_entries) + throw new ArtifactReaderFailure( + "limit", + `PE certificate table exceeds the ${String(input.max_entries)}-entry budget.`, + { boundary: "capacity" }, + ); + const length = table.readUInt32LE(at); + const revision = table.readUInt16LE(at + 4); + const certificateType = table.readUInt16LE(at + 6); + if (length < 8) + fail("WIN_CERTIFICATE dwLength is shorter than its header."); + if (at + length > table.length) + fail("WIN_CERTIFICATE dwLength exceeds the certificate table."); + const payload = table.subarray(at + 8, at + length); + let pkcs7: PeCertificates["entries"][number]["pkcs7"] = null; + if (peCertificateTypeName(certificateType) === "pkcs7") { + try { + const parsed = parsePkcs7SignedData(payload); + pkcs7 = { + content_type_oid: parsed.content_type_oid, + content_type_name: oidName(parsed.content_type_oid), + digest_algorithm_oids: [...parsed.digest_algorithm_oids], + certificates: parsed.certificates.map((certificate) => ({ + location: { + offset: tableOffset + at + 8 + certificate.start, + bytes: certificate.end - certificate.start, + }, + der_sha256: sha256( + payload.subarray(certificate.start, certificate.end), + ), + serial_hex: certificate.serial_hex, + issuer: { + text: certificate.issuer.text, + attributes: [...certificate.issuer.attributes], + }, + subject: { + text: certificate.subject.text, + attributes: [...certificate.subject.attributes], + }, + not_before: certificate.not_before, + not_after: certificate.not_after, + signature_algorithm_oid: certificate.signature_algorithm_oid, + signature_algorithm_name: oidName( + certificate.signature_algorithm_oid, + ), + })), + signers: parsed.signers.map((signer) => ({ + digest_algorithm_oid: signer.digest_algorithm_oid, + digest_algorithm_name: oidName(signer.digest_algorithm_oid), + signature_algorithm_oid: signer.signature_algorithm_oid, + signature_algorithm_name: oidName(signer.signature_algorithm_oid), + issuer: + signer.issuer === null + ? null + : { + text: signer.issuer.text, + attributes: [...signer.issuer.attributes], + }, + serial_hex: signer.serial_hex, + subject_key_identifier_hex: signer.subject_key_identifier_hex, + authenticated_attributes: signer.authenticated_attributes, + })), + }; + } catch (cause) { + if (cause instanceof PeCertificateDerFailure) + fail(`PKCS#7 signedData is malformed: ${cause.message}`); + throw cause; + } + } + entries.push({ + index: entries.length, + location: { offset: tableOffset + at, bytes: length }, + length, + revision, + certificate_type: certificateType, + certificate_type_name: peCertificateTypeName(certificateType), + payload: { + location: { offset: tableOffset + at + 8, bytes: payload.length }, + bytes: payload.length, + sha256: sha256(payload), + }, + pkcs7, + }); + const next = align8(at + length); + if (next <= at) fail("WIN_CERTIFICATE alignment did not advance."); + at = next; + } + const certificates = entries.reduce( + (count, entry) => count + (entry.pkcs7?.certificates.length ?? 0), + 0, + ); + return { + artifact, + format, + machine, + table: { + location: { offset: tableOffset, bytes: tableSize }, + data_directory_location: { offset: directoryAt, bytes: 8 }, + }, + entries, + coverage: { + status: "complete", + examined_entries: examined, + certificates, + }, + limitations: [...LIMITATIONS], + }; +}; diff --git a/src/native/pe/PeCertificates.fixture.ts b/src/native/pe/PeCertificates.fixture.ts new file mode 100644 index 0000000000..3d5c752f8f --- /dev/null +++ b/src/native/pe/PeCertificates.fixture.ts @@ -0,0 +1,156 @@ +const concat = (...parts: Buffer[]): Buffer => Buffer.concat(parts); + +const encodeLength = (length: number): Buffer => { + if (length < 0x80) return Buffer.from([length]); + if (length <= 0xff) return Buffer.from([0x81, length]); + const bytes = Buffer.alloc(3); + bytes[0] = 0x82; + bytes.writeUInt16BE(length, 1); + return bytes; +}; + +const tlv = (tag: number, content: Buffer): Buffer => + concat(Buffer.from([tag]), encodeLength(content.length), content); + +const seq = (...children: Buffer[]): Buffer => tlv(0x30, concat(...children)); +const set = (...children: Buffer[]): Buffer => tlv(0x31, concat(...children)); +const integer = (value: number | Buffer): Buffer => + tlv(0x02, Buffer.isBuffer(value) ? value : Buffer.from([value])); +const encodeBase128 = (value: number): number[] => { + if (value < 0x80) return [value]; + const stack: number[] = []; + let remaining = value; + while (remaining > 0) { + stack.push(remaining & 0x7f); + remaining >>= 7; + } + stack.reverse(); + return stack.map((byte, index) => + index === stack.length - 1 ? byte : byte | 0x80, + ); +}; + +const oid = (parts: readonly number[]): Buffer => { + const encoded: number[] = [40 * parts[0]! + parts[1]!]; + for (const part of parts.slice(2)) encoded.push(...encodeBase128(part)); + return tlv(0x06, Buffer.from(encoded)); +}; +const utf8 = (value: string): Buffer => tlv(0x0c, Buffer.from(value, "utf8")); +const utc = (value: string): Buffer => tlv(0x17, Buffer.from(value, "ascii")); +const nul = (): Buffer => tlv(0x05, Buffer.alloc(0)); +const octet = (value: Buffer): Buffer => tlv(0x04, value); +const bitString = (value: Buffer): Buffer => + tlv(0x03, concat(Buffer.from([0]), value)); +const context = (number: number, content: Buffer, constructed = true): Buffer => + tlv((constructed ? 0xa0 : 0x80) | number, content); + +const SHA256_RSA = oid([1, 2, 840, 113549, 1, 1, 11]); +const SHA256 = oid([2, 16, 840, 1, 101, 3, 4, 2, 1]); +const RSA = oid([1, 2, 840, 113549, 1, 1, 1]); +const SIGNED_DATA = oid([1, 2, 840, 113549, 1, 7, 2]); +const DATA = oid([1, 2, 840, 113549, 1, 7, 1]); +const CN = oid([2, 5, 4, 3]); +const O = oid([2, 5, 4, 10]); + +const attribute = (type: Buffer, value: Buffer): Buffer => seq(type, value); +const rdn = (...attributes: Buffer[]): Buffer => set(...attributes); +const name = (commonName: string, organization: string): Buffer => + seq( + rdn(attribute(CN, utf8(commonName))), + rdn(attribute(O, utf8(organization))), + ); + +const algorithm = (identifier: Buffer): Buffer => seq(identifier, nul()); + +/** Hand-authored PKCS#7 signedData, independent of the production decoder. */ +export const pkcs7SignedDataFixture = ( + commonName = "REA Fixture", + organization = "REA Tests", +): Buffer => { + const serial = Buffer.from([0x11, 0x22, 0x33, 0x44]); + const subject = name(commonName, organization); + const tbs = seq( + context(0, integer(2)), + integer(serial), + algorithm(SHA256_RSA), + subject, + seq(utc("250101000000Z"), utc("260101000000Z")), + subject, + seq(algorithm(RSA), bitString(Buffer.from("pubkey"))), + context( + 3, + seq(seq(oid([2, 5, 29, 14]), octet(Buffer.from([0x04, 0x01, 0xaa])))), + ), + ); + const certificate = seq( + tbs, + algorithm(SHA256_RSA), + bitString(Buffer.from("signature")), + ); + const signer = seq( + integer(1), + seq(subject, integer(serial)), + algorithm(SHA256), + algorithm(SHA256_RSA), + octet(Buffer.from("sig")), + ); + const signedData = seq( + integer(1), + set(algorithm(SHA256)), + seq(DATA), + context(0, certificate), + set(signer), + ); + return seq(SIGNED_DATA, context(0, signedData)); +}; + +/** Minimal PE32/PE32+ image with an overlay IMAGE_DIRECTORY_ENTRY_SECURITY table. */ +export const peCertificateFixture = ( + plus = false, + payload: Buffer = pkcs7SignedDataFixture(), + type = 0x0002, +): Buffer => { + const headerBytes = 0x200; + const alignedPayload = payload; + const entryLength = 8 + alignedPayload.length; + const padded = align8(entryLength); + const table = Buffer.alloc(padded); + table.writeUInt32LE(entryLength, 0); + table.writeUInt16LE(0x0200, 4); + table.writeUInt16LE(type, 6); + alignedPayload.copy(table, 8); + const bytes = Buffer.alloc(headerBytes + table.length); + bytes.writeUInt16LE(0x5a4d, 0); + bytes.writeUInt32LE(0x80, 60); + bytes.writeUInt32LE(0x4550, 0x80); + bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, 0x84); + bytes.writeUInt16LE(1, 0x86); + const optionalSize = plus ? 240 : 224; + bytes.writeUInt16LE(optionalSize, 0x94); + bytes.writeUInt16LE(plus ? 0x20b : 0x10b, 0x98); + bytes.writeUInt32LE(headerBytes, 0x98 + 60); + bytes.writeUInt32LE(16, 0x98 + (plus ? 108 : 92)); + const directoryAt = 0x98 + (plus ? 112 : 96) + 4 * 8; + bytes.writeUInt32LE(headerBytes, directoryAt); + bytes.writeUInt32LE(table.length, directoryAt + 4); + table.copy(bytes, headerBytes); + return bytes; +}; + +const align8 = (value: number): number => Math.ceil(value / 8) * 8; + +/** PE image whose optional header admits no security data directory. */ +export const peWithoutCertificateTableFixture = (plus = false): Buffer => { + const bytes = Buffer.alloc(0x200); + bytes.writeUInt16LE(0x5a4d, 0); + bytes.writeUInt32LE(0x80, 60); + bytes.writeUInt32LE(0x4550, 0x80); + bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, 0x84); + bytes.writeUInt16LE(1, 0x86); + const optionalSize = plus ? 240 : 224; + bytes.writeUInt16LE(optionalSize, 0x94); + bytes.writeUInt16LE(plus ? 0x20b : 0x10b, 0x98); + bytes.writeUInt32LE(0x200, 0x98 + 60); + bytes.writeUInt32LE(4, 0x98 + (plus ? 108 : 92)); + return bytes; +}; diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts new file mode 100644 index 0000000000..bd64e37c05 --- /dev/null +++ b/src/native/pe/PeCertificatesProvider.ts @@ -0,0 +1,201 @@ +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + ArtifactFileCleanupFailure, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import type { PeCertificatesPort } from "../../application/binaryDiagnostics/PeCertificatesPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { + MAX_INSPECT_PE_CERTIFICATE_ENTRIES, + MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES, + type InspectPeCertificatesInput, +} from "../../domain/native/peCertificates.js"; +import { err, ok } from "../../domain/result.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { PE_CERTIFICATES_PROVIDER } from "../../application/InvestigationProviders.js"; +import { parsePeCertificates } from "./PeCertificateParser.js"; + +const OPERATION = "inspect_pe_certificates"; + +/** Portable parser over one bounded, identity-checked regular file. */ +export class PeCertificatesProvider implements PeCertificatesPort { + readonly identity = PE_CERTIFICATES_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_CERTIFICATES_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect(input: InspectPeCertificatesInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + parsePeCertificates( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + ), + ); + }); + } catch (cause) { + return err(peCertificatesFailure(cause, input, options?.signal)); + } + } +} + +const inputBudgetRemediation = ( + boundary: "file-size" | "capacity", + input: InspectPeCertificatesInput, +): string => + boundary === "file-size" + ? input.max_file_bytes >= MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES + ? `The image exceeds the ${String(MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES)}-byte input ceiling this operation accepts; 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.` + : input.max_entries >= MAX_INSPECT_PE_CERTIFICATE_ENTRIES + ? `The image exceeds the ${String(MAX_INSPECT_PE_CERTIFICATE_ENTRIES)}-entry certificate-table ceiling this operation accepts; select a smaller artifact.` + : `Raise max_entries above ${String(input.max_entries)} or select a smaller artifact; the limit is the caller-selected certificate entry budget.`; + +const peCertificatesFailurePrimary = ( + cause: unknown, + input: InspectPeCertificatesInput, +) => { + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { + cause, + }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + cause.boundary ?? "memory", + cause.message, + { + max_file_bytes: input.max_file_bytes, + max_entries: input.max_entries, + }, + { + cause, + ...(cause.boundary === undefined || cause.boundary === "memory" + ? {} + : { + retryable: false, + remediationAction: inputBudgetRemediation( + cause.boundary, + 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 ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected PE image could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ArtifactOperationError( + OPERATION, + cause instanceof ArtifactReaderFailure ? cause.reason : "io", + undefined, + cause instanceof Error + ? cause.message + : "PE certificate inspection failed.", + { cause }, + ); +}; + +const peCertificatesFailure = ( + cause: unknown, + input: InspectPeCertificatesInput, + signal: AbortSignal | undefined, +) => { + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? peCertificatesFailurePrimary( + cause.fileCleanupFailure.outcome.cause, + input, + ) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError(OPERATION, { cause: signal.reason }) + : peCertificatesFailurePrimary(cause, input); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + PE_CERTIFICATES_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/PeResourceLayout.ts b/src/native/pe/PeResourceLayout.ts index 8484ec4baf..5941bb4b52 100644 --- a/src/native/pe/PeResourceLayout.ts +++ b/src/native/pe/PeResourceLayout.ts @@ -51,8 +51,8 @@ const intersectsResource = ( ); }; -/** Header admission and unambiguous file-backed RVA mapping, independent of section names. */ -export const readPeResourceLayout = (bytes: Buffer) => { +/** Shared PE header admission: signature, optional header, data directories, and a section table that fits inside SizeOfHeaders. */ +export const readPeHeaders = (bytes: Buffer) => { requirePeRange(bytes, 0, 64); if (bytes.readUInt16LE(0) !== 0x5a4d) peFailure("Expected an MZ PE image."); const pe = bytes.readUInt32LE(60); @@ -78,6 +78,30 @@ export const readPeResourceLayout = (bytes: Buffer) => { requirePeRange(bytes, table, sectionCount * 40); if (headerSize < table + sectionCount * 40 || headerSize > bytes.length) peFailure("Invalid PE SizeOfHeaders."); + return { + pe, + sectionCount, + optional, + magic, + directoriesAt, + directoryCount, + headerSize, + table, + }; +}; + +/** Header admission and unambiguous file-backed RVA mapping, independent of section names. */ +export const readPeResourceLayout = (bytes: Buffer) => { + const { + pe, + sectionCount, + optional, + magic, + directoriesAt, + directoryCount, + headerSize, + table, + } = readPeHeaders(bytes); const sections: Section[] = []; for (let index = 0; index < sectionCount; index++) { const at = table + index * 40; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index cc9be59848..ca912ac282 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -14,13 +14,16 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createElfNotesService, + createPeCertificatesService, createNativeExportsService, createPeResourcesService, } from "../composition/binaryDiagnostics.js"; import type { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; +import type { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.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 { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import { registerNativeExportsTool } from "./registerNativeExportsTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; @@ -360,6 +363,7 @@ export const createServer = ( const peResources = createPeResourcesService(); const nativeExports = createNativeExportsService(); const elfNotes = createElfNotesService(); + const peCertificates = createPeCertificatesService(); const electronFuses = options.electronFuses ?? createElectronFuseService(); const webNetworkCapture = options.webNetworkCapture ?? createWebNetworkCaptureService(environment); @@ -463,6 +467,7 @@ export const createServer = ( peResources.close(), nativeExports.close(), elfNotes.close(), + peCertificates.close(), electronFuses.close(), webNetworkCapture.close(), javascriptRecovery.close?.() ?? Promise.resolve(), @@ -528,6 +533,7 @@ export const createServer = ( peResources, nativeExports, elfNotes, + peCertificates, electronFuses, webNetworkCapture, }; @@ -761,6 +767,7 @@ const registerConfiguredAnalysisTools = ( peResources, nativeExports, elfNotes, + peCertificates, webNetworkCapture, artifactResources, }: ServerToolContext, @@ -824,6 +831,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerPeCertificatesTool( + server, + peCertificates, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerGoTools( server, goBinary, @@ -959,6 +973,7 @@ interface ServerToolContext extends ReturnType { readonly peResources: PeResourcesService; readonly nativeExports: NativeExportsService; readonly elfNotes: ElfNotesService; + readonly peCertificates: PeCertificatesService; readonly electronFuses: ElectronFuseService; readonly webNetworkCapture: WebNetworkCaptureService; readonly artifactResources: ArtifactResourceScope; diff --git a/src/server/registerPeCertificatesTool.ts b/src/server/registerPeCertificatesTool.ts new file mode 100644 index 0000000000..80da753b8f --- /dev/null +++ b/src/server/registerPeCertificatesTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.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 certificate-table workflow and retain its complete Evidence. */ +export const registerPeCertificatesTool = ( + server: EvidenceMcpServer, + service: PeCertificatesService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_pe_certificates"); + 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/peCertificatesCli.test.ts b/tests/acceptance/peCertificatesCli.test.ts new file mode 100644 index 0000000000..0a20398c46 --- /dev/null +++ b/tests/acceptance/peCertificatesCli.test.ts @@ -0,0 +1,64 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + peCertificateFixture, + peWithoutCertificateTableFixture, +} from "../../src/native/pe/PeCertificates.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "reports PKCS#7 signer identity through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-cert-cli-"); + const path = join(root, "signed.exe"); + await writeFile(path, peCertificateFixture()); + const result = await cli.run({ + arguments: ["inspect-pe-certificates", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_pe_certificates").outputSchema.parse( + result.json, + ); + expect( + evidence.normalized_result.entries[0]?.pkcs7?.certificates[0]?.serial_hex, + ).toBe("11223344"); + }, +); + +cliTest( + "resolves a relative PE path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-cert-cli-rel-"); + await writeFile( + join(root, "unsigned.exe"), + peWithoutCertificateTableFixture(), + ); + const result = await cli.run({ + arguments: ["inspect-pe-certificates", "unsigned.exe", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_pe_certificates").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.table).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-cert-cli-bad-"); + const path = join(root, "broken.exe"); + await writeFile(path, Buffer.from("MZ")); + const result = await cli.run({ + arguments: ["inspect-pe-certificates", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_pe_certificates" }, + }); +}); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index b8d6b5baf5..d1d654c7b2 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -218,6 +218,7 @@ const pathRequests: readonly [string, Record][] = [ { capture_path: nul, output_directory: "/tmp/exported-scripts" }, ], ["inspect_pe_resources", { path: nul }], + ["inspect_pe_certificates", { path: nul }], ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], ["inspect_binary_layout", { path: nul }], diff --git a/tests/boundary/filesystem/peCertificates.test.ts b/tests/boundary/filesystem/peCertificates.test.ts new file mode 100644 index 0000000000..3e09cfdd35 --- /dev/null +++ b/tests/boundary/filesystem/peCertificates.test.ts @@ -0,0 +1,57 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createPeCertificatesService } from "../../../src/composition/binaryDiagnostics.js"; +import { peCertificateFixture } from "../../../src/native/pe/PeCertificates.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("PE certificate stable filesystem boundary", () => { + it("reports non-file and malformed PE selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-pe-cert-files-"); + const path = join(root, "signed.exe"); + const alias = join(root, "alias.exe"); + const malformed = join(root, "malformed.exe"); + await writeFile(path, peCertificateFixture()); + await writeFile(malformed, Buffer.from("not a pe")); + await symlink(path, alias); + const service = createPeCertificatesService(); + 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_certificates", + issues: [ + { + path: ["path"], + reason, + message: expect.any(String), + }, + ], + }); + } + }); + + it("returns typed resource and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-pe-cert-budget-"); + const path = join(root, "signed.exe"); + const bytes = peCertificateFixture(); + await writeFile(path, bytes); + const service = createPeCertificatesService(); + 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"); + }); +}); diff --git a/tests/boundary/mcp/peCertificatesMcp.test.ts b/tests/boundary/mcp/peCertificatesMcp.test.ts new file mode 100644 index 0000000000..88da46dc15 --- /dev/null +++ b/tests/boundary/mcp/peCertificatesMcp.test.ts @@ -0,0 +1,72 @@ +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 { peCertificateFixture } from "../../../src/native/pe/PeCertificates.fixture.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 certificate inspection must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_pe_certificates and reads PKCS#7 signer identity", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_pe_certificates", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-pe-cert-mcp-"); + const path = join(root, "signed.exe"); + await writeFile(path, peCertificateFixture(true)); + const response = await client.callTool({ + name: "inspect_pe_certificates", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract( + "inspect_pe_certificates", + ).outputSchema.parse(response.structuredContent); + expect(evidence.normalized_result.format).toBe("pe32-plus"); + expect( + evidence.normalized_result.entries[0]?.pkcs7?.certificates[0] + ?.serial_hex, + ).toBe("11223344"); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_pe_certificates", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_pe_certificates", + arguments: { path: "signed.exe" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_pe_certificates" }, + }); + } finally { + await session.close(); + } + }, +);