diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 4784bfeabb..b98687ef39 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -276,6 +276,15 @@ explicit path or endpoint and do not need it. linker declarations, not proof of enforcement. A missing directory is observed absence of that declaration. See the [PE load configuration guide](https://github.com/morluto/rea/blob/main/docs/pe-load-config.md). +- ELF, PE, and Mach-O hardening: `inspect_binary_hardening` with the explicit + local file. This built-in target-free inspection requires no pwntools or other + host tool. One image per Mach-O slice. Each property is `enabled`, `disabled`, + `partial`, `unknown`, or `not_applicable`, with the header field or import + behind it. A missing stack-protector or fortify import is `unknown`, not + proof the protection was omitted. Authenticode and Mach-O code signatures + report presence, not validity. Hardened runtime stays unknown and names + `inspect_signature`. The image is not executed. See the + [binary hardening guide](https://github.com/morluto/rea/blob/main/docs/binary-hardening.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 5e854285d2..fa969b5222 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -155,6 +155,18 @@ mitigations carry `evidence_kind: "inferred"`; enforcement remains unobserved. See [PE TLS callbacks and load configuration](https://github.com/morluto/rea/blob/main/docs/pe-load-config.md). +## Binary hardening + +Use `inspect_binary_hardening` for an explicit local ELF, PE, or Mach-O file +when the question concerns PIE or ASLR, NX, RELRO, stack-protector or FORTIFY +signs, control-flow flags, or stripped state. No `open_binary`, pwntools, or +target execution is required. The CLI equivalent is +`inspect-binary-hardening PATH`. Universal Mach-O files return one image per +slice. A missing import is `unknown`, not proof the protection was omitted. +Authenticode and Mach-O code-signature rows report presence, not validity. +`hardened_runtime` stays unknown and names `inspect_signature`. See +[binary hardening](https://github.com/morluto/rea/blob/main/docs/binary-hardening.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 a6343e167a..4a3ea462e7 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ Additional tools and host support depend on the target: | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](docs/pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](docs/elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](docs/binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and documented crash classes | Built-in static JSON inspection; [Apple IPS](docs/apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](docs/kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, imports, lambdas and the Code header | Built-in static BEAM inspection; [Erlang BEAM](docs/erlang-beam.md) | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d121d861e5..a438e9257d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -36,6 +36,7 @@ export default defineConfig({ { text: "PE imports", link: "/pe-imports" }, { text: "PE TLS/load config", link: "/pe-load-config" }, { text: "ELF notes", link: "/elf-notes" }, + { text: "Binary hardening", link: "/binary-hardening" }, { text: "Apple IPS crash reports", link: "/apple-ips" }, { text: "Linux kernel modules", link: "/kernel-modules" }, { text: "CPython pyc files", link: "/python-pyc" }, diff --git a/docs/binary-hardening.md b/docs/binary-hardening.md new file mode 100644 index 0000000000..2d54d1cda9 --- /dev/null +++ b/docs/binary-hardening.md @@ -0,0 +1,62 @@ +# Binary hardening + +Use `inspect_binary_hardening` to read compile-time and link-time hardening from +an explicit local ELF, PE, or Mach-O file. No disassembler, pwntools install, or +target execution is required. Universal Mach-O files return one image per slice. + +```sh +rea inspect-binary-hardening /absolute/path/to/program --json +rea inspect-binary-hardening /absolute/path/to/program --max-file-bytes 1048576 --json +``` + +MCP accepts `{ "path": "/absolute/path/to/program" }`. CLI paths may be relative +to the current directory; MCP paths must be absolute. + +Each image has one row per property: `property`, `state`, `confidence`, `basis`, +`location`, `note`, and `next_probe`. States are `enabled`, `disabled`, +`partial`, `unknown`, and `not_applicable`. `confidence` is `observed` for a +single header or import, and `derived` when the state combines several fields, +such as ELF PIE or full RELRO. An image's `architecture` is its CPU family, or +null when REA has no family for it. `machine` and `slice.architecture` keep the +exact identity, such as `arm64e`. + +ELF rows cover PIE (`ET_DYN` with `PT_INTERP` or `DF_1_PIE`), NX (`PT_GNU_STACK`; +a missing header is `unknown` and the architecture default is only noted), RELRO +(`PT_GNU_RELRO` plus `BIND_NOW`), stack protector and FORTIFY undefined dynamic +imports, CET and AArch64 BTI/PAC GNU property notes, `DT_RPATH`, `DT_RUNPATH`, and +whether a `SHT_SYMTAB` section is present. Only an undefined dynamic import +counts. Imports come from the table `DT_SYMTAB` declares. A `SHT_DYNSYM` section +only supplies the symbol count when `DT_HASH` and `DT_GNU_HASH` leave it open, +and only if it names the same symbol and string tables. Without that count the +import rows are `unknown` and give the reason. A missing `__stack_chk_fail` or fortify import is `unknown`. That +absence does not establish that the protection was left out of a static or +stripped build. A present import is a sign that some code uses the protection, +not proof that all code does. NX, RELRO, `DT_RPATH`, and `DT_RUNPATH` are +`not_applicable` when `e_type` is neither `ET_EXEC` nor `ET_DYN`, and `unknown` +for a linked image with no readable program headers. + +PE rows read `DllCharacteristics` for dynamic base, high-entropy VA, NX +compatibility, force integrity, no SEH, and guard CF. `cfg_instrumented` is the +load-config `GuardFlags` linker claim when `Size` covers that field. An +unreadable load configuration, including a TLS failure in that parser, leaves +only `cfg_instrumented` `unknown`. Authenticode and debug rows report certificate +and debug directory presence, not signature validity or debug content. A +directory whose address and size disagree is `unknown`. + +Mach-O rows read `MH_PIE`, `MH_ALLOW_STACK_EXECUTION`, `MH_NO_HEAP_EXECUTION`, +an `__RESTRICT` segment, `LC_CODE_SIGNATURE` presence, encryption from the +existing encryption reader, the arm64e subtype, and a stack-protector import. +`hardened_runtime` is `unknown` with `next_probe` `inspect_signature`. +CodeDirectory flags stay with that tool. + +The report states presence of build-time features. It does not state +exploitability. `inspect_binary_layout` is unchanged. + +The default read budget is 64 MiB (`max_file_bytes`, ceiling 512 MiB). A changed +file is refused. A malformed header, a Mach-O segment or encryption command that fails admission, or a program, section, +or segment payload declared outside the file returns no partial report, except that one bad slice in a universal Mach-O leaves the other slices. A symbol table or +symbol name over its budget leaves the import rows `unknown` and keeps the +header rows. A GNU property note over its budget, or a compressed note section +that may hold one, leaves the CET and BTI/PAC rows `unknown` and keeps the header +rows. A dynamic-table budget still fails the ELF report. The image is never +executed. diff --git a/docs/cli.md b/docs/cli.md index dedf9f638d..aebeca4b8f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1416,6 +1416,16 @@ Reports TLS callbacks and size-covered load-config fields without execution. Use `--max-callbacks` and `--max-file-bytes` to set read budgets. See [PE TLS and load configuration](pe-load-config.md). +### Binary hardening + +```sh +rea inspect-binary-hardening /absolute/path/to/program --json +``` + +Reports ELF, PE, and Mach-O hardening rows without execution. A missing import +is `unknown`, not `disabled`. Use `--max-file-bytes` to set the read budget. See +[binary hardening](binary-hardening.md). + Bun standalone executables use the existing `inspect-artifact` and `extract-artifact` commands to inventory and materialize embedded source, sourcemaps and bytecode without running the executable. See diff --git a/docs/index.md b/docs/index.md index b977622e0f..88c4ec2ec1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Task | Guides | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Binary hardening](binary-hardening.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.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/readme/README_ar.md b/docs/readme/README_ar.md index e4e4c2dd84..59737b5315 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -160,6 +160,7 @@ npx rea-agents@latest setup | شيفرة 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 1301036c8d..0b5fab6856 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -160,6 +160,7 @@ REA benötigt Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) oder 26+ (>=26.12.1) so | 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index e5e450cce2..a7d6ca940c 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -160,6 +160,7 @@ npx rea-agents@latest setup | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 0d276c8a42..1cd19d75da 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -160,6 +160,7 @@ REA requiere Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), adem | 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index 494317aa50..996917f468 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -152,6 +152,7 @@ REA به Node.js 22.x (>=22.23.4)، 24.x (>=24.22.1) یا 26+ (>=26.12.1)، به | بایت‌کد 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index 2f165982fe..764a9f7867 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -160,6 +160,7 @@ REA nécessite Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), ai | 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) | | 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index b283dba9b3..c252f40de7 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -163,6 +163,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal di Linux x64; [panduan 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index ad7f25de50..790eca557b 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -160,6 +160,7 @@ REA richiede Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), oltre | Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale su Linux x64; [guida 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index 1918517ff6..cc3a51a08a 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -160,6 +160,7 @@ REA には Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)、または 26+ (>= | 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 4a4755cb04..6621cf498f 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -160,6 +160,7 @@ REA에는 Node.js 22.x(>=22.23.4), 24.x(>=24.22.1) 또는 26+ (>=26.12.1)와 npm | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index 05e2d110a2..18afde3a3b 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -160,6 +160,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.23.4), 24.x (>=24.22.1), അല്ല | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 590f32e636..cd7b771766 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -160,6 +160,7 @@ REA wymaga Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) lub 26+ (>=26.12.1) oraz n | 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 366325f3fc..9075e20f51 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -160,6 +160,7 @@ O REA requer Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), alé | 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_ro.md b/docs/readme/README_ro.md index c16fde180d..d242a6b48b 100644 --- a/docs/readme/README_ro.md +++ b/docs/readme/README_ro.md @@ -200,6 +200,7 @@ Uneltele suplimentare și platformele acceptate depind de ținta analizată: | Certificate PE | Intrări Authenticode WIN_CERTIFICATE și câmpuri de identitate ale semnatarilor PKCS#7 și certificatelor X.509 | Inspectare statică integrată a fișierelor PE; [certificate PE](../pe-certificates.md) | | Importuri PE | Numele DLL-urilor importate și încărcate întârziat, ordinale, RVA-uri ale IAT și hash-ul importurilor calculat de pefile | Inspectare statică integrată a fișierelor PE; [importuri PE](../pe-imports.md) | | Note ELF | Proprietari și tipuri PT_NOTE/SHT_NOTE, câmpuri GNU build-id și ABI-tag | Inspectare statică integrată a fișierelor ELF; [note ELF](../elf-notes.md) | +| Întărire binară | PIE, NX, RELRO, CFG și indicatori de antet pentru ELF, PE și Mach-O | Inspecție statică integrată a antetelor; [întărire binară](../binary-hardening.md) | | Rapoarte Apple IPS de eroare fatală | Metadate .ips, fire de execuție, imagini binare, registre și clase documentate de erori fatale | Inspectare statică integrată a datelor JSON; [Apple IPS](../apple-ips.md) | | Module de kernel Linux | Chei .modinfo, CRC-uri din __versions, prezența this_module și structura semnăturii de la sfârșitul fișierului | Inspectare statică integrată a fișierelor ELF; [module de kernel](../kernel-modules.md) | | Erlang BEAM | Blocuri IFF, atomi, exporturi, importuri, funcții lambda și antetul Code | Inspectare statică integrată a fișierelor BEAM; [Erlang BEAM](../erlang-beam.md) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index b873e2cd0f..968e4940cd 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -160,6 +160,7 @@ REA требует Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) или 26+ (>=26.1 | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 9621f78793..576f25e7e1 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -160,6 +160,7 @@ REA ต้องใช้ Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) หรื | ไบต์โค้ด 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 58b79a5428..e834852924 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -158,6 +158,7 @@ REA; Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) veya 26+ (>=26.12.1) ve npm gere | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index a181184b4b..f33cbdfb21 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -160,6 +160,7 @@ REA потребує Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) або 26+ (>=26 | Байт-код 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) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index cff973fdbf..3b246677fa 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -200,6 +200,7 @@ Mazeed tools aur host support target par depend karte hain: | PE certificates | Authenticode WIN_CERTIFICATE entries aur PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import aur delay-load DLL names, ordinals, IAT RVAs aur pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id aur ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, imports, lambdas aur Code header | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artifact | Validation, sections, exact import/export WAT forms aur decoded WAT | Aapka diya hua WABT 1.0.42; `inspect_wasm_artifact` | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index dd1ed47c8d..835d5be563 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -160,6 +160,7 @@ REA yêu cầu Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) hoặc 26+ (>=26.12.1) | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index a01bb80e28..168a95488d 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -161,6 +161,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 5a0609472e..43bcc81e9c 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -161,6 +161,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | diff --git a/docs/verification/binary-hardening-2026-10-31.md b/docs/verification/binary-hardening-2026-10-31.md new file mode 100644 index 0000000000..22c4f5de3b --- /dev/null +++ b/docs/verification/binary-hardening-2026-10-31.md @@ -0,0 +1,121 @@ +# Binary hardening verification - 2026-10-31 + +Command: `npm run verify:binary-hardening` + +`package.json` maps that script to `node scripts/verify-binary-hardening.mjs`. +The script loads its TypeScript sources with `importVerifierSource`. Its stderr +printed `ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time`. +The process exited 0. No target was executed. `readelf` was not present, so +this run did not compare ELF rows with readelf output. + +Host from the script JSON: `darwin arm64`. `node` was `v24.22.1`. The script's +`npm -v` line was `11.16.0`. + +## Tools the script found + +`xcrun --find` from the script: + +| Name | Status | Path | +| -------------- | -----: | ---------------------------------------------------------------------------------------------- | +| `readelf` | 72 | none | +| `llvm-readelf` | 72 | none | +| `clang` | 0 | `/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang` | +| `gcc` | 0 | `/Applications/Xcode.app/Contents/Developer/usr/bin/gcc` | +| `cc` | 0 | `/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/cc` | +| `ld` | 0 | `/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld` | + +The stored `readelf` stderr was `xcrun: error: sh -c '/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk -find readelf 2> /dev/null' failed with exit code 17664: `. +The stored `llvm-readelf` stderr was `xcrun: error: sh -c '/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk -find llvm-readelf 2> /dev/null' failed with exit code 17`. +The script keeps 240 characters of that stderr. + +Compilers selected, with the version line the script ran: + +| Name | Command | Version line | +| ------------------ | ----------------------- | ------------------------------------------------- | +| `gcc` | `/usr/bin/gcc` | `Apple clang version 21.0.0 (clang-2100.1.1.101)` | +| `clang-linux-gnu` | `/usr/bin/clang` | `Apple clang version 21.0.0 (clang-2100.1.1.101)` | +| `clang-linux-musl` | `/usr/bin/clang` | `Apple clang version 21.0.0 (clang-2100.1.1.101)` | +| `zig-linux-gnu` | `/opt/homebrew/bin/zig` | `0.16.0` | +| `zig-linux-musl` | `/opt/homebrew/bin/zig` | `0.16.0` | + +`x86_64-linux-gnu-gcc`, `aarch64-linux-gnu-gcc`, and `musl-gcc` were not in +that list. The script recorded `elf-readelf`: `No readelf or llvm-readelf was found. Compiled ELF images are still parsed.` + +For each variant the script tries later compilers when an earlier one does not +emit the requested ELF feature. It stops that variant at the first ELF whose +parsed rows meet the feature. `execstack` needs NX `disabled`. `stack-protector` +needs `stack_protector` `enabled`. Other variants accept the first ELF. A +missing feature is recorded and does not fail the process. A present `readelf` +that disagrees still fails the process. This run had no `readelf`. + +`DT_NEEDED` and build-id below are the script's own little-endian ELF reads, +because `readelf` was absent. + +## Synthetic PE + +`peHardeningImage()` built a PE32+ fixture. `readPeHardening` reported bits 64 +and these states: + +| Property | State | +| ---------------------- | -------- | +| `dynamic_base` | enabled | +| `high_entropy_va` | enabled | +| `nx_compat` | enabled | +| `force_integrity` | disabled | +| `no_seh` | disabled | +| `guard_cf` | enabled | +| `cfg_instrumented` | enabled | +| `authenticode_present` | disabled | +| `debug_record_present` | disabled | + +## ELF + +`/usr/bin/gcc` compiled `pie`, `no-pie`, `stack-protector`, `dynamic`, and +`two-needed`, and the script skipped each with `compiler did not emit ELF`. +The same compiler exited 1 for `relro-now`, `relro`, `no-relro`, `execstack`, +and `build-id`. Those stderrs named unused `-pie` and `ld: unknown options` +for `-z` or `--build-id=sha1`. + +`/usr/bin/clang --target=x86_64-linux-gnu` and +`/usr/bin/clang --target=x86_64-linux-musl` exited 1 for every variant. The +`pie` stderr was `ld: unknown options: --hash-style=gnu --eh-frame-hdr -dynamic-linker --as-needed --no-as-needed --as-needed --no-as-needed` and +`clang: error: linker command failed with exit code 1 (use -v to see invocation)`. + +`zig cc -target x86_64-linux-gnu` emitted an ELF for every variant except +`execstack`. The script stopped those variants there, except `stack-protector`, +where `stack_protector` stayed `unknown`, so it also parsed +`zig cc -target x86_64-linux-musl`. + +| Variant | Compiler | Bytes | `DT_NEEDED` | Build-id | PIE | NX | RELRO | Stack protector | Stripped | CET | +| ----------------- | -------------- | ------: | ----------: | -------- | -------- | ------- | -------- | --------------- | -------- | -------- | +| `pie` | zig-linux-gnu | 3792792 | 3 | no | enabled | enabled | enabled | unknown | disabled | disabled | +| `no-pie` | zig-linux-gnu | 3788400 | 3 | no | disabled | enabled | enabled | unknown | disabled | disabled | +| `relro-now` | zig-linux-gnu | 3792792 | 3 | no | enabled | enabled | enabled | unknown | disabled | disabled | +| `relro` | zig-linux-gnu | 3792768 | 3 | no | enabled | enabled | partial | unknown | disabled | disabled | +| `no-relro` | zig-linux-gnu | 3792600 | 3 | no | enabled | enabled | disabled | unknown | disabled | disabled | +| `stack-protector` | zig-linux-gnu | 3792792 | 3 | no | enabled | enabled | enabled | unknown | disabled | disabled | +| `stack-protector` | zig-linux-musl | 4476552 | 0 | no | enabled | enabled | enabled | unknown | disabled | disabled | +| `build-id` | zig-linux-gnu | 3792912 | 3 | yes | enabled | enabled | enabled | unknown | disabled | disabled | +| `dynamic` | zig-linux-gnu | 3792792 | 3 | no | enabled | enabled | enabled | unknown | disabled | disabled | +| `two-needed` | zig-linux-gnu | 3792952 | 5 | no | enabled | enabled | enabled | unknown | disabled | disabled | + +`build-id` note alignments recorded by the script were `4:32` and `4:36`. +`cet_ibt_shstk` on that image was `disabled`. + +`stack-protector` was recorded unavailable: `No compiler emitted an ELF with an undefined dynamic __stack_chk_fail, so stack_protector enabled was not observed.` + +`execstack` (`-Wl,-z,execstack`) produced no ELF. Both zig targets exited 1 +with `error: unsupported linker extension flag: -z execstack`. The script +recorded: `No compiler emitted an ELF whose PT_GNU_STACK is executable, so NX disabled was not observed.` +NX `disabled` was not exercised by a real ELF on this host. + +## Mach-O + +`/usr/bin/cc -arch arm64` compiled one image. `cc --version` printed +`Apple clang version 21.0.0 (clang-2100.1.1.101)`. REA reported one image, PIE +`enabled`, `hardened_runtime` `unknown`. `/usr/bin/otool -hv` agreed +(`otoolPie: true`). `otool -version` exited 1. The stored text begins +"error: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/otool: unknown char `s' in flag -version". + +`/usr/bin/cc -arch x86_64 -arch arm64` produced a universal image. REA returned +two parsed slices, `x86_64` and `arm64`, both with PIE `enabled`. diff --git a/package.json b/package.json index 85bf319342..06654f2d42 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,7 @@ "verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs", "verify:pcap": "npm run build:cached && node scripts/verify-pcap.mjs", "verify:pe:load-config": "npm run build:cached && node scripts/verify-pe-load-config.mjs", + "verify:binary-hardening": "node scripts/verify-binary-hardening.mjs", "verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", diff --git a/scripts/verify-binary-hardening.mjs b/scripts/verify-binary-hardening.mjs new file mode 100644 index 0000000000..fcd25374e1 --- /dev/null +++ b/scripts/verify-binary-hardening.mjs @@ -0,0 +1,633 @@ +/** Compare binary hardening rows with host toolchains when they exist. */ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; +import { importVerifierSource } from "./lib/verifier-source-loader.mjs"; +import { createVerifierCommands } from "./lib/verify-commands.mjs"; + +const { readElfHardening } = await importVerifierSource( + "../src/native/hardening/ElfHardening.ts", + import.meta.url, +); +const { readMachoHardening } = await importVerifierSource( + "../src/native/hardening/MachoHardening.ts", + import.meta.url, +); +const { readPeHardening } = await importVerifierSource( + "../src/native/hardening/PeHardening.ts", + import.meta.url, +); +const { peHardeningImage } = await importVerifierSource( + "../src/native/hardening/BinaryHardening.fixture.ts", + import.meta.url, +); +const { hasMachoMagic } = await importVerifierSource( + "../src/domain/apple/machoContainer.ts", + import.meta.url, +); + +/** + * Every probe and compiler driver runs as an owned process tree, so a timeout + * or close also stops the children a driver starts. Close it before removing + * the compiled outputs. + */ +const commands = await createVerifierCommands(); + +/** + * Ctrl-C or SIGTERM aborts the running command instead of ending Node, so the + * owned process trees and the compiled outputs are still cleaned up below. + */ +const interruption = new AbortController(); +const interrupt = (signal) => + interruption.abort( + new Error(`Binary hardening verifier interrupted by ${signal}`), + ); +process.on("SIGINT", interrupt); +process.on("SIGTERM", interrupt); + +/** Report an exit status the way the probes read it; other failures propagate. */ +const run = async (command, args) => { + try { + const { stdout, stderr } = await commands.exec(command, args, { + maxBuffer: 16 * 1024 * 1024, + timeout: 120_000, + signal: interruption.signal, + }); + return { status: 0, stdout, stderr }; + } catch (failure) { + if (typeof failure?.code === "number" && failure.code !== 0) + return { + status: failure.code, + stdout: failure.snapshot.stdout.text, + stderr: failure.snapshot.stderr.text, + }; + // A probe for an absent tool, such as xcrun off macOS, is not a failure. + if (failure?.code === "ENOENT") + return { status: null, stdout: "", stderr: String(failure.message) }; + throw failure; + } +}; + +const which = async (command) => { + const result = await run("which", [command]); + return result.status === 0 ? result.stdout.trim() : null; +}; + +const xcrunFind = async (name) => { + const result = await run("xcrun", ["--find", name]); + return { + name, + status: result.status, + path: result.status === 0 ? result.stdout.trim() : null, + stderr: (result.stderr ?? "").trim().slice(0, 240), + }; +}; + +const findTool = async (name) => + (await which(name)) ?? (await xcrunFind(name)).path; + +const versionLine = async (command, args = ["--version"]) => { + const result = await run(command, args); + return `${result.stdout ?? ""}\n${result.stderr ?? ""}` + .trim() + .split("\n")[0] + ?.slice(0, 240); +}; + +const statesOf = (rows) => + Object.fromEntries(rows.map((row) => [row.property, row.state])); + +// The root holds every compiled image. Remove it only after the owned +// commands have settled, and keep it when their cleanup failed. +let root; +let commandsClosed = false; +await withVerifierCleanup(async () => { + const report = { + host: `${process.platform} ${process.arch}`, + node: process.version, + npm: await versionLine("npm", ["-v"]), + xcrun: await Promise.all( + ["readelf", "llvm-readelf", "clang", "gcc", "cc", "ld"].map(xcrunFind), + ), + unavailable: [], + checks: [], + }; + + const synthetic = peHardeningImage(); + const peImage = await readPeHardening(synthetic, { + path: "/artifacts/synthetic.exe", + sha256: "a".repeat(64), + bytes: synthetic.length, + }); + const peStates = statesOf(peImage.rows); + assert.equal(peStates.dynamic_base, "enabled"); + assert.equal(peStates.high_entropy_va, "enabled"); + assert.equal(peStates.nx_compat, "enabled"); + assert.equal(peStates.force_integrity, "disabled"); + assert.equal(peStates.no_seh, "disabled"); + assert.equal(peStates.guard_cf, "enabled"); + assert.equal(peStates.cfg_instrumented, "enabled"); + assert.equal(peStates.authenticode_present, "disabled"); + assert.equal(peStates.debug_record_present, "disabled"); + report.checks.push({ + kind: "synthetic-pe", + bits: peImage.bits, + states: peStates, + }); + + const source = `int main(void) { + volatile char buf[64]; + buf[0] = 1; + return (int)buf[0]; + } + `; + + root = await mkdtemp(join(tmpdir(), "rea-binary-hardening-")); + const cFile = join(root, "sample.c"); + await writeFile(cFile, source); + await verifyElf(root, cFile, report); + await verifyMacho(root, cFile, report); + console.log(JSON.stringify(report, null, 2)); +}, [ + async () => { + await commands.close(); + commandsClosed = true; + }, + async () => { + if (commandsClosed && root !== undefined) + await rm(root, { recursive: true, force: true }); + }, + async () => { + process.off("SIGINT", interrupt); + process.off("SIGTERM", interrupt); + }, +]); +// A signal that lands outside a command, such as during the final rm, is still +// a failed run. An earlier failure already left through the rejection above. +if (interruption.signal.aborted) throw interruption.signal.reason; + +async function compilerCandidate( + name, + command, + prefix, + versionArgs = ["--version"], +) { + const located = await findTool(command); + if (located === null) return null; + return { name, command: located, prefix, versionArgs }; +} + +async function verifyElf(directory, cFile, output) { + const readelf = + (await findTool("readelf")) ?? (await findTool("llvm-readelf")); + const candidates = await Promise.all([ + compilerCandidate("x86_64-linux-gnu-gcc", "x86_64-linux-gnu-gcc", []), + compilerCandidate("aarch64-linux-gnu-gcc", "aarch64-linux-gnu-gcc", []), + compilerCandidate("musl-gcc", "musl-gcc", []), + compilerCandidate("gcc", "gcc", []), + compilerCandidate("clang-linux-gnu", "clang", [ + "--target=x86_64-linux-gnu", + ]), + compilerCandidate("clang-linux-musl", "clang", [ + "--target=x86_64-linux-musl", + ]), + compilerCandidate( + "zig-linux-gnu", + "zig", + ["cc", "-target", "x86_64-linux-gnu"], + ["version"], + ), + compilerCandidate( + "zig-linux-musl", + "zig", + ["cc", "-target", "x86_64-linux-musl"], + ["version"], + ), + ]); + const compilers = candidates.filter((candidate) => candidate !== null); + output.tools = { + ...output.tools, + readelf, + readelfVersion: + readelf === null ? null : await versionLine(readelf, ["--version"]), + compilers: await Promise.all( + compilers.map(async (compiler) => ({ + name: compiler.name, + command: compiler.command, + version: await versionLine(compiler.command, compiler.versionArgs), + })), + ), + }; + if (compilers.length === 0) { + output.unavailable.push({ + kind: "elf", + reason: + "No x86_64-linux-gnu-gcc, aarch64-linux-gnu-gcc, musl-gcc, gcc, clang, or zig was found.", + compilers: [], + readelf, + }); + return; + } + if (readelf === null) + output.unavailable.push({ + kind: "elf-readelf", + reason: + "No readelf or llvm-readelf was found. Compiled ELF images are still parsed.", + readelf: null, + }); + const variants = [ + { name: "pie", flags: ["-fPIE", "-pie"] }, + { name: "no-pie", flags: ["-fno-pie", "-no-pie"] }, + { name: "relro-now", flags: ["-fPIE", "-pie", "-Wl,-z,relro,-z,now"] }, + { name: "relro", flags: ["-fPIE", "-pie", "-Wl,-z,relro,-z,lazy"] }, + { name: "no-relro", flags: ["-fPIE", "-pie", "-Wl,-z,norelro"] }, + { + name: "stack-protector", + flags: ["-fPIE", "-pie", "-fstack-protector-strong"], + }, + { name: "execstack", flags: ["-fPIE", "-pie", "-Wl,-z,execstack"] }, + { name: "build-id", flags: ["-fPIE", "-pie", "-Wl,--build-id=sha1"] }, + { name: "dynamic", flags: ["-fPIE", "-pie", "-dynamic"] }, + { name: "two-needed", twoNeeded: true }, + ]; + for (const variant of variants) { + let met = false; + for (const compiler of compilers) { + const binary = join(directory, `${compiler.name}-${variant.name}`); + const compiled = variant.twoNeeded + ? await compileTwoNeeded(compiler, directory, binary) + : await run(compiler.command, [ + ...compiler.prefix, + ...variant.flags, + cFile, + "-o", + binary, + ]); + if (compiled.status !== 0) { + output.checks.push({ + kind: "elf-compile-skipped", + compiler: compiler.name, + variant: variant.name, + status: compiled.status, + stderr: (compiled.stderr ?? "").slice(0, 400), + }); + continue; + } + const header = await readFile(binary); + if (header.subarray(0, 4).toString("latin1") !== "\u007fELF") { + output.checks.push({ + kind: "elf-compile-skipped", + compiler: compiler.name, + variant: variant.name, + reason: "compiler did not emit ELF", + }); + continue; + } + const observed = observeElfTags(header); + let expected = null; + let needed = observed.needed; + let buildId = observed.buildId; + if (readelf !== null) { + const dumped = await run(readelf, [ + "--wide", + "-h", + "-l", + "-d", + "-S", + "-s", + "-n", + binary, + ]); + if (dumped.status !== 0) + throw new Error( + `${readelf} failed for ${variant.name}: ${dumped.stderr}`, + ); + expected = factsFromReadelf(dumped.stdout); + needed = dumped.stdout.match(/\(NEEDED\)/g)?.length ?? 0; + buildId = /Build ID|NT_GNU_BUILD_ID|GNU_BUILD_ID/i.test(dumped.stdout); + } + const image = readElfHardening(header); + const actual = statesOf(image.rows); + if (expected !== null) + assertElf(actual, expected, `${compiler.name} ${variant.name}`); + const satisfied = featureMet(variant.name, actual); + output.checks.push({ + kind: "elf", + compiler: compiler.name, + variant: variant.name, + bytes: header.length, + readelf, + expected, + actual, + needed, + buildId, + noteAlign: observed.noteAlign, + featureMet: satisfied, + }); + if (satisfied) { + met = true; + break; + } + } + if (!met) + output.unavailable.push({ + kind: "elf-feature", + variant: variant.name, + reason: unmetReason(variant.name), + }); + } +} + +async function compileTwoNeeded(compiler, directory, binary) { + const libaSource = join(directory, "liba.c"); + const libbSource = join(directory, "libb.c"); + const mainSource = join(directory, "needed-main.c"); + await writeFile(libaSource, "int a_fn(void) { return 1; }\n"); + await writeFile(libbSource, "int b_fn(void) { return 2; }\n"); + await writeFile( + mainSource, + "int a_fn(void);\nint b_fn(void);\nint main(void) { return a_fn() + b_fn(); }\n", + ); + const liba = join(directory, "liba.so"); + const libb = join(directory, "libb.so"); + const first = await run(compiler.command, [ + ...compiler.prefix, + "-shared", + "-fPIC", + libaSource, + "-o", + liba, + ]); + if (first.status !== 0) return first; + const second = await run(compiler.command, [ + ...compiler.prefix, + "-shared", + "-fPIC", + libbSource, + "-o", + libb, + ]); + if (second.status !== 0) return second; + return await run(compiler.command, [ + ...compiler.prefix, + "-fPIE", + "-pie", + "-dynamic", + "-L", + directory, + mainSource, + "-la", + "-lb", + "-o", + binary, + ]); +} + +/** Count DT_NEEDED and a 36-byte GNU build-id note in a little-endian ELF. */ +function observeElfTags(header) { + const empty = { needed: null, buildId: false, noteAlign: [] }; + if (header.length < 52 || header[0] !== 0x7f || header[5] !== 1) return empty; + const bits = header[4] === 2 ? 64 : header[4] === 1 ? 32 : 0; + if (bits === 0) return empty; + const u16 = (offset) => header.readUInt16LE(offset); + const u32 = (offset) => header.readUInt32LE(offset); + const u64 = (offset) => header.readBigUInt64LE(offset); + const word = (offset) => (bits === 64 ? u64(offset) : BigInt(u32(offset))); + const phoff = Number(bits === 64 ? u64(32) : u32(28)); + const shoff = Number(bits === 64 ? u64(40) : u32(32)); + const phentsize = u16(bits === 64 ? 54 : 42); + const phnum = u16(bits === 64 ? 56 : 44); + const shentsize = u16(bits === 64 ? 58 : 46); + const shnum = u16(bits === 64 ? 60 : 48); + let needed = null; + if ( + phentsize > 0 && + phnum > 0 && + phoff >= 0 && + phoff + phnum * phentsize <= header.length + ) { + for (let index = 0; index < phnum; index += 1) { + const at = phoff + index * phentsize; + if (u32(at) !== 2) continue; + needed ??= 0; + const off = Number(bits === 64 ? u64(at + 8) : u32(at + 4)); + const size = Number(bits === 64 ? u64(at + 32) : u32(at + 16)); + const width = bits === 64 ? 16 : 8; + if (off < 0 || size < 0 || off + size > header.length) continue; + for (let cursor = 0; cursor + width <= size; cursor += width) { + const tag = bits === 64 ? u64(off + cursor) : BigInt(u32(off + cursor)); + if (tag === 0n) break; + if (tag === 1n) needed += 1; + } + } + } + const noteAlign = []; + let buildId = false; + if ( + shentsize > 0 && + shnum > 0 && + shoff >= 0 && + shoff + shnum * shentsize <= header.length + ) { + for (let index = 0; index < shnum; index += 1) { + const at = shoff + index * shentsize; + if (u32(at + 4) !== 7) continue; + const off = Number(bits === 64 ? u64(at + 24) : u32(at + 16)); + const size = Number(word(bits === 64 ? at + 32 : at + 20)); + const align = word(bits === 64 ? at + 48 : at + 32); + noteAlign.push(`${align.toString()}:${String(size)}`); + if (off >= 0 && size >= 16 && off + 16 <= header.length) { + const owner = header.subarray(off + 12, off + 16).toString("latin1"); + if ( + u32(off) === 4 && + u32(off + 4) === 20 && + u32(off + 8) === 3 && + owner.startsWith("GNU") + ) + buildId = true; + } + } + } + return { needed, buildId, noteAlign }; +} + +function dynsymText(text) { + const marker = "Symbol table '.dynsym'"; + const start = text.indexOf(marker); + if (start < 0) return ""; + const rest = text.slice(start); + const next = rest.indexOf("\nSymbol table ", marker.length); + return next < 0 ? rest : rest.slice(0, next); +} + +function featureMet(variant, actual) { + if (variant === "execstack") return actual.nx === "disabled"; + if (variant === "stack-protector") + return actual.stack_protector === "enabled"; + return true; +} + +function unmetReason(variant) { + if (variant === "execstack") + return "No compiler emitted an ELF whose PT_GNU_STACK is executable, so NX disabled was not observed."; + if (variant === "stack-protector") + return "No compiler emitted an ELF with an undefined dynamic __stack_chk_fail, so stack_protector enabled was not observed."; + return "No compiler emitted a readable ELF image for this variant."; +} + +function factsFromReadelf(text) { + const type = /Type:\s+(\S+)/.exec(text)?.[1] ?? ""; + const hasInterp = /\bINTERP\b/.test(text); + const flags1 = /FLAGS_1\b[^\n]*/.exec(text)?.[0] ?? ""; + const stack = /GNU_STACK\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+([RWE]+)/i.exec( + text, + ); + const dynsym = dynsymText(text); + return { + type, + pie: type === "DYN" && (hasInterp || /\bPIE\b/.test(flags1)), + executable: type === "EXEC", + nx: stack === null ? null : !stack[1].includes("E"), + relro: /GNU_RELRO/.test(text), + bindNow: /BIND_NOW/.test(text) || /\bNOW\b/.test(flags1), + symtab: /\.symtab\b/.test(text), + sectionHeaders: /Section Headers:/.test(text) && !/no sections/i.test(text), + canary: dynsym + .split("\n") + .some( + (line) => line.includes("__stack_chk_fail") && /\bUND\b/.test(line), + ), + }; +} + +function assertElf(actual, expected, label) { + if (expected.executable) assert.equal(actual.pie, "disabled", label); + else if (expected.pie) assert.equal(actual.pie, "enabled", label); + else if (expected.type === "DYN") + assert.equal(actual.pie, "not_applicable", label); + if (expected.nx === null) assert.equal(actual.nx, "unknown", label); + else assert.equal(actual.nx, expected.nx ? "enabled" : "disabled", label); + if (!expected.relro) assert.equal(actual.relro, "disabled", label); + else + assert.equal(actual.relro, expected.bindNow ? "enabled" : "partial", label); + assert.equal( + actual.stack_protector, + expected.canary ? "enabled" : "unknown", + label, + ); + if (!expected.sectionHeaders) assert.equal(actual.stripped, "unknown", label); + else + assert.equal( + actual.stripped, + expected.symtab ? "disabled" : "enabled", + label, + ); +} + +async function verifyMacho(directory, cFile, output) { + const cc = (await findTool("cc")) ?? (await findTool("clang")); + const otool = await findTool("otool"); + if (cc === null) { + output.unavailable.push({ + kind: "mach-o", + reason: "No cc or clang was found.", + }); + return; + } + const binary = join(directory, "macho"); + const compiled = await run(cc, [ + "-arch", + process.arch === "arm64" ? "arm64" : "x86_64", + cFile, + "-o", + binary, + ]); + if (compiled.status !== 0) { + output.unavailable.push({ + kind: "mach-o", + reason: "cc did not emit a Mach-O image.", + stderr: (compiled.stderr ?? "").slice(0, 400), + }); + return; + } + const bytes = await readFile(binary); + // A compiler that ignores -arch can succeed while emitting another format. + if (!hasMachoMagic(bytes)) { + output.unavailable.push({ + kind: "mach-o", + reason: "cc did not emit a Mach-O image.", + }); + return; + } + const images = await readMachoHardening(bytes); + assert.ok(images.length >= 1); + const pie = statesOf(images[0].rows).pie; + let otoolPie = null; + if (otool !== null) { + const dumped = await run(otool, ["-hv", binary]); + if (dumped.status === 0) { + otoolPie = /\bPIE\b/.test(dumped.stdout); + if (otoolPie) assert.equal(pie, "enabled", "otool PIE"); + } + } + const otoolVersion = otool === null ? null : await run(otool, ["-version"]); + output.checks.push({ + kind: "mach-o", + compiler: cc, + ccVersion: await versionLine(cc, ["--version"]), + otool, + otoolVersionStatus: otoolVersion?.status ?? null, + otoolVersion: + otoolVersion === null + ? null + : `${otoolVersion.stdout ?? ""}\n${otoolVersion.stderr ?? ""}` + .trim() + .slice(0, 400), + images: images.length, + pie, + otoolPie, + hardened_runtime: statesOf(images[0].rows).hardened_runtime, + }); + const fat = join(directory, "fat"); + const universal = await run(cc, [ + "-arch", + "x86_64", + "-arch", + "arm64", + cFile, + "-o", + fat, + ]); + if (universal.status !== 0) { + output.unavailable.push({ + kind: "mach-o-universal", + reason: "cc could not emit a universal Mach-O image.", + stderr: (universal.stderr ?? "").slice(0, 400), + }); + return; + } + const fatBytes = await readFile(fat); + if (!hasMachoMagic(fatBytes)) { + output.unavailable.push({ + kind: "mach-o-universal", + reason: "cc did not emit a universal Mach-O image.", + }); + return; + } + const fatImages = await readMachoHardening(fatBytes); + assert.equal(fatImages.length, 2); + assert.ok( + fatImages.every( + (image) => image.status === "parsed" && image.slice !== null, + ), + ); + output.checks.push({ + kind: "mach-o-universal", + slices: fatImages.map((image) => ({ + architecture: image.architecture, + pie: statesOf(image.rows).pie, + })), + }); +} diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 2d7d023645..73ffa4eb9b 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -586,7 +586,8 @@ const workflowAvailabilityFor = ( name === "inspect_elf_notes" || name === "inspect_devicetree" || name === "inspect_kernel_module" || - name === "inspect_pe_load_config" + name === "inspect_pe_load_config" || + name === "inspect_binary_hardening" ) return { reason: "available", remediation: null }; if (name === "inspect_evm_interface") { diff --git a/src/application/binaryDiagnostics/BinaryHardeningPort.ts b/src/application/binaryDiagnostics/BinaryHardeningPort.ts new file mode 100644 index 0000000000..fa772294db --- /dev/null +++ b/src/application/binaryDiagnostics/BinaryHardeningPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + BinaryHardening, + InspectBinaryHardeningInput, +} from "../../domain/native/binaryHardening.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and header hardening decode, independent of a disassembler session. */ +export interface BinaryHardeningPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectBinaryHardeningInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/BinaryHardeningService.test.ts b/src/application/binaryDiagnostics/BinaryHardeningService.test.ts new file mode 100644 index 0000000000..ee6c9d94bc --- /dev/null +++ b/src/application/binaryDiagnostics/BinaryHardeningService.test.ts @@ -0,0 +1,383 @@ +import { expect, it } from "vitest"; +import { BinaryHardeningService } from "./BinaryHardeningService.js"; +import { BINARY_HARDENING_PROVIDER } from "../../domain/investigationProviders.js"; +import { + BINARY_HARDENING_LIMITATIONS, + ELF_HARDENING_PROPERTIES, + MACHO_HARDENING_PROPERTIES, + type BinaryHardening, + type BinaryHardeningImage, + type BinaryHardeningProperty, + type BinaryHardeningRow, +} from "../../domain/native/binaryHardening.js"; +import { ok } from "../../domain/result.js"; + +const DIGEST = "a".repeat(64); + +const row = ( + property: BinaryHardeningProperty, + patch: Partial = {}, +): BinaryHardeningRow => ({ + property, + state: property === "hardened_runtime" ? "unknown" : "disabled", + confidence: "observed", + basis: property, + location: null, + note: null, + next_probe: property === "hardened_runtime" ? "inspect_signature" : null, + ...patch, +}); + +const image = ( + format: BinaryHardeningImage["format"], + properties: readonly BinaryHardeningProperty[], + patch: Partial = {}, +): BinaryHardeningImage => ({ + format, + status: "parsed", + reason: null, + architecture: "x86_64", + bits: 64, + byte_order: "little", + machine: 62, + slice: null, + rows: properties.map((property) => + property === "pie" && format === "elf" + ? row(property, { state: "enabled", confidence: "derived" }) + : row(property), + ), + ...patch, +}); + +const report = ( + patch: Partial = {}, + imagePatch: Partial = {}, +): BinaryHardening => ({ + artifact: { path: "/artifacts/app", sha256: DIGEST, bytes: 128 }, + format: "elf", + images: [image("elf", ELF_HARDENING_PROPERTIES, imagePatch)], + limitations: [...BINARY_HARDENING_LIMITATIONS], + ...patch, +}); + +const service = (value: BinaryHardening = report()) => + new BinaryHardeningService({ + identity: BINARY_HARDENING_PROVIDER, + inspect: () => Promise.resolve(ok(value)), + }); + +it("returns derived Evidence for a multi-field ELF row", async () => { + const value = report(); + const result = await service(value).inspect({ path: value.artifact.path }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "derived", + subject: { format: "elf", architecture: "x86_64" }, + normalized_result: value, + }); + expect(result.value.limitations).toEqual([...BINARY_HARDENING_LIMITATIONS]); +}); + +it("labels one Mach-O slice as a universal image and omits derived confidence", async () => { + const slice = { + index: 0, + file_offset: 8, + size: 32, + cpu_type: 0x0100000c, + cpu_subtype: 0, + architecture: "arm64", + }; + const value = report( + { format: "mach-o" }, + { + format: "mach-o", + architecture: "arm64", + machine: 0x0100000c, + slice, + rows: MACHO_HARDENING_PROPERTIES.map((property) => row(property)), + }, + ); + const result = await service(value).inspect({ path: value.artifact.path }); + if (!result.ok) throw result.error; + expect(result.value.confidence).toBe("observed"); + expect(result.value.subject).toMatchObject({ + format: "mach-o-universal", + architecture: "arm64", + }); + expect(result.value.locations).toEqual([ + { kind: "artifact-path", path: value.artifact.path }, + { kind: "file-offset-range", start: 8, end: 40 }, + ]); +}); + +it("omits architecture when more than one slice is present", async () => { + const sliceAt = (index: number) => ({ + index, + file_offset: index * 32, + size: 32, + cpu_type: 0x0100000c, + cpu_subtype: index, + architecture: index === 0 ? "arm64" : "arm64e", + }); + const value: BinaryHardening = { + artifact: { path: "/artifacts/app", sha256: DIGEST, bytes: 128 }, + format: "mach-o", + limitations: [...BINARY_HARDENING_LIMITATIONS], + images: [0, 1].map((index) => + image("mach-o", MACHO_HARDENING_PROPERTIES, { + architecture: "arm64", + slice: sliceAt(index), + rows: MACHO_HARDENING_PROPERTIES.map((property) => + row(property, { + location: { offset: 24 + index, bytes: 1 }, + }), + ), + }), + ), + }; + const result = await service(value).inspect({ path: value.artifact.path }); + if (!result.ok) throw result.error; + expect(result.value.subject).toMatchObject({ + format: "mach-o-universal", + architecture: null, + }); +}); + +it("rejects input and cancellation before reading", async () => { + const boundary = new BinaryHardeningService({ + identity: BINARY_HARDENING_PROVIDER, + inspect: () => { + throw new Error("Unexpected read"); + }, + }); + for (const input of [ + { path: "relative" }, + { path: "/artifacts/app", extra: true }, + { path: "/artifacts/app", max_file_bytes: 536870913 }, + ]) { + const result = await boundary.inspect(input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisInputError"); + } + const result = await boundary.inspect( + { path: "/artifacts/app" }, + { signal: AbortSignal.abort() }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); +}); + +it.each([ + report({ artifact: { path: "/other", sha256: DIGEST, bytes: 128 } }), + report({ + limitations: BINARY_HARDENING_LIMITATIONS.slice(0, 4), + }), + report( + { artifact: { path: "/artifacts/app", sha256: DIGEST, bytes: 80 } }, + {}, + ), + report( + {}, + { + rows: [...ELF_HARDENING_PROPERTIES] + .reverse() + .map((property) => row(property)), + }, + ), + report( + {}, + { + rows: ELF_HARDENING_PROPERTIES.map((property) => + row( + property, + property === "pie" ? { next_probe: "inspect_signature" } : {}, + ), + ), + }, + ), + report( + { format: "mach-o" }, + { + format: "mach-o", + rows: MACHO_HARDENING_PROPERTIES.map((property) => + row( + property, + property === "hardened_runtime" ? { state: "enabled" } : {}, + ), + ), + }, + ), + report( + { format: "mach-o" }, + { + format: "mach-o", + rows: MACHO_HARDENING_PROPERTIES.map((property) => + row(property, { + next_probe: property === "hardened_runtime" ? "inspect_macho" : null, + }), + ), + }, + ), + report( + {}, + { + rows: ELF_HARDENING_PROPERTIES.map((property) => + row(property, { location: { offset: 120, bytes: 16 } }), + ), + }, + ), + report({ + format: "mach-o", + images: [ + image("mach-o", MACHO_HARDENING_PROPERTIES), + image("mach-o", MACHO_HARDENING_PROPERTIES), + ], + }), + report({}, { status: "malformed", reason: "truncated", rows: [] }), + report( + {}, + { + slice: { + index: 0, + file_offset: 0, + size: 16, + cpu_type: 1, + cpu_subtype: 0, + architecture: "arm64", + }, + }, + ), +])("rejects inconsistent provider output", async (value) => { + const input = + value.artifact.bytes === 80 + ? { path: "/artifacts/app", max_file_bytes: 64 } + : { path: "/artifacts/app" }; + const result = await service(value).inspect(input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); +}); + +it("keeps sibling Mach-O slices when one range is empty or past EOF", async () => { + const slice = ( + index: number, + file_offset: number, + size: number, + ): NonNullable => ({ + index, + file_offset, + size, + cpu_type: 0x0100000c, + cpu_subtype: 0, + architecture: "arm64", + }); + const failed = ( + index: number, + file_offset: number, + size: number, + reason: string, + ): BinaryHardeningImage => + image("mach-o", MACHO_HARDENING_PROPERTIES, { + status: "malformed", + reason, + rows: [], + architecture: "arm64", + bits: null, + byte_order: null, + machine: 0x0100000c, + slice: slice(index, file_offset, size), + }); + const value = report({ + format: "mach-o", + images: [ + image("mach-o", MACHO_HARDENING_PROPERTIES, { + architecture: "arm64", + machine: 0x0100000c, + slice: slice(0, 0, 64), + }), + failed( + 1, + 96, + 64, + "FAT architecture 1 slice range extends beyond the file", + ), + failed(2, 80, 0, "FAT architecture 2 has an invalid slice range"), + ], + }); + const result = await service(value).inspect({ path: value.artifact.path }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + images: [ + { status: "parsed" }, + { status: "malformed" }, + { status: "malformed" }, + ], + }); + expect(result.value.locations).toEqual([ + { kind: "artifact-path", path: value.artifact.path }, + { kind: "file-offset-range", start: 0, end: 64 }, + ]); +}); + +it("keeps a sibling when a failed slice declares an offset past 2^32", async () => { + const slice = ( + index: number, + file_offset: number, + size: number, + ): NonNullable => ({ + index, + file_offset, + size, + cpu_type: 0x0100000c, + cpu_subtype: 0, + architecture: "arm64", + }); + const value = report({ + format: "mach-o", + images: [ + image("mach-o", MACHO_HARDENING_PROPERTIES, { + architecture: "arm64", + machine: 0x0100000c, + slice: slice(0, 0, 64), + }), + image("mach-o", MACHO_HARDENING_PROPERTIES, { + status: "malformed", + reason: "FAT64 slice offset exceeds 32 bits", + rows: [], + architecture: "arm64", + bits: null, + byte_order: null, + machine: 0x0100000c, + slice: slice(1, 2 ** 32, 2 ** 32), + }), + ], + }); + const result = await service(value).inspect({ path: value.artifact.path }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.locations).toEqual([ + { kind: "artifact-path", path: value.artifact.path }, + { kind: "file-offset-range", start: 0, end: 64 }, + ]); +}); + +it("rejects a thin Mach-O malformed image that has no slice", async () => { + const value = report( + { format: "mach-o" }, + { + format: "mach-o", + status: "malformed", + reason: "Mach-O header is truncated", + rows: [], + architecture: null, + bits: null, + byte_order: null, + machine: null, + slice: null, + }, + ); + const result = await service(value).inspect({ path: value.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); +}); diff --git a/src/application/binaryDiagnostics/BinaryHardeningService.ts b/src/application/binaryDiagnostics/BinaryHardeningService.ts new file mode 100644 index 0000000000..2c77fa1d87 --- /dev/null +++ b/src/application/binaryDiagnostics/BinaryHardeningService.ts @@ -0,0 +1,204 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { BinaryHardeningPort } from "./BinaryHardeningPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + BINARY_HARDENING_LIMITATIONS, + binaryHardeningSchema, + hardeningPropertiesFor, + inspectBinaryHardeningInputSchema, + type BinaryHardening, + type BinaryHardeningImage, + type InspectBinaryHardeningInput, +} from "../../domain/native/binaryHardening.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; + +const OPERATION = "inspect_binary_hardening"; + +/** Shared caller validation, output parsing, and Evidence provenance. */ +export class BinaryHardeningService { + constructor(readonly provider: BinaryHardeningPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + const input = parseAnalysisInput( + OPERATION, + rawInput, + inspectBinaryHardeningInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const result = await this.provider.inspect(input.value, options); + if (!result.ok) return result; + const report = binaryHardeningSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.value.path || + !validReport(report.data, input.value) + ) + return err( + new AnalysisOutputError( + OPERATION, + "Binary hardening provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + // Several slices have no single family; each image keeps its own. + const architecture = + value.images.length === 1 ? value.images[0]?.architecture : null; + const format = + value.format === "mach-o" && + value.images.some((image) => image.slice !== null) + ? "mach-o-universal" + : value.format; + const located = (start: number, end: number) => + fileOffsetRange(start, end, value.artifact.bytes); + const locations = [ + { kind: "artifact-path" as const, path: value.artifact.path }, + ...value.images.flatMap((image) => { + const slice = + image.slice === null + ? undefined + : located( + image.slice.file_offset, + image.slice.file_offset + image.slice.size, + ); + const rows = image.rows.flatMap((entry) => { + if (entry.location === null) return []; + const range = located( + entry.location.offset, + entry.location.offset + entry.location.bytes, + ); + return range === undefined ? [] : [range]; + }); + return slice === undefined ? rows : [slice, ...rows]; + }), + ]; + const derived = value.images.some((image) => + image.rows.some((entry) => entry.confidence === "derived"), + ); + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format, + sha256: value.artifact.sha256, + ...(architecture === null || architecture === undefined + ? {} + : { architecture }), + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.value, + result: value, + confidence: derived ? "derived" : "observed", + limitations: value.limitations, + locations, + }, + ), + options?.signal, + ); + } +} + +/** Drop empty, overflowing, or out-of-file ranges instead of citing them. */ +const fileOffsetRange = ( + start: number, + end: number, + bytes: number, +): + | { + readonly kind: "file-offset-range"; + readonly start: number; + readonly end: number; + } + | undefined => { + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end <= start || + end > bytes + ) + return undefined; + return { kind: "file-offset-range", start, end }; +}; + +const sameLimitations = (limitations: readonly string[]): boolean => + limitations.length === BINARY_HARDENING_LIMITATIONS.length && + limitations.every( + (entry, index) => entry === BINARY_HARDENING_LIMITATIONS[index], + ); + +const validImage = ( + image: BinaryHardeningImage, + format: BinaryHardening["format"], + bytes: number, + requireSlice: boolean, +): boolean => { + if (image.format !== format) return false; + if ( + format !== "mach-o" && + (image.slice !== null || image.status !== "parsed") + ) + return false; + if (requireSlice && image.slice === null) return false; + if (image.slice !== null && image.status === "parsed") { + const end = image.slice.file_offset + image.slice.size; + if (image.slice.size < 1 || image.slice.file_offset > bytes || end > bytes) + return false; + } + if (image.status !== "parsed") + return ( + format === "mach-o" && + image.slice !== null && + image.rows.length === 0 && + image.reason !== null && + (image.status === "malformed" || image.status === "unknown") + ); + if (image.reason !== null) return false; + const expected = hardeningPropertiesFor(format); + if ( + image.rows.length !== expected.length || + image.rows.some((entry, index) => entry.property !== expected[index]) + ) + return false; + return image.rows.every((entry) => { + if (entry.property === "hardened_runtime") + return ( + entry.state === "unknown" && entry.next_probe === "inspect_signature" + ); + if (entry.next_probe !== null) return false; + if (entry.location === null) return true; + return ( + entry.location.offset <= bytes - entry.location.bytes && + entry.location.offset + entry.location.bytes <= bytes + ); + }); +}; + +const validReport = ( + report: BinaryHardening, + input: InspectBinaryHardeningInput, +): boolean => + sameLimitations(report.limitations) && + report.artifact.bytes <= input.max_file_bytes && + report.images.length >= 1 && + report.images.every((image) => + validImage( + image, + report.format, + report.artifact.bytes, + report.images.length > 1, + ), + ); diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index a661865a98..e36ebd4030 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -7,6 +7,7 @@ import { MachoLoadCommandCursor, readMachoHeaderDeclarations, readMachoCommandStringBytes, + readMachoLinkeditDataCommand, type MachoCommandStringFailure, } from "../../domain/apple/machoLoadCommands.js"; import { @@ -412,6 +413,7 @@ export interface MachoSliceHeader { readonly cpuType: number; readonly cpuSubtype: number; readonly fileType: number; + readonly flags: number; readonly commandCount: number; readonly commandBytes: number; } @@ -455,14 +457,22 @@ export const readSliceCommandTable = async ( "big-endian Mach-O slices are not supported", null, ); - const { wide, headerSize, commandCount, commandBytes, fileType, identity } = - parsed.value; + const { + wide, + headerSize, + commandCount, + commandBytes, + fileType, + flags, + identity, + } = parsed.value; const header: MachoSliceHeader = { wide, headerSize, cpuType: identity.cpuType, cpuSubtype: identity.cpuSubtype, fileType, + flags, commandCount, commandBytes, }; @@ -762,8 +772,7 @@ const decodeCommand = ( return; } if (command === LC_CODE_SIGNATURE) { - // linkedit_data_command: cmd, cmdsize, dataoff, datasize. - if (body.byteLength < 16) + if (readMachoLinkeditDataCommand(LC_CODE_SIGNATURE, body, true) === null) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for LC_CODE_SIGNATURE`, diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 4ddd3974c7..9708a63d0a 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -1,6 +1,7 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { inspectDevicetreeInputSchema } from "../domain/firmware/devicetree.js"; import { inspectPeLoadConfigInputSchema } from "../domain/native/peLoadConfig.js"; +import { inspectBinaryHardeningInputSchema } from "../domain/native/binaryHardening.js"; import { inspectByteEntropyInputSchema } from "../domain/native/byteEntropy.js"; import { z } from "incur"; import { @@ -12,6 +13,7 @@ import { createDevicetreeService, createKernelModuleService, createPeLoadConfigService, + createBinaryHardeningService, createPeResourcesService, createPeCertificatesService, createPeImportsService, @@ -202,6 +204,40 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectBinaryHardening, { + description: + "Inspect ELF, PE, and Mach-O hardening without executing the image", + args: z.object({ + path: localPathStringSchema.describe("Local ELF, PE, or Mach-O image"), + }), + options: z.object({ + maxFileBytes: inspectBinaryHardeningInputSchema.shape.max_file_bytes, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const hardening = createBinaryHardeningService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectBinaryHardening, + async () => + finishCliEvidenceCommand( + args.path, + () => + hardening.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + }, + { signal }, + ), + hardening, + "rea-binary-hardening", + "inspect_binary_hardening", + signal, + ).then(projectCliAnalysisResult), + ); + }), + }); const entropy = createByteEntropyService(); cli.command(CLI_COMMANDS.inspectByteEntropy, { description: diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index a51e4acd03..4c7e12ec6f 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -81,6 +81,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectAppleIps: "inspect-apple-ips", inspectKernelModule: "inspect-kernel-module", inspectPeLoadConfig: "inspect-pe-load-config", + inspectBinaryHardening: "inspect-binary-hardening", inspectAnalysisView: "inspect-analysis-view", listFridaDevices: "list-frida-devices", listFridaProcesses: "list-frida-processes", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index 85cff42e1c..cca8f0e917 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -1,5 +1,7 @@ import { PeLoadConfigService } from "../application/binaryDiagnostics/PeLoadConfigService.js"; import { PeLoadConfigProvider } from "../native/pe/PeLoadConfigProvider.js"; +import { BinaryHardeningService } from "../application/binaryDiagnostics/BinaryHardeningService.js"; +import { BinaryHardeningProvider } from "../native/hardening/BinaryHardeningProvider.js"; import { ByteEntropyService } from "../application/binaryDiagnostics/ByteEntropyService.js"; import { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; import { PwntoolsRecordedCrashProvider } from "../native/pwntools/PwntoolsRecordedCrashProvider.js"; @@ -74,3 +76,7 @@ export const createByteEntropyService = ( export const createPeLoadConfigService = (): PeLoadConfigService => new PeLoadConfigService(new PeLoadConfigProvider()); + +/** Portable ELF, PE, and Mach-O hardening inspection with no engine or toolchain. */ +export const createBinaryHardeningService = (): BinaryHardeningService => + new BinaryHardeningService(new BinaryHardeningProvider()); diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index cfbfd494d7..df2073a19d 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -26,6 +26,7 @@ import { APPLE_APPLICATION_PROVIDER, APPLE_IPS_PROVIDER, ARTIFACT_GRAPH_PROVIDER, + BINARY_HARDENING_PROVIDER, BYTE_ENTROPY_PROVIDER, DEVICETREE_PROVIDER, ELF_NOTES_PROVIDER, @@ -429,6 +430,11 @@ export const CAPABILITY_DECLARATIONS = { providerBinding(PE_LOAD_CONFIG_PROVIDER, ["inspect_pe_load_config"]), ], }, + binaryHardening: { + providers: [ + providerBinding(BINARY_HARDENING_PROVIDER, ["inspect_binary_hardening"]), + ], + }, peResources: { providers: [ providerBinding(PE_RESOURCES_PROVIDER, ["inspect_pe_resources"]), diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index 67d28f2ace..15fdc65471 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -64,6 +64,7 @@ import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHo import { pcapCaptureHostSupported } from "../domain/pcap/pcapAnalysis.js"; import { createPeLoadConfigService, + createBinaryHardeningService, createPeCertificatesService, createPeImportsService, createNativeExportsService, @@ -338,6 +339,7 @@ const modules = { appleIps: service(() => createAppleIpsService()), kernelModule: service(() => createKernelModuleService()), peLoadConfig: service(() => createPeLoadConfigService()), + binaryHardening: service(() => createBinaryHardeningService()), peCertificates: service(() => createPeCertificatesService()), peImports: service(() => createPeImportsService()), electronFuses: service( diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 0099a0c9f9..262abfe179 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -11,6 +11,10 @@ import { inspectPeLoadConfigInputSchema, peLoadConfigSchema, } from "../../domain/native/peLoadConfig.js"; +import { + binaryHardeningSchema, + inspectBinaryHardeningInputSchema, +} from "../../domain/native/binaryHardening.js"; import { inspectByteEntropyInputSchema } from "../../domain/native/byteEntropy.js"; import { byteEntropyOutputSchema } from "../toolOutputSchemaGroups.js"; import { @@ -105,6 +109,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_binary_hardening", + ...toolContractMetadata("inspect_binary_hardening"), + kind: "native-provider", + description: + "Inspect compile-time and link-time hardening of one explicit local ELF, PE, or Mach-O file, including one image per universal Mach-O slice. Reports PIE or ASLR, NX, RELRO, stack-protector signs, FORTIFY signs, control-flow flags and stripped state, each with the header field or import behind it. States are enabled, disabled, partial, unknown, or not_applicable. A missing import in a static or stripped image is unknown, not disabled. Authenticode and Mach-O code-signature rows report table or load-command presence, not validity. Mach-O hardened runtime stays unknown and names inspect_signature. This inspection states that it does not execute the image and does not assess exploitability. Reads a stable regular file. A malformed header returns no partial report, except one bad slice in a universal Mach-O. A symbol table, symbol name, or GNU property note over its budget leaves those rows unknown and keeps the header rows. A dynamic-table budget still fails the ELF report.", + inputSchema: inspectBinaryHardeningInputSchema, + outputSchema: evidenceResultOf(binaryHardeningSchema), + examples: [ + { + title: "Inspect binary hardening", + input: { path: "/artifacts/program" }, + }, + ], + }, { name: "inspect_elf_notes", diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index e0f43fc3bf..a519bde4fc 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -40,6 +40,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_apple_ips: "states that it does not execute a target", inspect_kernel_module: "states that it does not execute the module", inspect_pe_load_config: "states that it does not execute the image", + inspect_binary_hardening: "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", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 40852d4a24..6707b67304 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -266,6 +266,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_devicetree: evidence, inspect_kernel_module: evidence, inspect_pe_load_config: evidence, + inspect_binary_hardening: evidence, inspect_analysis_view: evidence, inspect_firmware_regions: effects({ mutatesSession: true, diff --git a/src/domain/apple/machoLoadCommands.test.ts b/src/domain/apple/machoLoadCommands.test.ts index 360e4a2d31..cc366f8b02 100644 --- a/src/domain/apple/machoLoadCommands.test.ts +++ b/src/domain/apple/machoLoadCommands.test.ts @@ -5,9 +5,12 @@ import { parseExecutableHeader, } from "../binaryTarget.js"; import { + decodeMachoFixedWordFields, readMachoHeaderDeclarations, + readMachoLinkeditDataCommand, readMachoLoadCommandDeclarations, readMachoLoadCommandTableHeader, + readMachoSymtabCommand, } from "./machoLoadCommands.js"; it.each([ @@ -27,6 +30,7 @@ it.each([ view.setUint32(12, 0xffffffff, littleEndian); view.setUint32(16, 0x80000001, littleEndian); view.setUint32(20, 0xffffffff, littleEndian); + view.setUint32(24, 0x01200085, littleEndian); const expected = { headerSize: wide ? 32 : 28, wide, @@ -34,6 +38,7 @@ it.each([ commandCount: 0x80000001, commandBytes: 0xffffffff, fileType: 0xffffffff, + flags: 0x01200085, identity: { cpuType: 0xfedcba98, cpuSubtype: 0x89abcdef }, }; const format = { wide, littleEndian, headerSize: wide ? 32 : 28 }; @@ -107,3 +112,47 @@ it.each([ }); }, ); + +it("reads linkedit data fields and refuses a truncated command", () => { + const body = Buffer.alloc(16); + body.writeUInt32LE(0x1234, 8); + body.writeUInt32LE(0x56, 12); + expect(readMachoLinkeditDataCommand(0x1d, body, true)).toEqual({ + dataOffset: 0x1234, + dataSize: 0x56, + }); + expect( + readMachoLinkeditDataCommand(0x1d, body.subarray(0, 15), true), + ).toBeNull(); + // The layout comes from the shared fixed-word table, so other layouts refuse. + expect(readMachoLinkeditDataCommand(0x2, Buffer.alloc(24), true)).toBeNull(); +}); + +it("reads big-endian linkedit data fields", () => { + const body = Buffer.alloc(16); + body.writeUInt32BE(0x1234, 8); + body.writeUInt32BE(0x56, 12); + expect(readMachoLinkeditDataCommand(0x1d, body, false)).toEqual({ + dataOffset: 0x1234, + dataSize: 0x56, + }); +}); + +it("reads symtab fields through the shared fixed-word decoder", () => { + const body = Buffer.alloc(24); + body.writeUInt32BE(0x2, 0); + body.writeUInt32BE(24, 4); + body.writeUInt32BE(0x100, 8); + body.writeUInt32BE(3, 12); + body.writeUInt32BE(0x200, 16); + body.writeUInt32BE(0x40, 20); + const fields = { symoff: 0x100, nsyms: 3, stroff: 0x200, strsize: 0x40 }; + expect(readMachoSymtabCommand(body, false)).toEqual(fields); + expect(decodeMachoFixedWordFields(0x2, body.subarray(8), false)).toEqual( + fields, + ); + expect(readMachoSymtabCommand(body.subarray(0, 23), false)).toBeNull(); + expect(decodeMachoFixedWordFields(0x19, body.subarray(8), false)).toBe( + undefined, + ); +}); diff --git a/src/domain/apple/machoLoadCommands.ts b/src/domain/apple/machoLoadCommands.ts index 8278a31f17..9b6e0cef98 100644 --- a/src/domain/apple/machoLoadCommands.ts +++ b/src/domain/apple/machoLoadCommands.ts @@ -13,8 +13,14 @@ export interface MachoLoadCommandTableHeader { readonly commandBytes: number; } +const LC_SYMTAB = 0x2; + +/** Byte offset of `mach_header.flags`, shared by the 32- and 64-bit headers. */ +export const MACHO_HEADER_FLAGS_OFFSET = 24; + export interface MachoHeaderDeclarations extends MachoLoadCommandTableHeader { readonly fileType: number; + readonly flags: number; readonly identity: { readonly cpuType: number; readonly cpuSubtype: number }; } @@ -39,6 +45,7 @@ export const readMachoHeaderDeclarations = ( commandCount: view.getUint32(16, littleEndian), commandBytes, fileType: view.getUint32(12, littleEndian), + flags: view.getUint32(MACHO_HEADER_FLAGS_OFFSET, littleEndian), identity: prefix.identity, }); }; @@ -135,6 +142,155 @@ export class MachoLoadCommandCursor { } } +// Fields with fixed uint32 layouts in mach-o/loader.h. This table is the single +// owner of those layouts; consumers read decoded fields rather than offsets. +const FIXED_WORD_FIELDS: ReadonlyMap = new Map([ + [0x2, ["symoff", "nsyms", "stroff", "strsize"]], + [0x3, ["offset", "size"]], + [ + 0xb, + [ + "ilocalsym", + "nlocalsym", + "iextdefsym", + "nextdefsym", + "iundefsym", + "nundefsym", + "tocoff", + "ntoc", + "modtaboff", + "nmodtab", + "extrefsymoff", + "nextrefsyms", + "indirectsymoff", + "nindirectsyms", + "extreloff", + "nextrel", + "locreloff", + "nlocrel", + ], + ], + [0x16, ["offset", "nhints"]], + [0x17, ["cksum"]], + [0x1d, ["dataoff", "datasize"]], + [0x1e, ["dataoff", "datasize"]], + [0x21, ["cryptoff", "cryptsize", "cryptid"]], + [ + 0x22, + [ + "rebase_off", + "rebase_size", + "bind_off", + "bind_size", + "weak_bind_off", + "weak_bind_size", + "lazy_bind_off", + "lazy_bind_size", + "export_off", + "export_size", + ], + ], + [ + 0x80000022, + [ + "rebase_off", + "rebase_size", + "bind_off", + "bind_size", + "weak_bind_off", + "weak_bind_size", + "lazy_bind_off", + "lazy_bind_size", + "export_off", + "export_size", + ], + ], + [0x26, ["dataoff", "datasize"]], + [0x29, ["dataoff", "datasize"]], + [0x2b, ["dataoff", "datasize"]], + [0x2c, ["cryptoff", "cryptsize", "cryptid", "pad"]], + [0x2e, ["dataoff", "datasize"]], + [0x80000033, ["dataoff", "datasize"]], + [0x80000034, ["dataoff", "datasize"]], + [0x36, ["dataoff", "datasize"]], + [0x37, ["dataoff", "datasize"]], + [0x38, ["dataoff", "datasize"]], +]); + +/** Names of a command's fixed uint32 fields, or undefined for other layouts. */ +export const machoFixedWordFieldNames = ( + command: number, +): readonly string[] | undefined => FIXED_WORD_FIELDS.get(command); + +/** + * Decode a command's fixed uint32 fields from the bytes after its 8-byte + * header. Undefined for commands without a fixed layout; null when truncated. + */ +export const decodeMachoFixedWordFields = ( + command: number, + fieldBytes: Uint8Array, + littleEndian: boolean, +): Readonly> | null | undefined => { + const names = FIXED_WORD_FIELDS.get(command); + if (names === undefined) return undefined; + if (fieldBytes.byteLength < names.length * 4) return null; + const view = new DataView( + fieldBytes.buffer, + fieldBytes.byteOffset, + fieldBytes.byteLength, + ); + return Object.fromEntries( + names.map((name, index) => [name, view.getUint32(index * 4, littleEndian)]), + ); +}; + +/** `symtab_command` fields from an admitted command body. Null when truncated. */ +export const readMachoSymtabCommand = ( + body: Uint8Array, + littleEndian: boolean, +): { + readonly symoff: number; + readonly nsyms: number; + readonly stroff: number; + readonly strsize: number; +} | null => { + const fields = decodeMachoFixedWordFields( + LC_SYMTAB, + body.subarray(8), + littleEndian, + ); + if (fields === null || fields === undefined) return null; + const { symoff, nsyms, stroff, strsize } = fields; + if ( + symoff === undefined || + nsyms === undefined || + stroff === undefined || + strsize === undefined + ) + return null; + return { symoff, nsyms, stroff, strsize }; +}; + +/** + * `linkedit_data_command` fields from an admitted command body. Null when + * truncated or when `command` does not use the linkedit_data_command layout. + */ +export const readMachoLinkeditDataCommand = ( + command: number, + body: Uint8Array, + littleEndian: boolean, +): { readonly dataOffset: number; readonly dataSize: number } | null => { + const fields = decodeMachoFixedWordFields( + command, + body.subarray(8), + littleEndian, + ); + if (fields === null || fields === undefined) return null; + const { dataoff, datasize } = fields; + if (dataoff === undefined || datasize === undefined) return null; + return { dataOffset: dataoff, dataSize: datasize }; +}; + export type MachoCommandStringFailure = | { readonly _tag: "InvalidMachoCommandString"; diff --git a/src/domain/apple/machoSectionHeader.test.ts b/src/domain/apple/machoSectionHeader.test.ts index 3d250c6a64..57cbe023e3 100644 --- a/src/domain/apple/machoSectionHeader.test.ts +++ b/src/domain/apple/machoSectionHeader.test.ts @@ -1,5 +1,8 @@ import { expect, it } from "vitest"; -import { readMachoSectionHeader } from "./machoSectionHeader.js"; +import { + admitMachoSectionPayload, + readMachoSectionHeader, +} from "./machoSectionHeader.js"; const formats = [ { wide: false, littleEndian: false, headerSize: 68 }, @@ -73,3 +76,19 @@ it.each(formats)( }); }, ); + +it.each([ + ["inside", { size: 16n, offset: 8, flags: 0 }, true], + ["ending at EOF", { size: 16n, offset: 24, flags: 0 }, true], + ["past EOF", { size: 1n, offset: 41, flags: 0 }, false], + ["straddling EOF", { size: 17n, offset: 24, flags: 0 }, false], + ["huge", { size: 0xffffffffffffffffn, offset: 0, flags: 0 }, false], + ["empty", { size: 0n, offset: 0xffffffff, flags: 0 }, true], + ["zero-fill", { size: 99n, offset: 0xffffffff, flags: 1 }, true], + ["thread-local zero-fill", { size: 99n, offset: 0, flags: 0x12 }, true], +] as const)( + "admits a %s section payload against a 40-byte image", + (_l, section, ok) => { + expect(admitMachoSectionPayload(section, 40).ok).toBe(ok); + }, +); diff --git a/src/domain/apple/machoSectionHeader.ts b/src/domain/apple/machoSectionHeader.ts index 6069254ef7..7fbdf5c9a7 100644 --- a/src/domain/apple/machoSectionHeader.ts +++ b/src/domain/apple/machoSectionHeader.ts @@ -37,3 +37,22 @@ export const readMachoSectionHeader = ( flags: view.getUint32(wide ? 64 : 56, littleEndian), }); }; + +/** S_ZEROFILL, S_GB_ZEROFILL and S_THREAD_LOCAL_ZEROFILL sections own no file bytes. */ +export const isMachoZeroFillSection = (flags: number): boolean => + [1, 0xc, 0x12].includes(flags & 0xff); + +/** Admit a section's declared payload against the image that holds it. */ +export const admitMachoSectionPayload = ( + section: Pick, + imageBytes: number, +): Result => + section.size === 0n || + isMachoZeroFillSection(section.flags) || + (section.offset <= imageBytes && + section.size <= BigInt(imageBytes - section.offset)) + ? ok(null) + : err({ + kind: "malformed", + message: "Mach-O section payload is outside the file", + }); diff --git a/src/domain/apple/machoSegmentHeader.test.ts b/src/domain/apple/machoSegmentHeader.test.ts index 2efb59dc9c..c3004128e5 100644 --- a/src/domain/apple/machoSegmentHeader.test.ts +++ b/src/domain/apple/machoSegmentHeader.test.ts @@ -1,5 +1,8 @@ import { expect, it } from "vitest"; -import { readMachoSegmentHeader } from "./machoSegmentHeader.js"; +import { + admitMachoSegmentCommand, + readMachoSegmentHeader, +} from "./machoSegmentHeader.js"; const formats = [ { wide: false, littleEndian: false, headerSize: 56 }, @@ -79,3 +82,25 @@ it.each(formats)( }); }, ); + +it("admits a segment's VM sizes, section table and file range", () => { + const segment = { + address: 0x1000n, + memorySize: 32n, + fileOffset: 8n, + fileSize: 16n, + protection: 1, + headerSize: 72, + sectionCount: 1, + }; + const admit = (facts: typeof segment, commandBytes = 152, image = 24) => + admitMachoSegmentCommand(facts, commandBytes, 64, image); + expect(admit(segment)).toEqual({ ok: true, value: { unmapped: false } }); + expect(admit({ ...segment, memorySize: 15n }).ok).toBe(false); + expect(admit(segment, 151).ok).toBe(false); + expect(admit(segment, 152, 23).ok).toBe(false); + // A segment with no file bytes owns no file range, wherever fileoff points. + expect(admit({ ...segment, fileOffset: 1n << 40n, fileSize: 0n }).ok).toBe( + true, + ); +}); diff --git a/src/domain/apple/machoSegmentHeader.ts b/src/domain/apple/machoSegmentHeader.ts index f6590f5699..b978279ba0 100644 --- a/src/domain/apple/machoSegmentHeader.ts +++ b/src/domain/apple/machoSegmentHeader.ts @@ -49,3 +49,38 @@ export const admitMachoSegmentMapping = ( }) : ok({ unmapped }); }; + +/** Admit one segment command against its cmdsize and the image that holds it. */ +export const admitMachoSegmentCommand = ( + segment: Parameters[0] & { + readonly headerSize: number; + readonly sectionCount: number; + readonly fileOffset: bigint; + }, + commandBytes: number, + bits: 32 | 64, + imageBytes: number, +) => { + const mapping = admitMachoSegmentMapping(segment, bits); + if (!mapping.ok) return mapping; + const sectionBytes = bits === 64 ? 80 : 68; + if ( + segment.sectionCount > + Math.floor((commandBytes - segment.headerSize) / sectionBytes) + ) + return err({ + kind: "malformed" as const, + message: "Mach-O section table exceeds its segment command", + }); + const image = BigInt(imageBytes); + if ( + segment.fileSize !== 0n && + (segment.fileOffset > image || + segment.fileSize > image - segment.fileOffset) + ) + return err({ + kind: "malformed" as const, + message: "Mach-O segment range is outside the file", + }); + return mapping; +}; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index a5213a9197..b601960af5 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -151,10 +151,7 @@ const parseThinMachO = ( if (!table.ok) return table; if (table.value === null) return err("unsupported binary format"); const { identity } = table.value; - const architecture = - identity.cpuType === CPU_TYPE_ARM64_32 - ? "arm64_32" - : machArchitecture(identity.cpuType); + const architecture = machoBinaryArchitecture(identity.cpuType); if (architecture === undefined) return err("unsupported Mach-O architecture"); const commands = new MachoLoadCommandCursor(table.value); while (commands.nextOffset !== null) { @@ -724,6 +721,12 @@ export const parsePeCodeViewFromBuffer = ( return undefined; }; +/** CPU family a Mach-O cpu type names, or undefined when it names none here. */ +export const machoBinaryArchitecture = ( + cpu: number, +): HeaderArchitecture | "arm64_32" | undefined => + cpu === CPU_TYPE_ARM64_32 ? "arm64_32" : machArchitecture(cpu); + const machArchitecture = (cpu: number): HeaderArchitecture | undefined => { switch (cpu) { case 7: diff --git a/src/domain/elfHashSymbolCount.test.ts b/src/domain/elfHashSymbolCount.test.ts new file mode 100644 index 0000000000..77df9bd8ac --- /dev/null +++ b/src/domain/elfHashSymbolCount.test.ts @@ -0,0 +1,89 @@ +import { expect, it } from "vitest"; +import { + readElfGnuHashSymbolCount, + readElfHashSymbolCount, + type ElfGnuHashCountWalk, + type ElfHashWordReader, +} from "./elfHashSymbolCount.js"; +import { err, ok } from "./result.js"; + +const words = + (values: readonly number[]): ElfHashWordReader => + (offset) => { + const value = values[offset / 4]; + return value === undefined ? err("is outside the image") : ok(value); + }; + +const finish = (walk: ElfGnuHashCountWalk) => { + for (let step = walk.next(); ; step = walk.next()) + if (step.done === true) return step.value; +}; + +/** nbuckets, symoffset, bloom words, shift, one 8-byte bloom word, buckets, chain. */ +const gnuTable = ( + buckets: readonly number[], + chain: readonly number[], + firstSymbol = 2, +) => [buckets.length, firstSymbol, 1, 0, 0, 0, ...buckets, ...chain]; + +it("reads nchain from a complete DT_HASH table", () => { + expect(readElfHashSymbolCount(words([1, 3, 0, 0, 0, 0]))).toEqual(ok(3)); + expect(readElfHashSymbolCount(words([1]))).toEqual( + err({ + kind: "malformed", + message: "DT_HASH header is outside the image", + }), + ); + expect(readElfHashSymbolCount(words([1, 3, 0]))).toEqual( + err({ kind: "malformed", message: "DT_HASH table is outside the image" }), + ); + expect(readElfHashSymbolCount(words([1, 9]), 8)).toEqual( + err({ kind: "budget", label: "DT_HASH symbol table", count: 9 }), + ); +}); + +it("follows the last DT_GNU_HASH chain to its end", () => { + expect( + finish(readElfGnuHashSymbolCount(words(gnuTable([2, 3], [0, 0, 1])), 8)), + ).toEqual(ok(5)); + expect( + finish(readElfGnuHashSymbolCount(words(gnuTable([0], [])), 8)), + ).toEqual(ok(null)); + const narrow = [1, 1, 1, 0, 0, 1, 1]; + expect(finish(readElfGnuHashSymbolCount(words(narrow), 4))).toEqual(ok(2)); +}); + +it("rejects DT_GNU_HASH tables that do not describe a chain", () => { + const malformed = (values: readonly number[], message: string) => + expect(finish(readElfGnuHashSymbolCount(words(values), 8))).toEqual( + err({ kind: "malformed", message }), + ); + malformed([1, 2, 1], "DT_GNU_HASH header is outside the image"); + malformed([0, 2, 1, 0], "DT_GNU_HASH buckets are malformed"); + malformed([1, 2, 0, 0], "DT_GNU_HASH buckets are malformed"); + malformed([1, 2, 1, 0, 0, 0], "DT_GNU_HASH buckets is outside the image"); + malformed(gnuTable([1], [1]), "DT_GNU_HASH bucket precedes its first symbol"); + malformed(gnuTable([2], [0]), "DT_GNU_HASH chain is outside the image"); +}); + +it("applies a caller budget before walking each table", () => { + const budget = (values: readonly number[], maximum: number) => + finish(readElfGnuHashSymbolCount(words(values), 8, maximum)); + expect(budget([9, 0, 1, 0], 8)).toEqual( + err({ kind: "budget", label: "DT_GNU_HASH bucket table", count: 9 }), + ); + expect(budget([1, 0, 9, 0], 8)).toEqual( + err({ kind: "budget", label: "DT_GNU_HASH bloom filter", count: 9 }), + ); + expect(budget([1, 9, 1, 0], 8)).toEqual( + err({ kind: "budget", label: "DT_GNU_HASH symbol index", count: 9 }), + ); + expect(budget(gnuTable([2], [0, 0, 0, 1]), 3)).toEqual( + err({ kind: "budget", label: "DT_GNU_HASH symbol table", count: 4 }), + ); +}); + +it("yields between bounded steps so a driver can stop a long walk", () => { + const walk = readElfGnuHashSymbolCount(words(gnuTable([2], [1])), 8); + expect(walk.next().done).toBe(false); +}); diff --git a/src/domain/elfHashSymbolCount.ts b/src/domain/elfHashSymbolCount.ts new file mode 100644 index 0000000000..42ed6c209f --- /dev/null +++ b/src/domain/elfHashSymbolCount.ts @@ -0,0 +1,92 @@ +import { err, ok, type Result } from "./result.js"; + +/** + * Read one 32-bit word at a byte offset from the start of a hash table. The + * caller owns address mapping and byte order; an error is its bounds reason. + */ +export type ElfHashWordReader = (offset: number) => Result; + +export type ElfHashCountFailure = + | { readonly kind: "malformed"; readonly message: string } + | { readonly kind: "budget"; readonly label: string; readonly count: number }; + +/** Yields between bounded steps so each driver owns scheduling and cancellation. */ +export type ElfGnuHashCountWalk = Generator< + void, + Result, + void +>; + +const CHECKPOINT_STEPS = 4096; + +const malformed = (message: string): Result => + err({ kind: "malformed", message }); +const overBudget = ( + label: string, + count: number, +): Result => err({ kind: "budget", label, count }); + +/** DT_HASH's nchain is the dynamic symbol count; the whole table must be readable. */ +export const readElfHashSymbolCount = ( + read: ElfHashWordReader, + maximum = Number.POSITIVE_INFINITY, +): Result => { + const buckets = read(0); + if (!buckets.ok) return malformed(`DT_HASH header ${buckets.error}`); + const count = read(4); + if (!count.ok) return malformed(`DT_HASH header ${count.error}`); + if (count.value > maximum) + return overBudget("DT_HASH symbol table", count.value); + const end = read(8 + 4 * (buckets.value + count.value) - 4); + return end.ok ? ok(count.value) : malformed(`DT_HASH table ${end.error}`); +}; + +/** + * DT_GNU_HASH records the first hashed index and bounded buckets and chains. + * The count is the end of the last chain, with no payload-to-EOF walk. + * Empty buckets leave the count indeterminate: unhashed imports can extend + * beyond symoffset. Return null rather than treating symoffset as exact. + */ +export function* readElfGnuHashSymbolCount( + read: ElfHashWordReader, + wordBytes: 4 | 8, + maximum = Number.POSITIVE_INFINITY, +): ElfGnuHashCountWalk { + const header: number[] = []; + for (const offset of [0, 4, 8, 12]) { + const word = read(offset); + if (!word.ok) return malformed(`DT_GNU_HASH header ${word.error}`); + header.push(word.value); + } + const [buckets = 0, firstSymbol = 0, bloomWords = 0] = header; + if (buckets > maximum) return overBudget("DT_GNU_HASH bucket table", buckets); + if (bloomWords > maximum) + return overBudget("DT_GNU_HASH bloom filter", bloomWords); + if (firstSymbol > maximum) + return overBudget("DT_GNU_HASH symbol index", firstSymbol); + if (buckets === 0 || bloomWords === 0) + return malformed("DT_GNU_HASH buckets are malformed"); + const bucketsAt = 16 + bloomWords * wordBytes; + const prefixBytes = bucketsAt + buckets * 4; + const lastBucket = read(prefixBytes - 4); + if (!lastBucket.ok) + return malformed(`DT_GNU_HASH buckets ${lastBucket.error}`); + let last = 0; + for (let index = 0; index < buckets; index += 1) { + if (index % CHECKPOINT_STEPS === 0) yield; + const value = read(bucketsAt + index * 4); + if (!value.ok) return malformed(`DT_GNU_HASH buckets ${value.error}`); + if (value.value !== 0 && value.value < firstSymbol) + return malformed("DT_GNU_HASH bucket precedes its first symbol"); + last = Math.max(last, value.value); + } + if (last === 0) return ok(null); + for (let index = last; ; index += 1) { + if (index + 1 > maximum) + return overBudget("DT_GNU_HASH symbol table", index + 1); + if ((index - last) % CHECKPOINT_STEPS === 0) yield; + const chain = read(prefixBytes + (index - firstSymbol) * 4); + if (!chain.ok) return malformed(`DT_GNU_HASH chain ${chain.error}`); + if ((chain.value & 1) !== 0) return ok(index + 1); + } +} diff --git a/src/domain/elfProgramHeader.test.ts b/src/domain/elfProgramHeader.test.ts index d082a7cb7a..acb35aae88 100644 --- a/src/domain/elfProgramHeader.test.ts +++ b/src/domain/elfProgramHeader.test.ts @@ -1,5 +1,8 @@ import { expect, it } from "vitest"; -import { readElfProgramHeader } from "./elfProgramHeader.js"; +import { + admitElfLoadSegment, + readElfProgramHeader, +} from "./elfProgramHeader.js"; const formats = [ { bits: 32, littleEndian: false }, @@ -85,3 +88,12 @@ it.each(formats)( }); }, ); + +it("admits load sizes only within the file-backed and address limits", () => { + const segment = { virtualAddress: 0x1000n, memorySize: 16n, fileSize: 16n }; + expect(admitElfLoadSegment(segment, 64).ok).toBe(true); + expect(admitElfLoadSegment({ ...segment, fileSize: 17n }, 64).ok).toBe(false); + expect( + admitElfLoadSegment({ ...segment, virtualAddress: 0xfffffff8n }, 32).ok, + ).toBe(false); +}); diff --git a/src/domain/elfProgramHeader.ts b/src/domain/elfProgramHeader.ts index b05cf9542b..63a7ef9168 100644 --- a/src/domain/elfProgramHeader.ts +++ b/src/domain/elfProgramHeader.ts @@ -33,3 +33,20 @@ export const readElfProgramHeader = ( alignment: word(wide ? 48 : 28), }); }; + +/** Admit PT_LOAD sizes once for consumers that read through load mappings. */ +export const admitElfLoadSegment = ( + segment: { + readonly virtualAddress: bigint; + readonly memorySize: bigint; + readonly fileSize: bigint; + }, + bits: 32 | 64, +) => + segment.fileSize > segment.memorySize || + segment.virtualAddress + segment.memorySize > 1n << BigInt(bits) + ? err({ + kind: "malformed" as const, + message: "ELF load segment sizes or addresses are invalid", + }) + : ok(null); diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index 0917a06af5..b328aebcbf 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -183,3 +183,10 @@ export const PE_LOAD_CONFIG_PROVIDER = Object.freeze({ name: "REA PE TLS/load-config parser", version: "1", } as const); + +/** Provider identity for portable ELF, PE, and Mach-O hardening inspection. */ +export const BINARY_HARDENING_PROVIDER = Object.freeze({ + id: "rea-binary-hardening", + name: "REA binary hardening parser", + version: "1", +} as const); diff --git a/src/domain/native/binaryHardening.test.ts b/src/domain/native/binaryHardening.test.ts new file mode 100644 index 0000000000..ff1653c827 --- /dev/null +++ b/src/domain/native/binaryHardening.test.ts @@ -0,0 +1,152 @@ +import { expect, it } from "vitest"; +import { + BINARY_HARDENING_LIMITATIONS, + ELF_HARDENING_PROPERTIES, + MACHO_HARDENING_PROPERTIES, + MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES, + MAX_INSPECT_BINARY_HARDENING_FILE_BYTES, + PE_HARDENING_PROPERTIES, + binaryHardeningSchema, + hardeningPropertiesFor, + inspectBinaryHardeningInputSchema, + type BinaryHardening, + type BinaryHardeningProperty, +} from "./binaryHardening.js"; + +const row = (property: BinaryHardeningProperty) => ({ + property, + state: + property === "hardened_runtime" + ? ("unknown" as const) + : ("disabled" as const), + confidence: "observed" as const, + basis: property, + location: null, + note: null, + next_probe: property === "hardened_runtime" ? "inspect_signature" : null, +}); + +const report = (): BinaryHardening => ({ + artifact: { path: "/artifacts/app", sha256: "a".repeat(64), bytes: 64 }, + format: "elf", + images: [ + { + format: "elf", + status: "parsed", + reason: null, + architecture: "x86_64", + bits: 64, + byte_order: "little", + machine: 62, + slice: null, + rows: ELF_HARDENING_PROPERTIES.map((property) => row(property)), + }, + ], + limitations: [...BINARY_HARDENING_LIMITATIONS], +}); + +it("defaults the read budget and rejects paths outside the ceiling", () => { + expect( + inspectBinaryHardeningInputSchema.parse({ path: "/artifacts/app" }), + ).toEqual({ path: "/artifacts/app", max_file_bytes: 67108864 }); + for (const input of [ + { path: "relative" }, + { path: "/artifacts/app", extra: true }, + { path: "/artifacts/app", max_file_bytes: 63 }, + { + path: "/artifacts/app", + max_file_bytes: MAX_INSPECT_BINARY_HARDENING_FILE_BYTES + 1, + }, + ]) + expect(inspectBinaryHardeningInputSchema.safeParse(input).success).toBe( + false, + ); +}); + +it("keeps one property order per format", () => { + expect(hardeningPropertiesFor("elf")).toEqual(ELF_HARDENING_PROPERTIES); + expect(hardeningPropertiesFor("pe")).toEqual(PE_HARDENING_PROPERTIES); + expect(hardeningPropertiesFor("mach-o")).toEqual(MACHO_HARDENING_PROPERTIES); + expect(BINARY_HARDENING_LIMITATIONS).toHaveLength(6); + expect(BINARY_HARDENING_LIMITATIONS.join("\n")).toContain( + "inspect_signature", + ); +}); + +it("accepts a closed hardening report and rejects an empty image list", () => { + const value = report(); + expect(binaryHardeningSchema.parse(value)).toEqual(value); + expect( + binaryHardeningSchema.safeParse({ ...value, images: [] }).success, + ).toBe(false); + expect( + binaryHardeningSchema.safeParse({ + ...value, + images: [ + { + ...value.images[0], + rows: [...value.images[0]!.rows, row("pie")], + }, + ], + }).success, + ).toBe(true); +}); + +it("accepts a failed Mach-O slice that declares size 0", () => { + const value = report(); + const failed = { + format: "mach-o" as const, + status: "malformed" as const, + reason: "FAT architecture 0 has an invalid slice range", + architecture: "arm64", + bits: null, + byte_order: null, + machine: 0x0100000c, + slice: { + index: 0, + file_offset: 32, + size: 0, + cpu_type: 0x0100000c, + cpu_subtype: 0, + architecture: "arm64", + }, + rows: [], + }; + expect( + binaryHardeningSchema.parse({ + ...value, + format: "mach-o", + images: [failed], + }).images[0]?.slice?.size, + ).toBe(0); + const wide = binaryHardeningSchema.parse({ + ...value, + format: "mach-o", + images: [ + { + ...failed, + slice: { + ...failed.slice, + file_offset: 2 ** 32, + size: 2 ** 32, + }, + }, + ], + }); + expect(wide.images[0]?.slice?.file_offset).toBe(2 ** 32); + expect(wide.images[0]?.slice?.size).toBe(2 ** 32); + const noted = { + ...value.images[0]!, + rows: value.images[0]!.rows.map((entry) => + entry.property === "rpath" + ? { + ...entry, + note: "A".repeat(MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES + 1), + } + : entry, + ), + }; + expect( + binaryHardeningSchema.safeParse({ ...value, images: [noted] }).success, + ).toBe(false); +}); diff --git a/src/domain/native/binaryHardening.ts b/src/domain/native/binaryHardening.ts new file mode 100644 index 0000000000..5321aafdfb --- /dev/null +++ b/src/domain/native/binaryHardening.ts @@ -0,0 +1,187 @@ +import { z } from "zod"; + +import { + BINARY_ARCHITECTURES, + RAW_ONLY_ARCHITECTURES, +} from "../binaryTargetTypes.js"; +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_BINARY_HARDENING_FILE_BYTES = 536870912; +/** Fixed dynamic-entry and symbol budget. This release has no runtime override. */ +export const MAX_BINARY_HARDENING_TABLE_ENTRIES = 65536; +/** Bytes examined for one symbol name. A longer run is not a terminated name. */ +export const MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES = 65536; +/** Aggregate string-table bytes examined while resolving symbol names. */ +export const MAX_BINARY_HARDENING_SYMBOL_SCAN_BYTES = 16 * 1024 * 1024; + +const uint = z.number().int().min(0).max(0xffffffff); +/** FAT64 declarations can exceed 2^32 while staying inside a safe integer. */ +const safeNonNegative = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); + +/** Explicit-file hardening inspection of one ELF, PE, or Mach-O image. */ +export const inspectBinaryHardeningInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to an ELF, PE, or Mach-O image", + ), + max_file_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_BINARY_HARDENING_FILE_BYTES) + .default(67108864) + .describe("Maximum stable artifact bytes to read (default 64 MiB)"), +}); +export type InspectBinaryHardeningInput = z.infer< + typeof inspectBinaryHardeningInputSchema +>; + +export const BINARY_HARDENING_STATES = [ + "enabled", + "disabled", + "partial", + "unknown", + "not_applicable", +] as const; + +/** ELF rows, in report order. */ +export const ELF_HARDENING_PROPERTIES = [ + "pie", + "nx", + "relro", + "stack_protector", + "fortify", + "cet_ibt_shstk", + "aarch64_bti_pac", + "rpath", + "runpath", + "stripped", +] as const; + +/** PE rows, in report order. */ +export const PE_HARDENING_PROPERTIES = [ + "dynamic_base", + "high_entropy_va", + "nx_compat", + "force_integrity", + "no_seh", + "guard_cf", + "cfg_instrumented", + "authenticode_present", + "debug_record_present", +] as const; + +/** Mach-O rows, in report order. One row per slice. */ +export const MACHO_HARDENING_PROPERTIES = [ + "pie", + "allow_stack_execution", + "no_heap_execution", + "restrict_segment", + "code_signature_present", + "encrypted", + "arm64e", + "stack_protector", + "hardened_runtime", +] as const; + +export const BINARY_HARDENING_PROPERTIES = [ + ...new Set([ + ...ELF_HARDENING_PROPERTIES, + ...PE_HARDENING_PROPERTIES, + ...MACHO_HARDENING_PROPERTIES, + ]), +] as const; +export type BinaryHardeningProperty = + (typeof BINARY_HARDENING_PROPERTIES)[number]; + +export const BINARY_HARDENING_FORMATS = ["elf", "pe", "mach-o"] as const; +export type BinaryHardeningFormat = (typeof BINARY_HARDENING_FORMATS)[number]; + +/** Property order a parsed image of this format must report. */ +export const hardeningPropertiesFor = ( + format: BinaryHardeningFormat, +): readonly BinaryHardeningProperty[] => { + switch (format) { + case "elf": + return ELF_HARDENING_PROPERTIES; + case "pe": + return PE_HARDENING_PROPERTIES; + case "mach-o": + return MACHO_HARDENING_PROPERTIES; + } +}; + +const locationSchema = z.strictObject({ offset: uint, bytes: uint.min(1) }); + +const rowSchema = z.strictObject({ + property: z.enum(BINARY_HARDENING_PROPERTIES), + state: z.enum(BINARY_HARDENING_STATES), + confidence: z.enum(["observed", "derived"]), + basis: z.string().min(1), + location: locationSchema.nullable(), + note: z + .string() + .min(1) + .max(MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES) + .nullable(), + next_probe: z.string().min(1).nullable(), +}); +export type BinaryHardeningRow = z.infer; + +const sliceSchema = z.strictObject({ + index: uint, + // A failed FAT64 slice may declare an offset or size of 2^32 or more. + file_offset: safeNonNegative, + // A failed fat slice may declare size 0. Parsed slices are checked later. + size: safeNonNegative, + cpu_type: z.number().int(), + cpu_subtype: z.number().int(), + architecture: z.string().min(1), +}); +export type BinaryHardeningSlice = z.infer; + +const imageSchema = z.strictObject({ + format: z.enum(BINARY_HARDENING_FORMATS), + status: z.enum(["parsed", "malformed", "unknown"]), + reason: z.string().min(1).nullable(), + /** CPU family. `machine` and `slice.architecture` keep the exact identity. */ + architecture: z + .enum(BINARY_ARCHITECTURES) + .exclude([...RAW_ONLY_ARCHITECTURES]) + .nullable(), + bits: z.union([z.literal(32), z.literal(64)]).nullable(), + byte_order: z.enum(["little", "big"]).nullable(), + machine: uint.nullable(), + slice: sliceSchema.nullable(), + rows: z.array(rowSchema), +}); +export type BinaryHardeningImage = z.infer; + +/** + * Facts this inspection does not establish. A missing import is not evidence + * that the protection was left out of the build. + */ +export const BINARY_HARDENING_LIMITATIONS = [ + "This inspection reads headers, program tables, dynamic symbols, and imports. It does not execute the image or assess exploitability.", + "A missing stack-protector or fortify symbol does not establish that the protection is absent.", + "An undefined stack-protector or fortify symbol is a sign that some code uses the protection, not proof that all code does.", + "Authenticode and Mach-O code-signature rows report table or load-command presence, not signature validity.", + "PE control-flow guard state is the GuardFlags linker claim, not a check that CFG checks run.", + "Mach-O hardened runtime is not read here. inspect_signature owns CodeDirectory flags.", +] as const; + +const artifactSchema = z.strictObject({ + path: absoluteLocalPathStringSchema, + sha256: digestSchema, + bytes: z.number().int().min(0).max(MAX_INSPECT_BINARY_HARDENING_FILE_BYTES), +}); + +/** One file, or one image per Mach-O slice, with a row for each property. */ +export const binaryHardeningSchema = z.strictObject({ + artifact: artifactSchema, + format: z.enum(BINARY_HARDENING_FORMATS), + images: z.array(imageSchema).min(1), + limitations: z.array(z.string().min(1)), +}); +export type BinaryHardening = z.infer; diff --git a/src/domain/peOptionalHeader.test.ts b/src/domain/peOptionalHeader.test.ts index 6a01892bfa..96e5cdb4d6 100644 --- a/src/domain/peOptionalHeader.test.ts +++ b/src/domain/peOptionalHeader.test.ts @@ -1,6 +1,9 @@ import { expect, it } from "vitest"; import { readPeOptionalHeader } from "./peMachine.js"; -import { readPeOptionalHeaderStructure } from "./peOptionalHeader.js"; +import { + readPeOptionalHeaderFixedFields, + readPeOptionalHeaderStructure, +} from "./peOptionalHeader.js"; const formats = [ { format: "pe32", magic: 0x10b, bits: 32, directoryOffset: 96 }, @@ -66,6 +69,7 @@ it.each(formats)( fileAlignment: 0x200, sizeOfImage: 0, sizeOfHeaders: 0xffffffff, + dllCharacteristics: 0, directoryCountOffset: format.directoryOffset - 4, directoryCount: 0, dataDirectories: [], @@ -153,6 +157,40 @@ it.each(formats)( }, ); +it.each(formats)( + "reads $format mapping fields from 64 bytes and leaves DllCharacteristics unread", + (format) => { + const bytes = Buffer.alloc(64); + bytes.writeUInt16LE(format.magic); + if (format.bits === 64) bytes.writeBigUInt64LE(0xfedcba9876540000n, 24); + else bytes.writeUInt32LE(0x87650000, 28); + bytes.writeUInt32LE(0x1000, 32); + bytes.writeUInt32LE(0x200, 36); + bytes.writeUInt32LE(0x1000, 56); + bytes.writeUInt32LE(0x400, 60); + const fixed = readPeOptionalHeaderFixedFields(bytes); + if (!fixed.ok) throw new Error(fixed.error.message); + expect(fixed.value.dllCharacteristics).toBeNull(); + expect(fixed.value.sizeOfHeaders).toBe(0x400); + expect(fixed.value.sizeOfImage).toBe(0x1000); + expect(readPeOptionalHeaderStructure(bytes)).toMatchObject({ + ok: false, + error: { reason: "truncated-fixed-fields" }, + }); + }, +); + +it.each(formats)( + "copies $format DllCharacteristics from the fixed optional-header fields", + (format) => { + const bytes = header(format, 0); + bytes.writeUInt16LE(0x4160, 70); + const result = readPeOptionalHeaderStructure(bytes); + if (!result.ok) throw new Error(result.error.message); + expect(result.value.dllCharacteristics).toBe(0x4160); + }, +); + it("honors a Uint8Array window and detaches facts from later source changes", () => { const bytes = header(formats[1], 1); bytes.writeUInt32LE(0x12345678, 112); @@ -174,6 +212,7 @@ it("honors a Uint8Array window and detaches facts from later source changes", () fileAlignment: 0x200, sizeOfImage: 0, sizeOfHeaders: 0x400, + dllCharacteristics: 0, directoryCountOffset: formats[1].directoryOffset - 4, directoryCount: 1, dataDirectories: [{ address: 0x12345678, size: 0x90, offset: 112 }], diff --git a/src/domain/peOptionalHeader.ts b/src/domain/peOptionalHeader.ts index 4500b288b5..8f5c049e47 100644 --- a/src/domain/peOptionalHeader.ts +++ b/src/domain/peOptionalHeader.ts @@ -21,11 +21,20 @@ export interface PeOptionalHeaderFixedFields extends PeOptionalHeaderIdentity { readonly fileAlignment: number; readonly sizeOfImage: number; readonly sizeOfHeaders: number; + /** + * Null when the declared header ends before the DllCharacteristics word. + * Mapping fields through SizeOfHeaders do not require it. + */ + readonly dllCharacteristics: number | null; readonly directoryOffset: number; readonly directoryCountOffset: number; } -export interface PeOptionalHeaderStructure extends PeOptionalHeaderFixedFields { +export interface PeOptionalHeaderStructure extends Omit< + PeOptionalHeaderFixedFields, + "dllCharacteristics" +> { + readonly dllCharacteristics: number; readonly directoryCount: number; readonly dataDirectories: readonly PeDataDirectory[]; } @@ -70,6 +79,9 @@ export const readPeOptionalHeaderFixedFields = ( if (!identity.ok) return identity; const { bits } = identity.value; const directoryOffset = bits === 64 ? 112 : 96; + // SizeOfHeaders ends at byte 64. DllCharacteristics, at offset 70, is only + // present when the declared header covers that word. NativeAOT and other + // mapping-field consumers admit a 64-byte header. if (bytes.byteLength < 64) return err({ kind: "malformed", @@ -87,6 +99,8 @@ export const readPeOptionalHeaderFixedFields = ( fileAlignment: view.getUint32(36, true), sizeOfImage: view.getUint32(56, true), sizeOfHeaders: view.getUint32(60, true), + dllCharacteristics: + bytes.byteLength >= 72 ? view.getUint16(70, true) : null, directoryOffset, directoryCountOffset: directoryOffset - 4, }); @@ -154,5 +168,13 @@ export const readPeOptionalHeaderStructure = ( const dataDirectories = Array.from({ length: directoryCount }, (_, index) => readDirectory(view, fixed.directoryOffset + index * 8), ); - return ok({ ...fixed, directoryCount, dataDirectories }); + // A directory table starts at byte 96 or 112, so the word at offset 70 is covered. + const dllCharacteristics = fixed.dllCharacteristics; + if (dllCharacteristics === null) + return err({ + kind: "malformed", + reason: "truncated-fixed-fields", + message: "truncated PE optional header", + }); + return ok({ ...fixed, dllCharacteristics, directoryCount, dataDirectories }); }; diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts index b854c27c81..a38381a7c0 100644 --- a/src/flutter/DartElf.test.ts +++ b/src/flutter/DartElf.test.ts @@ -381,6 +381,8 @@ describe("ELF symbol-table ownership", () => { ).toEqual(names); image.writeUInt32LE(0xffffffff, hash + 24); expect((await readElfSymbols(image, names)).symbols).toEqual([]); + image.writeUInt32LE(0, hash + 24); + expect((await readElfSymbols(image, names)).failures).toHaveLength(1); }); }); diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts index d547cfa9d1..69ed869433 100644 --- a/src/flutter/DartElf.ts +++ b/src/flutter/DartElf.ts @@ -2,7 +2,14 @@ import { observeElfStringTableEntry, elfStringTableText, } from "../domain/elfStringTable.js"; +import { err, ok } from "../domain/result.js"; import { readElfDynamicEntry } from "../domain/elfDynamicEntry.js"; +import { + readElfGnuHashSymbolCount, + readElfHashSymbolCount, + type ElfGnuHashCountWalk, + type ElfHashWordReader, +} from "../domain/elfHashSymbolCount.js"; import { readElfSectionHeader } from "../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { readElfSymbol } from "../domain/elfSymbol.js"; @@ -17,6 +24,18 @@ import { } from "./FlutterPayloadScan.js"; import { readElfFixedHeader } from "../domain/elfFixedHeader.js"; +/** Keep the event loop and cancellation live across a large DT_GNU_HASH walk. */ +const finishGnuHashCount = async ( + walk: ElfGnuHashCountWalk, + signal: AbortSignal | undefined, +) => { + for (let step = walk.next(); ; step = walk.next()) { + if (step.done === true) return step.value; + await yieldToEventLoop(); + signal?.throwIfAborted(); + } +}; + /** Bounds-checked little-endian reader over one loaded buffer. */ class BoundedReader { constructor(private readonly bytes: Buffer) {} @@ -396,64 +415,37 @@ export const readElfSymbols = async ( return failure( "Stripped ELF lacks a complete dynamic symbol-table description", ); - // DT_HASH's nchain is the dynsym count. GNU hash instead records the - // first hashed index and bounded buckets/chains. No payload-to-EOF walk. - let count: number; const sysv = tags.get(DT_HASH); const gnu = tags.get(DT_GNU_HASH); - if (sysv !== undefined) { - const at = toOffset(sysv, 8); - if (!at.ok) return failure(`DT_HASH header ${at.error.reason}`); - const buckets = reader.u32(at.value)!; - count = reader.u32(at.value + 4)!; - const table = toOffset(sysv, 8 + 4 * (buckets + count)); - if (!table.ok) return failure(`DT_HASH table ${table.error.reason}`); - } else if (gnu !== undefined) { - const at = toOffset(gnu, 16); - if (!at.ok) return failure(`DT_GNU_HASH header ${at.error.reason}`); - const buckets = reader.u32(at.value)!; - const firstSymbol = reader.u32(at.value + 4)!; - const bloomWords = reader.u32(at.value + 8)!; - const prefixBytes = 16 + bloomWords * (is64 ? 8 : 4) + buckets * 4; - if (buckets === 0 || bloomWords === 0) - return failure("DT_GNU_HASH buckets are malformed"); - const prefix = toOffset(gnu, prefixBytes); - if (!prefix.ok) - return failure(`DT_GNU_HASH buckets ${prefix.error.reason}`); - let last = 0; - const bucketsAt = at.value + 16 + bloomWords * (is64 ? 8 : 4); - for (let index = 0; index < buckets; index += 1) { - if (index % 4096 === 0) { - await yieldToEventLoop(); - signal?.throwIfAborted(); - } - const value = reader.u32(bucketsAt + index * 4)!; - if (value !== 0 && value < firstSymbol) - return failure("DT_GNU_HASH bucket precedes its first symbol"); - last = Math.max(last, value); - } - count = firstSymbol; - if (last !== 0) { - let index = last; - while (true) { - if ((index - last) % 4096 === 0) { - await yieldToEventLoop(); - signal?.throwIfAborted(); - } - const span = prefixBytes + (index - firstSymbol + 1) * 4; - const range = toOffset(gnu, span); - if (!range.ok) - return failure(`DT_GNU_HASH chain ${range.error.reason}`); - const chain = reader.u32(at.value + span - 4)!; - index += 1; - if ((chain & 1) !== 0) { - count = index; - break; - } - } - } - } else + const hashWords = + (address: number): ElfHashWordReader => + (offset) => { + const mapped = toOffset(address, offset + 4); + return mapped.ok + ? ok(reader.u32(mapped.value + offset)!) + : err(mapped.error.reason); + }; + if (sysv === undefined && gnu === undefined) return failure("Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count"); + const counted = + sysv !== undefined + ? readElfHashSymbolCount(hashWords(sysv)) + : await finishGnuHashCount( + readElfGnuHashSymbolCount(hashWords(gnu!), is64 ? 8 : 4), + signal, + ); + // These walks pass no budget, so a failure here is always malformed. + if (!counted.ok) + return failure( + counted.error.kind === "malformed" + ? counted.error.message + : counted.error.label, + ); + const count = counted.value; + if (count === null) + return failure( + "Stripped ELF has an indeterminate DT_GNU_HASH symbol count", + ); const offset = toOffset(sym, count * entrySize); const stringOffset = toOffset(str, stringSize); if (!offset.ok) diff --git a/src/native/MachoCommandNames.ts b/src/native/MachoCommandNames.ts index 13d8f8ee7c..18d934701c 100644 --- a/src/native/MachoCommandNames.ts +++ b/src/native/MachoCommandNames.ts @@ -1,5 +1,7 @@ import { MachoLoadCommandCursor, + decodeMachoFixedWordFields, + machoFixedWordFieldNames, readMachoLoadCommandTableHeader, } from "../domain/apple/machoLoadCommands.js"; import { readMachoSegmentHeader } from "../domain/apple/machoSegmentHeader.js"; @@ -26,81 +28,6 @@ const LC_STR_COMMANDS = new Set([ 0x39, ]); -// Fields with fixed uint32 layouts in mach-o/loader.h. Keep these names at -// the binary owner so a downstream projection need not reinterpret text. -const FIXED_WORD_FIELDS: ReadonlyMap = new Map([ - [0x2, ["symoff", "nsyms", "stroff", "strsize"]], - [0x3, ["offset", "size"]], - [ - 0xb, - [ - "ilocalsym", - "nlocalsym", - "iextdefsym", - "nextdefsym", - "iundefsym", - "nundefsym", - "tocoff", - "ntoc", - "modtaboff", - "nmodtab", - "extrefsymoff", - "nextrefsyms", - "indirectsymoff", - "nindirectsyms", - "extreloff", - "nextrel", - "locreloff", - "nlocrel", - ], - ], - [0x16, ["offset", "nhints"]], - [0x17, ["cksum"]], - [0x1d, ["dataoff", "datasize"]], - [0x1e, ["dataoff", "datasize"]], - [0x21, ["cryptoff", "cryptsize", "cryptid"]], - [ - 0x22, - [ - "rebase_off", - "rebase_size", - "bind_off", - "bind_size", - "weak_bind_off", - "weak_bind_size", - "lazy_bind_off", - "lazy_bind_size", - "export_off", - "export_size", - ], - ], - [ - 0x80000022, - [ - "rebase_off", - "rebase_size", - "bind_off", - "bind_size", - "weak_bind_off", - "weak_bind_size", - "lazy_bind_off", - "lazy_bind_size", - "export_off", - "export_size", - ], - ], - [0x26, ["dataoff", "datasize"]], - [0x29, ["dataoff", "datasize"]], - [0x2b, ["dataoff", "datasize"]], - [0x2c, ["cryptoff", "cryptsize", "cryptid", "pad"]], - [0x2e, ["dataoff", "datasize"]], - [0x80000033, ["dataoff", "datasize"]], - [0x80000034, ["dataoff", "datasize"]], - [0x36, ["dataoff", "datasize"]], - [0x37, ["dataoff", "datasize"]], - [0x38, ["dataoff", "datasize"]], -]); - /** Read exact ranges from an already admitted and identity-verified slice. */ export type MachoRangeReader = ( offset: number, @@ -304,26 +231,23 @@ export const readMachoCommandNames = async ( if (!row.ok) throw new MachoBinaryFormatError(row.error); const frame = row.value; const at = table.headerSize + frame.offset; - const fixedNames = FIXED_WORD_FIELDS.get(frame.command); - let fixedFields: Record | undefined; + const fixedNames = machoFixedWordFieldNames(frame.command); + let fixedFields: Readonly> | undefined; if (fixedNames !== undefined) { const byteLength = fixedNames.length * 4; - if (frame.size < 8 + byteLength) + const decoded = + frame.size < 8 + byteLength + ? null + : decodeMachoFixedWordFields( + frame.command, + await exact(at + 8, byteLength), + table.littleEndian, + ); + if (decoded === null) throw new MachoBinaryFormatError( "Mach-O fixed command fields are truncated", ); - const bytes = await exact(at + 8, byteLength); - const view = new DataView( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength, - ); - fixedFields = Object.fromEntries( - fixedNames.map((name, index) => [ - name, - view.getUint32(index * 4, table.littleEndian), - ]), - ); + fixedFields = decoded; } const segmentWide = frame.command === 0x19 ? true : frame.command === 1 ? false : null; diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index 1d60e43add..6d911d3f17 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -8,6 +8,7 @@ import { validateElfSectionTablePresence } from "../../domain/elfDeclaredRanges. import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { createHash } from "node:crypto"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; import { readElfFixedHeader } from "../../domain/elfFixedHeader.js"; import { elfNoteAlignment as noteAlignment, @@ -171,6 +172,7 @@ const parseNoteStream = ( maxEntries: number, examined: { count: number }, text: TextBudget, + signal?: AbortSignal, ): ParsedNote[] => { // Alignment padding is not read when a note's aligned size equals the // declared table. Require the whole table first so a short file is invalid @@ -179,6 +181,7 @@ const parseNoteStream = ( const notes: ParsedNote[] = []; let at = 0; while (at + 12 <= size) { + assertArtifactReadActive(signal); examined.count += 1; if (examined.count > maxEntries) throw new ArtifactReaderFailure( @@ -909,3 +912,117 @@ const toEntry = ( ), }; }; + +const NT_GNU_PROPERTY_TYPE_0 = 5; +const GNU_PROPERTY_AARCH64_FEATURE_1_AND = 0xc0000000; +const GNU_PROPERTY_X86_FEATURE_1_AND = 0xc0000002; + +/** One note table. `alignment` is the declared `sh_addralign` or `p_align`. */ +export interface ElfNoteTableWalk { + readonly offset: number; + readonly size: number; + readonly alignment: number | bigint; +} + +export interface ElfWalkedNote { + readonly owner: string; + readonly type: number; + readonly descriptor: Buffer; + readonly descriptorOffset: number; +} + +export interface GnuPropertyWord { + readonly value: number; + readonly offset: number; + readonly conflict: boolean; +} + +/** Walk note tables with the owner's alignment, entry budget, and cancellation. */ +export const walkElfNoteTables = ( + bytes: Buffer, + little: boolean, + tables: readonly ElfNoteTableWalk[], + maxEntries: number, + signal?: AbortSignal, +): ElfWalkedNote[] => { + const reader = new ElfReader(bytes, little); + const text: TextBudget = { + limit: Math.max(bytes.length, 1), + used: 0, + hashed: 0, + scanned: 0, + hashes: new Map(), + scans: new Map(), + }; + const examined = { count: 0 }; + const notes: ElfWalkedNote[] = []; + for (const table of tables) { + assertArtifactReadActive(signal); + for (const note of parseNoteStream( + reader, + table.offset, + table.size, + noteAlignment(table.alignment), + maxEntries, + examined, + text, + signal, + )) + notes.push({ + owner: note.owner, + type: note.type, + descriptor: note.descriptor, + descriptorOffset: note.descriptor_offset, + }); + } + return notes; +}; + +/** GNU property feature words from notes the owner already walked. */ +export const readGnuPropertyWords = ( + notes: readonly ElfWalkedNote[], + little: boolean, + propertyAlign: number, +): Map => { + const found = new Map(); + for (const note of notes) { + if (note.owner !== "GNU" || note.type !== NT_GNU_PROPERTY_TYPE_0) continue; + const descriptor = note.descriptor; + let cursor = 0; + while (cursor < descriptor.length) { + if (descriptor.length - cursor < 8) + fail("ELF GNU property record is truncated."); + const type = little + ? descriptor.readUInt32LE(cursor) + : descriptor.readUInt32BE(cursor); + const dataSize = little + ? descriptor.readUInt32LE(cursor + 4) + : descriptor.readUInt32BE(cursor + 4); + if (dataSize > descriptor.length - cursor - 8) + fail("ELF GNU property data extends outside the note descriptor."); + const step = Math.ceil((8 + dataSize) / propertyAlign) * propertyAlign; + if (!Number.isSafeInteger(step) || cursor + step > descriptor.length) + fail("ELF GNU property record is truncated."); + if ( + type === GNU_PROPERTY_X86_FEATURE_1_AND || + type === GNU_PROPERTY_AARCH64_FEATURE_1_AND + ) { + if (dataSize !== 4) + fail("ELF GNU feature property is not a 4-byte word."); + const value = little + ? descriptor.readUInt32LE(cursor + 8) + : descriptor.readUInt32BE(cursor + 8); + const previous = found.get(type); + found.set(type, { + value: previous === undefined ? value : previous.value, + offset: previous?.offset ?? note.descriptorOffset + cursor + 8, + conflict: + previous !== undefined && + (previous.conflict || previous.value !== value), + }); + } + cursor += step; + } + } + return found; +}; diff --git a/src/native/hardening/BinaryHardening.fixture.ts b/src/native/hardening/BinaryHardening.fixture.ts new file mode 100644 index 0000000000..f82b851e16 --- /dev/null +++ b/src/native/hardening/BinaryHardening.fixture.ts @@ -0,0 +1,1302 @@ +import { elfNote, gnuBuildIdNote } from "../elf/ElfNotes.fixture.js"; +import { + peLoadConfigImage, + type PeLoadConfigFixtureMalformed, +} from "../pe/PeLoadConfig.fixture.js"; + +const PT_LOAD = 1; +const PT_DYNAMIC = 2; +const PT_INTERP = 3; +const PT_GNU_STACK = 0x6474e551; +const PT_GNU_RELRO = 0x6474e552; +const PT_GNU_PROPERTY = 0x6474e553; +const SHT_PROGBITS = 1; +const SHT_SYMTAB = 2; +const SHT_STRTAB = 3; +const SHT_DYNAMIC = 6; +const SHT_NOTE = 7; +const SHT_DYNSYM = 11; +const DT_NULL = 0; +const DT_NEEDED = 1; +const DT_HASH = 4; +const DT_STRTAB = 5; +const DT_SYMTAB = 6; +const DT_STRSZ = 10; +const DT_SYMENT = 11; +const DT_RPATH = 15; +const DT_RUNPATH = 29; +const DT_BIND_NOW = 24; +const DT_FLAGS = 30; +const DT_FLAGS_1 = 0x6ffffffb; +const DF_BIND_NOW = 0x8; +const DF_1_NOW = 0x1; +const DF_1_PIE = 0x08000000; +const PF_R = 4; +const SHF_COMPRESSED = 0x800; + +export interface ElfHardeningFixtureOptions { + readonly bits?: 32 | 64; + readonly little?: boolean; + readonly machine?: number; + readonly type?: number; + readonly dynamic?: boolean; + readonly interp?: boolean; + readonly pieFlag?: boolean; + readonly bindNow?: boolean; + readonly rpath?: string | null; + readonly runpath?: string | null; + readonly stackChk?: boolean; + readonly fortify?: boolean; + /** Set st_shndx so the protector names are defined exports, not imports. */ + readonly exportSymbols?: boolean; + /** Leave st_info zero so the protector names have STB_LOCAL binding. */ + readonly localSymbols?: boolean; + readonly gnuStack?: { readonly flags: number } | null; + readonly gnuRelro?: boolean; + readonly symtab?: boolean; + readonly sections?: boolean; + readonly property?: { + readonly arch: "x86" | "aarch64"; + readonly value: number; + readonly also?: number; + } | null; + /** Where a GNU property note is declared. Defaults to PT_GNU_PROPERTY. */ + readonly propertyCarrier?: "pt-gnu-property" | "pt-note"; + /** Repeat one GNU property note so a section and program overlap past 256. */ + readonly propertyCopies?: number; + readonly needed?: readonly string[]; + /** DT_BIND_NOW tag without DF_BIND_NOW or DF_1_NOW. */ + readonly bindNowTag?: boolean; + /** Two DT_FLAGS entries. */ + readonly duplicateFlags?: boolean; + readonly duplicateStack?: boolean; + readonly truncateDynamic?: boolean; + /** DT_HASH nchain above the symbol budget, with no section symbol table. */ + readonly symbolBudget?: boolean; + /** One `.note.gnu.build-id` section, `sh_addralign` 4. */ + readonly buildId?: boolean; + /** Build-id notes concatenated in one section. */ + readonly buildIdCount?: number; + /** Repeated SHT_NOTE headers naming that same build-id range. */ + readonly buildIdSections?: number; + /** 300 symbols sharing one long terminated name. */ + readonly symbolScanBudget?: boolean; + /** One symbol whose name exceeds the per-name scan cap. */ + readonly longSymbolName?: boolean; + /** Replace `__memcpy_chk`; bytes are written as latin1. */ + readonly fortifyName?: string; + /** Mark `.note.gnu.property` as SHF_COMPRESSED. */ + readonly compressedPropertyNote?: boolean; + /** Disjoint `.note.gnu.property` sections holding one empty note each. */ + readonly disjointNoteSections?: number; + /** A property note whose name charges more text than the file holds. */ + readonly longNoteName?: boolean; +} + +interface ElfProgramSpec { + type: number; + flags: number; + offset: number; + address: number; + filesz: number; + memsz: number; + align: number; +} + +interface ElfSectionSpec { + name: string; + type: number; + offset: number; + size: number; + link: number; + entry: number; + align?: number; + flags?: number; +} + +interface ElfSymbolPayload { + readonly dynstr: Buffer; + readonly dynsym: Buffer; + readonly hash: Buffer; + readonly symbolWidth: number; + readonly dynamicWidth: number; + readonly stringOffset: (value: string | null | undefined) => number | null; +} + +interface ElfPlacedImage { + readonly programs: ElfProgramSpec[]; + readonly sectionTable: ElfSectionSpec[]; + readonly shstrtab: Buffer; + readonly intern: (name: string) => number; + readonly interpAt: number; + readonly dynstrAt: number; + readonly dynsymAt: number; + readonly hashAt: number; + readonly noteAt: number; + readonly buildIdAt: number; + readonly dynamicAt: number; + readonly shstrAt: number; + readonly shoff: number; + readonly fileSize: number; +} + +const elfProgram = ( + type: number, + flags: number, + filesz: number, + memsz: number, + align: number, +): ElfProgramSpec => ({ + type, + flags, + offset: 0, + address: 0, + filesz, + memsz, + align, +}); + +/** st_info for STB_GLOBAL, STT_FUNC. */ +const ELF_GLOBAL_FUNC = 0x12; +const infoOffset = (symbolWidth: number): number => + symbolWidth === 24 ? 4 : 12; + +const elfSymbolPayload = ( + options: ElfHardeningFixtureOptions, + bits: 32 | 64, + little: boolean, +): ElfSymbolPayload => { + const symbolWidth = bits === 64 ? 24 : 16; + const dynamicWidth = bits === 64 ? 16 : 8; + if (options.symbolScanBudget === true || options.longSymbolName === true) { + const signAfterLongName = + options.longSymbolName === true && options.stackChk === true; + const symbolCount = + options.symbolScanBudget === true ? 300 : signAfterLongName ? 3 : 2; + const dynstr = + options.symbolScanBudget === true + ? Buffer.alloc(65537, 0x41) + : Buffer.concat([ + Buffer.from([0]), + Buffer.alloc(70000, 0x41), + Buffer.from([0]), + Buffer.from("__stack_chk_fail\0", "latin1"), + ]); + if (options.symbolScanBudget === true) { + dynstr[0] = 0; + dynstr[65536] = 0; + } + const dynsym = Buffer.alloc(symbolCount * symbolWidth); + const named = options.symbolScanBudget === true ? symbolCount : 2; + for (let index = 1; index < named; index += 1) { + const at = index * symbolWidth; + if (little) dynsym.writeUInt32LE(1, at); + else dynsym.writeUInt32BE(1, at); + dynsym.writeUInt8(ELF_GLOBAL_FUNC, at + infoOffset(symbolWidth)); + } + if (signAfterLongName) { + const signAt = dynstr.indexOf("__stack_chk_fail", 0, "latin1"); + if (little) dynsym.writeUInt32LE(signAt, 2 * symbolWidth); + else dynsym.writeUInt32BE(signAt, 2 * symbolWidth); + dynsym.writeUInt8( + ELF_GLOBAL_FUNC, + 2 * symbolWidth + infoOffset(symbolWidth), + ); + } + const hash = Buffer.alloc(8 + 4 * (1 + symbolCount)); + if (little) { + hash.writeUInt32LE(1, 0); + hash.writeUInt32LE(symbolCount, 4); + } else { + hash.writeUInt32BE(1, 0); + hash.writeUInt32BE(symbolCount, 4); + } + return { + dynstr, + dynsym, + hash, + symbolWidth, + dynamicWidth, + stringOffset: () => null, + }; + } + const symbolNames: string[] = []; + if (options.stackChk === true) symbolNames.push("__stack_chk_fail"); + if (options.fortify === true) + symbolNames.push(options.fortifyName ?? "__memcpy_chk"); + const dynstr = Buffer.concat([ + Buffer.from([0]), + ...[ + options.rpath, + options.runpath, + ...(options.needed ?? []), + ...symbolNames, + ] + .filter( + (value): value is string => + value !== null && + value !== undefined && + value.length >= 0 && + typeof value === "string", + ) + .map((value) => Buffer.from(`${value}\0`, "latin1")), + ]); + const stringOffset = (value: string | null | undefined): number | null => { + if (value === null || value === undefined) return null; + return dynstr.indexOf(Buffer.from(`${value}\0`, "latin1")); + }; + const symbolCount = 1 + symbolNames.length; + const dynsym = Buffer.alloc(symbolCount * symbolWidth); + symbolNames.forEach((name, index) => { + const at = (index + 1) * symbolWidth; + const nameAt = stringOffset(name) ?? 0; + if (little) dynsym.writeUInt32LE(nameAt, at); + else dynsym.writeUInt32BE(nameAt, at); + if (options.localSymbols !== true) + dynsym.writeUInt8(ELF_GLOBAL_FUNC, at + infoOffset(symbolWidth)); + if (options.exportSymbols === true) { + const shndxAt = at + (symbolWidth === 24 ? 6 : 14); + if (little) dynsym.writeUInt16LE(1, shndxAt); + else dynsym.writeUInt16BE(1, shndxAt); + } + }); + const hash = Buffer.alloc(8); + const nchain = options.symbolBudget === true ? 65537 : symbolCount; + if (little) { + hash.writeUInt32LE(1, 0); + hash.writeUInt32LE(nchain, 4); + } else { + hash.writeUInt32BE(1, 0); + hash.writeUInt32BE(nchain, 4); + } + return { dynstr, dynsym, hash, symbolWidth, dynamicWidth, stringOffset }; +}; + +const elfPropertyNote = ( + options: ElfHardeningFixtureOptions, + bits: 32 | 64, + little: boolean, +): Buffer => { + if (options.longNoteName === true) { + const name = Buffer.alloc(70000, 0x41); + const note = Buffer.alloc(12 + name.length + 1 + 3); + if (little) { + note.writeUInt32LE(name.length + 1, 0); + note.writeUInt32LE(5, 8); + } else { + note.writeUInt32BE(name.length + 1, 0); + note.writeUInt32BE(5, 8); + } + name.copy(note, 12); + return note; + } + if (options.property === undefined || options.property === null) + return Buffer.alloc(0); + const propertyAlign = bits === 64 ? 8 : 4; + const one = (value: number): Buffer => { + const descriptor = Buffer.alloc(propertyAlign === 8 ? 16 : 12); + const propertyType = + options.property?.arch === "aarch64" ? 0xc0000000 : 0xc0000002; + if (little) { + descriptor.writeUInt32LE(propertyType, 0); + descriptor.writeUInt32LE(4, 4); + descriptor.writeUInt32LE(value, 8); + } else { + descriptor.writeUInt32BE(propertyType, 0); + descriptor.writeUInt32BE(4, 4); + descriptor.writeUInt32BE(value, 8); + } + return elfNote("GNU", 5, descriptor, little, propertyAlign); + }; + const first = one(options.property.value); + if ((options.propertyCopies ?? 0) > 1) + return Buffer.concat( + Array.from({ length: options.propertyCopies ?? 1 }, () => first), + ); + return Buffer.concat([ + first, + ...(options.property.also === undefined + ? [] + : [one(options.property.also)]), + ]); +}; + +const elfDynamicTags = ( + options: ElfHardeningFixtureOptions, + dynamic: boolean, + symbols: ElfSymbolPayload, +): { tag: number; value: number }[] => { + if (!dynamic) return []; + const flags1 = + (options.pieFlag === true ? DF_1_PIE : 0) | + (options.bindNow === true ? DF_1_NOW : 0); + return [ + { tag: DT_STRTAB, value: 0 }, + { tag: DT_SYMTAB, value: 0 }, + { tag: DT_STRSZ, value: symbols.dynstr.length }, + { tag: DT_SYMENT, value: symbols.symbolWidth }, + { tag: DT_HASH, value: 0 }, + ...(options.bindNow === true + ? [{ tag: DT_FLAGS, value: DF_BIND_NOW }] + : []), + ...(options.duplicateFlags === true + ? [ + { tag: DT_FLAGS, value: 0 }, + { tag: DT_FLAGS, value: DF_BIND_NOW }, + ] + : []), + ...(options.needed ?? []).map((name) => ({ + tag: DT_NEEDED, + value: symbols.stringOffset(name) ?? 0, + })), + ...(options.bindNowTag === true ? [{ tag: DT_BIND_NOW, value: 0 }] : []), + ...(flags1 !== 0 ? [{ tag: DT_FLAGS_1, value: flags1 }] : []), + ...(options.rpath !== undefined && options.rpath !== null + ? [{ tag: DT_RPATH, value: symbols.stringOffset(options.rpath) ?? 0 }] + : []), + ...(options.runpath !== undefined && options.runpath !== null + ? [{ tag: DT_RUNPATH, value: symbols.stringOffset(options.runpath) ?? 0 }] + : []), + { tag: DT_NULL, value: 0 }, + ]; +}; + +const elfPrograms = ( + options: ElfHardeningFixtureOptions, + facts: { + readonly dynamic: boolean; + readonly interp: boolean; + readonly headerSize: number; + readonly dynamicWidth: number; + readonly dynamicBytes: number; + readonly interpBytes: number; + readonly noteBytes: number; + readonly propertyAlign: number; + readonly gnuStack: { readonly flags: number } | null; + }, +): ElfProgramSpec[] => { + const programs = [elfProgram(PT_LOAD, 5, 0, 0, 0x1000)]; + if (facts.interp) + programs.push( + elfProgram(PT_INTERP, PF_R, facts.interpBytes, facts.interpBytes, 1), + ); + if (facts.dynamic) + programs.push( + elfProgram( + PT_DYNAMIC, + 6, + options.truncateDynamic === true ? 4 : facts.dynamicBytes, + facts.dynamicBytes, + facts.dynamicWidth, + ), + ); + const stackCount = + facts.gnuStack === null ? 0 : options.duplicateStack === true ? 2 : 1; + for (let index = 0; index < stackCount; index += 1) + programs.push( + elfProgram(PT_GNU_STACK, facts.gnuStack?.flags ?? 6, 0, 0, 0x10), + ); + if (options.gnuRelro === true) + programs.push( + elfProgram(PT_GNU_RELRO, PF_R, facts.headerSize, facts.headerSize, 1), + ); + if (facts.noteBytes !== 0) + programs.push( + elfProgram( + options.propertyCarrier === "pt-note" ? 4 : PT_GNU_PROPERTY, + PF_R, + facts.noteBytes, + facts.noteBytes, + facts.propertyAlign, + ), + ); + return programs; +}; + +interface ElfPlacementFacts { + readonly dynamic: boolean; + readonly interp: boolean; + readonly sections: boolean; + readonly symtab: boolean; + readonly headerSize: number; + readonly phentsize: number; + readonly shentsize: number; + readonly symbolWidth: number; + readonly dynamicWidth: number; + readonly propertyAlign: number; + readonly interpBytes: Buffer; + readonly dynstr: Buffer; + readonly dynsym: Buffer; + readonly hash: Buffer; + readonly note: Buffer; + readonly buildId: Buffer; + readonly buildIdSections: number; + readonly compressedPropertyNote: boolean; + readonly disjointNoteSections: number; + readonly dynamicBytes: number; + readonly programs: ElfProgramSpec[]; +} + +const elfSectionTable = ( + facts: ElfPlacementFacts, + placed: { + readonly interpAt: number; + readonly dynstrAt: number; + readonly dynsymAt: number; + readonly dynamicAt: number; + readonly noteAt: number; + readonly buildIdAt: number; + readonly disjointNotesAt: number; + readonly symtabAt: number; + }, +): ElfSectionSpec[] => { + const { + interpAt, + dynstrAt, + dynsymAt, + dynamicAt, + noteAt, + buildIdAt, + disjointNotesAt, + symtabAt, + } = placed; + const sectionTable: ElfSectionSpec[] = [ + { name: "", type: 0, offset: 0, size: 0, link: 0, entry: 0 }, + ]; + if (facts.interp) + sectionTable.push({ + name: ".interp", + type: SHT_PROGBITS, + offset: interpAt, + size: facts.interpBytes.length, + link: 0, + entry: 0, + }); + if (facts.dynamic) { + sectionTable.push({ + name: ".dynstr", + type: SHT_STRTAB, + offset: dynstrAt, + size: facts.dynstr.length, + link: 0, + entry: 0, + }); + sectionTable.push({ + name: ".dynsym", + type: SHT_DYNSYM, + offset: dynsymAt, + size: facts.dynsym.length, + link: 0, + entry: facts.symbolWidth, + }); + sectionTable.push({ + name: ".dynamic", + type: SHT_DYNAMIC, + offset: dynamicAt, + size: facts.dynamicBytes, + link: 0, + entry: facts.dynamicWidth, + }); + } + if (facts.note.length !== 0) + sectionTable.push({ + name: ".note.gnu.property", + type: SHT_NOTE, + offset: noteAt, + size: facts.note.length, + link: 0, + entry: 0, + align: facts.propertyAlign, + ...(facts.compressedPropertyNote ? { flags: SHF_COMPRESSED } : {}), + }); + for (let index = 0; index < facts.disjointNoteSections; index += 1) + sectionTable.push({ + name: ".note.gnu.property", + type: SHT_NOTE, + offset: disjointNotesAt + index * 12, + size: 12, + link: 0, + entry: 0, + align: 4, + }); + for (let index = 0; index < facts.buildIdSections; index += 1) + sectionTable.push({ + name: ".note.gnu.build-id", + type: SHT_NOTE, + offset: buildIdAt, + size: facts.buildId.length, + link: 0, + entry: 0, + align: 4, + }); + if (facts.symtab) + sectionTable.push({ + name: ".symtab", + type: SHT_SYMTAB, + offset: symtabAt, + size: facts.symbolWidth, + link: 0, + entry: facts.symbolWidth, + }); + if (facts.sections) + sectionTable.push({ + name: ".shstrtab", + type: SHT_STRTAB, + offset: 0, + size: 0, + link: 0, + entry: 0, + }); + return sectionTable; +}; + +const placeElfPayloads = (facts: ElfPlacementFacts): ElfPlacedImage => { + let cursor = facts.headerSize + facts.programs.length * facts.phentsize; + const take = (size: number, alignment = 8): number => { + if (size === 0) return cursor; + cursor += (alignment - (cursor % alignment)) % alignment; + const at = cursor; + cursor += size; + return at; + }; + const interpAt = facts.interp ? take(facts.interpBytes.length) : 0; + const dynstrAt = facts.dynamic ? take(facts.dynstr.length) : 0; + const dynsymAt = facts.dynamic ? take(facts.dynsym.length) : 0; + const hashAt = facts.dynamic ? take(facts.hash.length) : 0; + const noteAt = + facts.note.length === 0 ? 0 : take(facts.note.length, facts.propertyAlign); + const buildIdAt = + facts.buildId.length === 0 ? 0 : take(facts.buildId.length, 4); + const disjointNotesAt = take(facts.disjointNoteSections * 12, 4); + const symtabAt = facts.symtab ? take(facts.symbolWidth) : 0; + const dynamicAt = facts.dynamic + ? take(facts.dynamicBytes, facts.dynamicWidth) + : 0; + const sectionNames = new Map(); + const shstr = Buffer.alloc(256); + let nameCursor = 1; + const intern = (name: string): number => { + const existing = sectionNames.get(name); + if (existing !== undefined) return existing; + const at = nameCursor; + shstr.write(`${name}\0`, at, "latin1"); + nameCursor += name.length + 1; + sectionNames.set(name, at); + return at; + }; + const sectionTable = elfSectionTable(facts, { + interpAt, + dynstrAt, + dynsymAt, + dynamicAt, + noteAt, + buildIdAt, + disjointNotesAt, + symtabAt, + }); + for (const section of sectionTable) + if (section.name !== "") intern(section.name); + const shstrtab = shstr.subarray(0, nameCursor); + const shstrAt = facts.sections ? take(shstrtab.length) : 0; + const shoff = facts.sections + ? take(sectionTable.length * facts.shentsize, 8) + : 0; + return { + programs: facts.programs, + sectionTable, + shstrtab, + intern, + interpAt, + dynstrAt, + dynsymAt, + hashAt, + noteAt, + buildIdAt, + dynamicAt, + shstrAt, + shoff, + fileSize: cursor, + }; +}; + +const elfWriters = (bytes: Buffer, little: boolean, bits: 32 | 64) => { + const write16 = (at: number, value: number): void => { + if (little) bytes.writeUInt16LE(value, at); + else bytes.writeUInt16BE(value, at); + }; + const write32 = (at: number, value: number): void => { + if (little) bytes.writeUInt32LE(value, at); + else bytes.writeUInt32BE(value, at); + }; + const writeWord = (at: number, value: number): void => { + if (bits === 64) { + if (little) bytes.writeBigUInt64LE(BigInt(value), at); + else bytes.writeBigUInt64BE(BigInt(value), at); + return; + } + write32(at, value); + }; + return { write16, write32, writeWord }; +}; + +const writeElfHeader = ( + bytes: Buffer, + facts: { + readonly bits: 32 | 64; + readonly little: boolean; + readonly type: number; + readonly machine: number; + readonly sections: boolean; + readonly headerSize: number; + readonly phentsize: number; + readonly shentsize: number; + readonly programCount: number; + readonly sectionCount: number; + readonly shoff: number; + }, +): ReturnType => { + const writers = elfWriters(bytes, facts.little, facts.bits); + bytes.writeUInt32BE(0x7f454c46, 0); + bytes[4] = facts.bits === 64 ? 2 : 1; + bytes[5] = facts.little ? 1 : 2; + bytes[6] = 1; + writers.write16(16, facts.type); + writers.write16(18, facts.machine); + writers.write32(20, 1); + const sectionCount = facts.sections ? facts.sectionCount : 0; + if (facts.bits === 64) { + writers.writeWord(32, facts.headerSize); + writers.writeWord(40, facts.shoff); + writers.write32(48, 0); + writers.write16(52, facts.headerSize); + writers.write16(54, facts.phentsize); + writers.write16(56, facts.programCount); + writers.write16(58, facts.shentsize); + writers.write16(60, sectionCount); + writers.write16(62, facts.sections ? facts.sectionCount - 1 : 0); + } else { + writers.write32(28, facts.headerSize); + writers.write32(32, facts.shoff); + writers.write32(36, 0); + writers.write16(40, facts.headerSize); + writers.write16(42, facts.phentsize); + writers.write16(44, facts.programCount); + writers.write16(46, facts.shentsize); + writers.write16(48, sectionCount); + writers.write16(50, facts.sections ? facts.sectionCount - 1 : 0); + } + return writers; +}; + +const assignElfProgramSpans = ( + programs: ElfProgramSpec[], + facts: { + readonly interp: boolean; + readonly dynamic: boolean; + readonly truncateDynamic: boolean; + readonly noteBytes: number; + readonly interpAt: number; + readonly interpBytes: number; + readonly dynamicAt: number; + readonly dynamicBytes: number; + readonly noteAt: number; + readonly propertyType: number; + readonly fileSize: number; + }, +): void => { + const programAt = (typeId: number, payload: number, size: number): void => { + const program = programs.find((candidate) => candidate.type === typeId); + if (program === undefined) return; + program.offset = payload; + program.address = payload; + program.filesz = size; + program.memsz = size; + }; + if (facts.interp) programAt(PT_INTERP, facts.interpAt, facts.interpBytes); + if (facts.dynamic) + programAt( + PT_DYNAMIC, + facts.dynamicAt, + facts.truncateDynamic ? 4 : facts.dynamicBytes, + ); + if (facts.noteBytes !== 0) + programAt(facts.propertyType, facts.noteAt, facts.noteBytes); + const load = programs[0]; + if (load === undefined) return; + load.filesz = facts.fileSize; + load.memsz = facts.fileSize; +}; + +const writeElfProgramHeaders = ( + programs: readonly ElfProgramSpec[], + facts: { + readonly bits: 32 | 64; + readonly headerSize: number; + readonly phentsize: number; + readonly write32: (at: number, value: number) => void; + readonly writeWord: (at: number, value: number) => void; + }, +): void => { + programs.forEach((program, index) => { + const at = facts.headerSize + index * facts.phentsize; + facts.write32(at, program.type); + if (facts.bits === 64) { + facts.write32(at + 4, program.flags); + facts.writeWord(at + 8, program.offset); + facts.writeWord(at + 16, program.address); + facts.writeWord(at + 24, program.address); + facts.writeWord(at + 32, program.filesz); + facts.writeWord(at + 40, program.memsz); + facts.writeWord(at + 48, program.align); + return; + } + facts.write32(at + 4, program.offset); + facts.write32(at + 8, program.address); + facts.write32(at + 12, program.address); + facts.write32(at + 16, program.filesz); + facts.write32(at + 20, program.memsz); + facts.write32(at + 24, program.flags); + facts.write32(at + 28, program.align); + }); +}; + +const writeElfDynamic = ( + bytes: Buffer, + tags: readonly { tag: number; value: number }[], + facts: { + readonly bits: 32 | 64; + readonly little: boolean; + readonly dynamic: boolean; + readonly interp: boolean; + readonly dynamicAt: number; + readonly dynamicWidth: number; + readonly dynstrAt: number; + readonly dynsymAt: number; + readonly hashAt: number; + readonly interpAt: number; + readonly interpBytes: Buffer; + readonly dynstr: Buffer; + readonly dynsym: Buffer; + readonly hash: Buffer; + }, +): void => { + if (facts.interp) facts.interpBytes.copy(bytes, facts.interpAt); + if (!facts.dynamic) return; + facts.dynstr.copy(bytes, facts.dynstrAt); + facts.dynsym.copy(bytes, facts.dynsymAt); + facts.hash.copy(bytes, facts.hashAt); + const valueFor = (tag: number, current: number): number => { + if (tag === DT_STRTAB) return facts.dynstrAt; + if (tag === DT_SYMTAB) return facts.dynsymAt; + if (tag === DT_HASH) return facts.hashAt; + return current; + }; + tags.forEach((entry, index) => { + const at = facts.dynamicAt + index * facts.dynamicWidth; + const value = valueFor(entry.tag, entry.value); + if (facts.bits === 64) { + if (facts.little) { + bytes.writeBigInt64LE(BigInt(entry.tag), at); + bytes.writeBigUInt64LE(BigInt(value), at + 8); + } else { + bytes.writeBigInt64BE(BigInt(entry.tag), at); + bytes.writeBigUInt64BE(BigInt(value), at + 8); + } + return; + } + if (facts.little) { + bytes.writeInt32LE(entry.tag, at); + bytes.writeUInt32LE(value, at + 4); + return; + } + bytes.writeInt32BE(entry.tag, at); + bytes.writeUInt32BE(value, at + 4); + }); +}; + +const writeElfSections = ( + bytes: Buffer, + placed: ElfPlacedImage, + facts: { + readonly bits: 32 | 64; + readonly sections: boolean; + readonly shentsize: number; + readonly write32: (at: number, value: number) => void; + readonly writeWord: (at: number, value: number) => void; + }, +): void => { + if (!facts.sections) return; + placed.shstrtab.copy(bytes, placed.shstrAt); + const dynstrIndex = placed.sectionTable.findIndex( + (section) => section.name === ".dynstr", + ); + const shstrIndex = placed.sectionTable.length - 1; + placed.sectionTable.forEach((section, index) => { + const at = placed.shoff + index * facts.shentsize; + const link = + section.name === ".dynsym" || section.name === ".dynamic" + ? Math.max(dynstrIndex, 0) + : section.name === ".symtab" + ? shstrIndex + : section.link; + const offset = + section.name === ".shstrtab" ? placed.shstrAt : section.offset; + const size = + section.name === ".shstrtab" ? placed.shstrtab.length : section.size; + facts.write32(at, section.name === "" ? 0 : placed.intern(section.name)); + facts.write32(at + 4, section.type); + facts.writeWord(at + 8, section.flags ?? 0); + if (facts.bits === 64) { + facts.writeWord(at + 16, offset); + facts.writeWord(at + 24, offset); + facts.writeWord(at + 32, size); + facts.write32(at + 40, link); + facts.writeWord(at + 48, section.align ?? 1); + facts.writeWord(at + 56, section.entry); + return; + } + facts.write32(at + 12, offset); + facts.write32(at + 16, offset); + facts.write32(at + 20, size); + facts.write32(at + 24, link); + facts.write32(at + 32, section.align ?? 1); + facts.write32(at + 36, section.entry); + }); +}; + +/** Synthetic little- or big-endian ELF image with only the requested hardening facts. */ +export const elfHardeningFixture = ( + options: ElfHardeningFixtureOptions = {}, +): Buffer => { + const bits = options.bits ?? 64; + const little = options.little ?? true; + const dynamic = options.dynamic ?? true; + const interp = options.interp ?? false; + const gnuStack = + options.gnuStack === undefined ? { flags: 6 } : options.gnuStack; + const sections = options.sections !== false && options.symbolBudget !== true; + const symtab = (options.symtab ?? true) && sections; + const headerSize = bits === 64 ? 64 : 52; + const phentsize = bits === 64 ? 56 : 32; + const shentsize = bits === 64 ? 64 : 40; + const propertyAlign = bits === 64 ? 8 : 4; + const symbols = elfSymbolPayload(options, bits, little); + const note = elfPropertyNote(options, bits, little); + const buildIdCount = + options.buildIdCount ?? + (options.buildId === true || (options.buildIdSections ?? 0) > 0 ? 1 : 0); + const buildId = + buildIdCount === 0 + ? Buffer.alloc(0) + : Buffer.concat( + Array.from({ length: buildIdCount }, () => + gnuBuildIdNote(undefined, little), + ), + ); + const buildIdSections = + buildId.length === 0 ? 0 : (options.buildIdSections ?? 1); + const propertyType = + options.propertyCarrier === "pt-note" ? 4 : PT_GNU_PROPERTY; + const interpBytes = interp + ? Buffer.from("/lib/interp\0", "latin1") + : Buffer.alloc(0); + const dynamicTags = elfDynamicTags(options, dynamic, symbols); + const dynamicBytes = dynamicTags.length * symbols.dynamicWidth; + const programs = elfPrograms(options, { + dynamic, + interp, + headerSize, + dynamicWidth: symbols.dynamicWidth, + dynamicBytes, + interpBytes: interpBytes.length, + noteBytes: note.length, + propertyAlign, + gnuStack, + }); + const placed = placeElfPayloads({ + dynamic, + interp, + sections, + symtab, + headerSize, + phentsize, + shentsize, + symbolWidth: symbols.symbolWidth, + dynamicWidth: symbols.dynamicWidth, + propertyAlign, + interpBytes, + dynstr: symbols.dynstr, + dynsym: symbols.dynsym, + hash: symbols.hash, + note, + buildId, + buildIdSections, + compressedPropertyNote: options.compressedPropertyNote === true, + disjointNoteSections: options.disjointNoteSections ?? 0, + dynamicBytes, + programs, + }); + const bytes = Buffer.alloc(placed.fileSize); + const writers = writeElfHeader(bytes, { + bits, + little, + type: options.type ?? 3, + machine: options.machine ?? (bits === 64 ? 62 : 3), + sections, + headerSize, + phentsize, + shentsize, + programCount: programs.length, + sectionCount: placed.sectionTable.length, + shoff: placed.shoff, + }); + assignElfProgramSpans(programs, { + interp, + dynamic, + truncateDynamic: options.truncateDynamic === true, + noteBytes: note.length, + interpAt: placed.interpAt, + interpBytes: interpBytes.length, + dynamicAt: placed.dynamicAt, + dynamicBytes, + noteAt: placed.noteAt, + propertyType, + fileSize: placed.fileSize, + }); + writeElfProgramHeaders(programs, { + bits, + headerSize, + phentsize, + write32: writers.write32, + writeWord: writers.writeWord, + }); + writeElfDynamic(bytes, dynamicTags, { + bits, + little, + dynamic, + interp, + dynamicAt: placed.dynamicAt, + dynamicWidth: symbols.dynamicWidth, + dynstrAt: placed.dynstrAt, + dynsymAt: placed.dynsymAt, + hashAt: placed.hashAt, + interpAt: placed.interpAt, + interpBytes, + dynstr: symbols.dynstr, + dynsym: symbols.dynsym, + hash: symbols.hash, + }); + if (note.length !== 0) note.copy(bytes, placed.noteAt); + if (buildId.length !== 0) buildId.copy(bytes, placed.buildIdAt); + writeElfSections(bytes, placed, { + bits, + sections, + shentsize, + write32: writers.write32, + writeWord: writers.writeWord, + }); + return bytes; +}; + +export interface MachoHardeningFixtureOptions { + readonly bits?: 32 | 64; + readonly cpuType?: number; + readonly cpuSubtype?: number; + readonly flags?: number; + readonly restrict?: boolean; + /** Segment name, including an embedded NUL, instead of plain __RESTRICT. */ + readonly restrictName?: string; + readonly codeSignature?: boolean; + /** `null` omits the encryption command. `false` keeps cryptid 0. */ + readonly encrypted?: boolean | null; + /** `null` omits LC_SYMTAB. `false` scans an empty symbol. */ + readonly stackChk?: boolean | null; + readonly symbolName?: string; + readonly nsyms?: number; + readonly brokenStrx?: boolean; + /** An undefined symbol whose name offset is outside the string table, after the named symbol. */ + readonly trailingBrokenStrx?: boolean; + readonly bigEndian?: boolean; + /** Mach-O filetype. Defaults to MH_EXECUTE. */ + readonly fileType?: number; + /** Two encryption commands, so the slice status is malformed. */ + readonly duplicateEncryption?: boolean; + /** 300 symbols sharing one long terminated name. */ + readonly symbolScanBudget?: boolean; + /** One symbol whose name exceeds the per-name scan cap. */ + readonly longSymbolName?: boolean; + /** N_SECT|N_EXT instead of an undefined external. */ + readonly definedSymbol?: boolean; + /** Nonzero n_value on the named symbol, as a common definition carries its size. */ + readonly commonSize?: number; + /** Raw load commands placed after the segments. */ + readonly extraCommands?: readonly Buffer[]; +} + +const MH_EXECUTE = 2; + +const machoSymbolPayload = ( + options: MachoHardeningFixtureOptions, + wide: boolean, +) => { + const symbolName = + options.symbolName ?? + (options.stackChk === true ? "___stack_chk_fail" : ""); + const wantSymtab = + options.stackChk !== null && options.stackChk !== undefined + ? true + : options.nsyms !== undefined || + options.brokenStrx === true || + options.symbolScanBudget === true || + options.longSymbolName === true; + const strings = options.symbolScanBudget + ? Buffer.alloc(65537, 0x41) + : options.longSymbolName === true + ? Buffer.concat([ + Buffer.from([0]), + Buffer.alloc(70000, 0x41), + Buffer.from([0]), + ]) + : Buffer.concat([ + Buffer.from([0]), + Buffer.from(symbolName === "" ? "" : `${symbolName}\0`, "latin1"), + ]); + if (options.symbolScanBudget === true) { + strings[0] = 0; + strings[65536] = 0; + } + const nlistWidth = wide ? 16 : 12; + const declaredSymbols = options.symbolScanBudget + ? 300 + : (options.nsyms ?? + (options.brokenStrx === true || + symbolName !== "" || + options.longSymbolName === true + ? 1 + : options.stackChk === false + ? 1 + : 0)) + (options.trailingBrokenStrx === true ? 1 : 0); + const storedSymbols = + options.nsyms !== undefined && options.nsyms > 4096 ? 0 : declaredSymbols; + const symbolBytes = Buffer.alloc(Math.max(storedSymbols, 0) * nlistWidth); + const symbolType = options.definedSymbol === true ? 0x0f : 0x01; + const writeSymbol = (index: number, nameOffset: number): void => { + const at = index * nlistWidth; + symbolBytes.writeUInt32LE(nameOffset, at); + symbolBytes.writeUInt8(symbolType, at + 4); + if (options.commonSize !== undefined) + symbolBytes.writeUInt32LE(options.commonSize, at + 8); + }; + if (options.symbolScanBudget === true) { + for (let index = 0; index < storedSymbols; index += 1) + writeSymbol(index, 1); + } else if (options.brokenStrx === true && symbolBytes.length >= 4) + writeSymbol(0, 50); + else if ( + (symbolName !== "" || options.longSymbolName === true) && + symbolBytes.length >= 4 + ) + writeSymbol(0, 1); + if (options.trailingBrokenStrx === true) + writeSymbol(storedSymbols - 1, strings.length + 50); + return { wantSymtab, strings, declaredSymbols, symbolBytes }; +}; + +/** One little-endian Mach-O slice, or a big-endian header when requested. */ +export const machoHardeningFixture = ( + options: MachoHardeningFixtureOptions = {}, +): Buffer => { + const bits = options.bits ?? 64; + const wide = bits === 64; + const headerSize = wide ? 32 : 28; + const cpuType = options.cpuType ?? (wide ? 0x0100000c : 7); + const cpuSubtype = options.cpuSubtype ?? 0; + const flags = options.flags ?? 0x200000 | 0x1000000; + const commands: Buffer[] = []; + const segment = (name: string): Buffer => { + const size = wide ? 72 : 56; + const body = Buffer.alloc(size); + body.writeUInt32LE(wide ? 0x19 : 0x1, 0); + body.writeUInt32LE(size, 4); + body.write(name, 8, "latin1"); + return body; + }; + commands.push(segment("__TEXT")); + if (options.restrict === true) commands.push(segment("__RESTRICT")); + if (options.restrictName !== undefined) + commands.push(segment(options.restrictName)); + for (const command of options.extraCommands ?? []) commands.push(command); + const { wantSymtab, strings, declaredSymbols, symbolBytes } = + machoSymbolPayload(options, wide); + const commandSize = commands.reduce( + (sum, command) => sum + command.length, + 0, + ); + const symtabSize = wantSymtab ? 24 : 0; + const signatureSize = options.codeSignature === true ? 16 : 0; + const encryptionCopies = + options.encrypted === undefined || options.encrypted === null + ? 0 + : options.duplicateEncryption === true + ? 2 + : 1; + const encryptionSize = encryptionCopies === 0 ? 0 : wide ? 24 : 20; + const tableSize = + commandSize + + symtabSize + + signatureSize + + encryptionSize * encryptionCopies; + let cursor = headerSize + tableSize; + cursor += (8 - (cursor % 8)) % 8; + const symoff = cursor; + cursor += symbolBytes.length; + const stroff = cursor; + const strsize = options.brokenStrx === true ? 4 : strings.length; + cursor += options.brokenStrx === true ? 4 : strings.length; + const bytes = Buffer.alloc(cursor); + // Magic is identified by a big-endian read: little-endian images store CIGAM. + bytes.writeUInt32BE( + options.bigEndian === true + ? wide + ? 0xfeedfacf + : 0xfeedface + : wide + ? 0xcffaedfe + : 0xcefaedfe, + 0, + ); + if (options.bigEndian === true) return bytes; + bytes.writeUInt32LE(cpuType, 4); + bytes.writeUInt32LE(cpuSubtype, 8); + bytes.writeUInt32LE(options.fileType ?? MH_EXECUTE, 12); + const pieces = [...commands]; + if (wantSymtab) { + const body = Buffer.alloc(24); + body.writeUInt32LE(0x2, 0); + body.writeUInt32LE(24, 4); + body.writeUInt32LE(symoff, 8); + body.writeUInt32LE(declaredSymbols, 12); + body.writeUInt32LE(stroff, 16); + body.writeUInt32LE(strsize, 20); + pieces.push(body); + } + if (options.codeSignature === true) { + const body = Buffer.alloc(16); + body.writeUInt32LE(0x1d, 0); + body.writeUInt32LE(16, 4); + pieces.push(body); + } + if (encryptionCopies !== 0) { + for (let index = 0; index < encryptionCopies; index += 1) { + const body = Buffer.alloc(encryptionSize); + body.writeUInt32LE(wide ? 0x2c : 0x21, 0); + body.writeUInt32LE(encryptionSize, 4); + body.writeUInt32LE(options.encrypted ? 1 : 0, 16); + pieces.push(body); + } + } + bytes.writeUInt32LE(pieces.length, 16); + bytes.writeUInt32LE( + pieces.reduce((sum, piece) => sum + piece.length, 0), + 20, + ); + bytes.writeUInt32LE(flags, 24); + let at = headerSize; + for (const piece of pieces) { + piece.copy(bytes, at); + at += piece.length; + } + symbolBytes.copy(bytes, symoff); + if (options.brokenStrx === true) bytes.writeUInt32LE(0, stroff); + else strings.copy(bytes, stroff); + return bytes; +}; + +export interface FatSliceInput { + readonly bytes: Buffer; + readonly cpuType: number; + readonly cpuSubtype: number; + readonly fatCpuType?: number; + readonly fatCpuSubtype?: number; + /** Declared slice size. Larger than the payload extends past EOF. */ + readonly declaredSize?: number; + /** When false, the payload is not copied and does not extend the file. */ + readonly includePayload?: boolean; +} + +/** Universal Mach-O with alignment 2^0 so any slice offset after the table is valid. */ +export const fatMachoHardeningFixture = ( + slices: readonly FatSliceInput[], +): Buffer => { + const tableEnd = 8 + slices.length * 20; + let cursor = tableEnd; + const placed = slices.map((slice) => { + const offset = cursor; + const payload = slice.includePayload === false ? 0 : slice.bytes.length; + cursor += payload; + return { ...slice, offset, payload }; + }); + const bytes = Buffer.alloc(cursor); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(slices.length, 4); + placed.forEach((slice, index) => { + const at = 8 + index * 20; + bytes.writeUInt32BE(slice.fatCpuType ?? slice.cpuType, at); + bytes.writeUInt32BE(slice.fatCpuSubtype ?? slice.cpuSubtype, at + 4); + bytes.writeUInt32BE(slice.offset, at + 8); + bytes.writeUInt32BE(slice.declaredSize ?? slice.bytes.length, at + 12); + bytes.writeUInt32BE(0, at + 16); + if (slice.payload !== 0) slice.bytes.copy(bytes, slice.offset); + }); + return bytes; +}; + +export interface PeHardeningFixtureOptions { + readonly bits?: 32 | 64; + readonly characteristics?: number; + readonly loadConfig?: boolean; + readonly loadConfigSize?: number; + readonly malformed?: PeLoadConfigFixtureMalformed; + readonly certificate?: { + readonly address: number; + readonly size: number; + } | null; + readonly debug?: { readonly address: number; readonly size: number } | null; +} + +/** PE load-config fixture with DllCharacteristics and optional certificate or debug directories. */ +export const peHardeningImage = ( + options: PeHardeningFixtureOptions = {}, +): Buffer => { + const bytes = peLoadConfigImage({ + ...(options.bits === undefined ? {} : { bits: options.bits }), + ...(options.loadConfig === undefined + ? {} + : { loadConfig: options.loadConfig }), + ...(options.loadConfigSize === undefined + ? {} + : { loadConfigSize: options.loadConfigSize }), + ...(options.malformed === undefined + ? {} + : { malformed: options.malformed }), + }); + const pe = bytes.readUInt32LE(0x3c); + const optional = pe + 24; + bytes.writeUInt16LE(options.characteristics ?? 0x4160, optional + 70); + const wide = bytes.readUInt16LE(optional) === 0x20b; + const directories = optional + (wide ? 112 : 96); + const writeDirectory = ( + index: number, + entry: + | { readonly address: number; readonly size: number } + | null + | undefined, + ): void => { + if (entry === undefined || entry === null) return; + bytes.writeUInt32LE(entry.address, directories + index * 8); + bytes.writeUInt32LE(entry.size, directories + index * 8 + 4); + }; + writeDirectory(4, options.certificate); + writeDirectory(6, options.debug); + return bytes; +}; diff --git a/src/native/hardening/BinaryHardeningParsers.test.ts b/src/native/hardening/BinaryHardeningParsers.test.ts new file mode 100644 index 0000000000..02cac1c15d --- /dev/null +++ b/src/native/hardening/BinaryHardeningParsers.test.ts @@ -0,0 +1,1721 @@ +import { expect, it } from "vitest"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + ELF_HARDENING_PROPERTIES, + MACHO_HARDENING_PROPERTIES, + PE_HARDENING_PROPERTIES, + type BinaryHardeningRow, +} from "../../domain/native/binaryHardening.js"; +import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; +import { readElfImage } from "../metadata/ElfImage.js"; +import { readMachoImage } from "../metadata/MachoImage.js"; +import { + elfHardeningFixture, + fatMachoHardeningFixture, + machoHardeningFixture, + peHardeningImage, +} from "./BinaryHardening.fixture.js"; +import { readElfHardening } from "./ElfHardening.js"; +import { readMachoHardening } from "./MachoHardening.js"; +import { readPeHardening } from "./PeHardening.js"; + +const states = (rows: readonly BinaryHardeningRow[]) => + Object.fromEntries(rows.map((entry) => [entry.property, entry.state])); + +const findRow = ( + rows: readonly BinaryHardeningRow[], + property: BinaryHardeningRow["property"], +): BinaryHardeningRow => { + const found = rows.find((entry) => entry.property === property); + if (found === undefined) throw new Error(`Missing ${property}`); + return found; +}; + +const peArtifact = (bytes: Buffer) => ({ + path: "/artifacts/app.exe", + sha256: "a".repeat(64), + bytes: bytes.length, +}); + +it("reports a default ET_DYN image without treating missing symbols as disabled", () => { + const image = readElfHardening(elfHardeningFixture()); + expect(image.rows.map(({ property }) => property)).toEqual([ + ...ELF_HARDENING_PROPERTIES, + ]); + expect(states(image.rows)).toEqual({ + pie: "not_applicable", + nx: "enabled", + relro: "disabled", + stack_protector: "unknown", + fortify: "unknown", + cet_ibt_shstk: "disabled", + aarch64_bti_pac: "not_applicable", + rpath: "disabled", + runpath: "disabled", + stripped: "disabled", + }); + expect(findRow(image.rows, "stack_protector").note).toEqual( + expect.stringContaining("static or stripped"), + ); + expect(image.architecture).toBe("x86_64"); + expect(image.byte_order).toBe("little"); +}); + +it("derives PIE, RELRO, NX, symbols, paths and GNU properties", () => { + const enabled = readElfHardening( + elfHardeningFixture({ + interp: true, + gnuRelro: true, + bindNow: true, + stackChk: true, + fortify: true, + rpath: "/opt/lib", + runpath: "/opt/run", + property: { arch: "x86", value: 0b11 }, + }), + ); + expect(states(enabled.rows)).toMatchObject({ + pie: "enabled", + relro: "enabled", + stack_protector: "enabled", + fortify: "enabled", + cet_ibt_shstk: "enabled", + rpath: "enabled", + runpath: "enabled", + }); + expect(findRow(enabled.rows, "pie")).toMatchObject({ + confidence: "derived", + basis: expect.stringContaining("PT_INTERP"), + }); + expect(findRow(enabled.rows, "relro").confidence).toBe("derived"); + expect(findRow(enabled.rows, "rpath").note).toBe("/opt/lib"); + expect(findRow(enabled.rows, "fortify").basis).toContain( + "Undefined dynamic symbol __memcpy_chk", + ); + const exported = readElfHardening( + elfHardeningFixture({ stackChk: true, exportSymbols: true }), + ); + expect(findRow(exported.rows, "stack_protector")).toMatchObject({ + state: "unknown", + basis: expect.stringContaining("was found"), + }); + expect(findRow(exported.rows, "nx").state).toBe("enabled"); + const local = readElfHardening( + elfHardeningFixture({ stackChk: true, fortify: true, localSymbols: true }), + ); + expect(findRow(local.rows, "stack_protector")).toMatchObject({ + state: "unknown", + }); + expect(findRow(local.rows, "fortify").state).toBe("unknown"); + + const pieFlag = readElfHardening(elfHardeningFixture({ pieFlag: true })); + expect(findRow(pieFlag.rows, "pie")).toMatchObject({ + state: "enabled", + confidence: "derived", + basis: expect.stringContaining("DF_1_PIE"), + }); + const executable = readElfHardening(elfHardeningFixture({ type: 2 })); + expect(findRow(executable.rows, "pie").state).toBe("disabled"); + const partial = readElfHardening(elfHardeningFixture({ gnuRelro: true })); + expect(findRow(partial.rows, "relro")).toMatchObject({ + state: "partial", + confidence: "derived", + }); + const executableStack = readElfHardening( + elfHardeningFixture({ gnuStack: { flags: 7 } }), + ); + expect(findRow(executableStack.rows, "nx").state).toBe("disabled"); + const missingStack = readElfHardening( + elfHardeningFixture({ gnuStack: null }), + ); + expect(findRow(missingStack.rows, "nx")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("does not apply"), + }); + const i386 = readElfHardening( + elfHardeningFixture({ bits: 32, machine: 3, gnuStack: null }), + ); + expect(findRow(i386.rows, "nx").note).toEqual( + expect.stringContaining("an executable stack"), + ); + const stripped = readElfHardening(elfHardeningFixture({ symtab: false })); + expect(findRow(stripped.rows, "stripped").state).toBe("enabled"); + const noSections = readElfHardening(elfHardeningFixture({ sections: false })); + expect(findRow(noSections.rows, "stripped").state).toBe("unknown"); + const oneBit = readElfHardening( + elfHardeningFixture({ property: { arch: "x86", value: 0b01 } }), + ); + expect(findRow(oneBit.rows, "cet_ibt_shstk")).toMatchObject({ + state: "partial", + confidence: "derived", + }); + const conflict = readElfHardening( + elfHardeningFixture({ property: { arch: "x86", value: 1, also: 2 } }), + ); + expect(findRow(conflict.rows, "cet_ibt_shstk").state).toBe("unknown"); + const same = readElfHardening( + elfHardeningFixture({ property: { arch: "x86", value: 3, also: 3 } }), + ); + expect(findRow(same.rows, "cet_ibt_shstk").state).toBe("enabled"); + const aarch64 = readElfHardening( + elfHardeningFixture({ + machine: 183, + property: { arch: "aarch64", value: 0b11 }, + }), + ); + expect(findRow(aarch64.rows, "aarch64_bti_pac").state).toBe("enabled"); + expect(findRow(aarch64.rows, "cet_ibt_shstk").state).toBe("not_applicable"); + const canaryOnly = readElfHardening(elfHardeningFixture({ stackChk: true })); + expect(findRow(canaryOnly.rows, "stack_protector").state).toBe("enabled"); + expect(findRow(canaryOnly.rows, "fortify").state).toBe("unknown"); +}); + +it("rejects truncated, duplicate and over-budget ELF tables", () => { + expect(() => readElfHardening(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))).toThrow( + BinaryFormatFailure, + ); + expect(() => + readElfHardening(elfHardeningFixture({ truncateDynamic: true })), + ).toThrow(expect.objectContaining({ kind: "malformed" })); + expect(() => + readElfHardening(elfHardeningFixture({ duplicateStack: true })), + ).toThrow(expect.objectContaining({ kind: "malformed" })); + const overBudget = readElfHardening( + elfHardeningFixture({ symbolBudget: true }), + ); + expect(overBudget.status).toBe("parsed"); + expect(findRow(overBudget.rows, "nx").state).toBe("enabled"); + expect(findRow(overBudget.rows, "stack_protector")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("structural budget"), + }); + expect(findRow(overBudget.rows, "fortify").state).toBe("unknown"); + const longPath = "A".repeat(70000); + const longRpath = readElfHardening(elfHardeningFixture({ rpath: longPath })); + expect(findRow(longRpath.rows, "rpath")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("name budget"), + }); + expect(findRow(longRpath.rows, "rpath").note).not.toContain("AAA"); + expect(findRow(longRpath.rows, "nx").state).toBe("enabled"); + const big = readElfHardening(elfHardeningFixture({ little: false })); + expect(big.byte_order).toBe("big"); + expect(findRow(big.rows, "nx").state).toBe("enabled"); +}); + +it("reports PE DllCharacteristics, GuardFlags and directory presence", async () => { + const bytes = peHardeningImage(); + const image = await readPeHardening(bytes, peArtifact(bytes)); + expect(image.rows.map(({ property }) => property)).toEqual([ + ...PE_HARDENING_PROPERTIES, + ]); + expect(states(image.rows)).toEqual({ + dynamic_base: "enabled", + high_entropy_va: "enabled", + nx_compat: "enabled", + force_integrity: "disabled", + no_seh: "disabled", + guard_cf: "enabled", + cfg_instrumented: "enabled", + authenticode_present: "disabled", + debug_record_present: "disabled", + }); + expect(image.architecture).toBe("x86_64"); + expect(findRow(image.rows, "dynamic_base").location).toEqual({ + offset: image.rows[0]?.location?.offset, + bytes: 2, + }); + expect(findRow(image.rows, "cfg_instrumented").note).toEqual( + expect.stringContaining("linker claim"), + ); + const cleared = peHardeningImage({ characteristics: 0 }); + const clearImage = await readPeHardening(cleared, peArtifact(cleared)); + expect(findRow(clearImage.rows, "nx_compat").state).toBe("disabled"); + expect(findRow(clearImage.rows, "cfg_instrumented").state).toBe("enabled"); + const unknownCfg = peHardeningImage({ loadConfig: false }); + expect( + findRow( + (await readPeHardening(unknownCfg, peArtifact(unknownCfg))).rows, + "cfg_instrumented", + ).state, + ).toBe("unknown"); + const shortConfig = peHardeningImage({ loadConfigSize: 64 }); + expect( + findRow( + (await readPeHardening(shortConfig, peArtifact(shortConfig))).rows, + "cfg_instrumented", + ).state, + ).toBe("unknown"); + const signed = peHardeningImage({ + certificate: { address: 0x1000, size: 8 }, + debug: { address: 0x2000, size: 16 }, + }); + const signedImage = await readPeHardening(signed, peArtifact(signed)); + expect(findRow(signedImage.rows, "authenticode_present")).toMatchObject({ + state: "enabled", + basis: expect.stringContaining("presence, not validity"), + location: { bytes: 8 }, + }); + expect(findRow(signedImage.rows, "debug_record_present").state).toBe( + "enabled", + ); + const half = peHardeningImage({ certificate: { address: 0, size: 8 } }); + const halfImage = await readPeHardening(half, peArtifact(half)); + expect(findRow(halfImage.rows, "authenticode_present").state).toBe("unknown"); + expect(findRow(halfImage.rows, "dynamic_base").state).toBe("enabled"); + const halfDebug = peHardeningImage({ debug: { address: 0x2000, size: 0 } }); + expect( + findRow( + (await readPeHardening(halfDebug, peArtifact(halfDebug))).rows, + "debug_record_present", + ).state, + ).toBe("unknown"); + const unmappedTls = peHardeningImage({ malformed: "tls-unmapped" }); + const unmappedImage = await readPeHardening( + unmappedTls, + peArtifact(unmappedTls), + ); + expect(findRow(unmappedImage.rows, "cfg_instrumented").state).toBe("unknown"); + expect(findRow(unmappedImage.rows, "dynamic_base").state).toBe("enabled"); + expect(findRow(unmappedImage.rows, "guard_cf").state).toBe("enabled"); + const halfTls = peHardeningImage({ malformed: "tls-half" }); + const halfTlsImage = await readPeHardening(halfTls, peArtifact(halfTls)); + expect(findRow(halfTlsImage.rows, "cfg_instrumented").state).toBe("unknown"); + expect(findRow(halfTlsImage.rows, "nx_compat").state).toBe("enabled"); + const narrow = peHardeningImage({ bits: 32, characteristics: 0x4160 }); + const narrowImage = await readPeHardening(narrow, peArtifact(narrow)); + expect(narrowImage.bits).toBe(32); + expect(findRow(narrowImage.rows, "dynamic_base").state).toBe("enabled"); + expect(findRow(narrowImage.rows, "high_entropy_va").state).toBe( + "not_applicable", + ); +}); + +it("reports Mach-O header flags, imports and per-slice defects", async () => { + const image = (await readMachoHardening(machoHardeningFixture()))[0]; + if (image === undefined) throw new Error("Missing Mach-O image"); + expect(image.rows.map(({ property }) => property)).toEqual([ + ...MACHO_HARDENING_PROPERTIES, + ]); + expect(states(image.rows)).toEqual({ + pie: "enabled", + allow_stack_execution: "disabled", + no_heap_execution: "enabled", + restrict_segment: "disabled", + code_signature_present: "disabled", + encrypted: "disabled", + arm64e: "disabled", + stack_protector: "unknown", + hardened_runtime: "unknown", + }); + expect(findRow(image.rows, "hardened_runtime")).toMatchObject({ + next_probe: "inspect_signature", + note: expect.stringContaining("inspect_signature"), + }); + expect(image.rows.every((entry) => entry.confidence === "observed")).toBe( + true, + ); + expect(image.slice).toBeNull(); + + const marked = ( + await readMachoHardening( + machoHardeningFixture({ + flags: 0x20000, + restrict: true, + codeSignature: true, + encrypted: true, + stackChk: true, + cpuSubtype: 2, + }), + ) + )[0]; + if (marked === undefined) throw new Error("Missing marked image"); + expect(states(marked.rows)).toMatchObject({ + pie: "disabled", + allow_stack_execution: "enabled", + no_heap_execution: "disabled", + restrict_segment: "enabled", + code_signature_present: "enabled", + encrypted: "enabled", + arm64e: "enabled", + stack_protector: "enabled", + }); + expect(findRow(marked.rows, "code_signature_present").basis).toContain( + "not validity", + ); + expect(findRow(marked.rows, "stack_protector").basis).toContain( + "Undefined symbol", + ); + const defined = ( + await readMachoHardening( + machoHardeningFixture({ stackChk: true, definedSymbol: true }), + ) + )[0]; + expect(findRow(defined?.rows ?? [], "stack_protector").state).toBe("unknown"); + expect(findRow(defined?.rows ?? [], "pie").state).toBe("enabled"); + for (const bits of [32, 64] as const) { + const common = ( + await readMachoHardening( + machoHardeningFixture({ bits, stackChk: true, commonSize: 4 }), + ) + )[0]; + expect(findRow(common?.rows ?? [], "stack_protector").state).toBe( + "unknown", + ); + } + const embeddedRestrict = ( + await readMachoHardening( + machoHardeningFixture({ restrictName: "__RESTRICT\0junk!" }), + ) + )[0]; + expect(findRow(embeddedRestrict?.rows ?? [], "restrict_segment").state).toBe( + "enabled", + ); + const clearCrypt = ( + await readMachoHardening(machoHardeningFixture({ encrypted: false })) + )[0]; + expect(findRow(clearCrypt?.rows ?? [], "encrypted").state).toBe("disabled"); + const emptySymbols = ( + await readMachoHardening(machoHardeningFixture({ stackChk: false })) + )[0]; + expect(findRow(emptySymbols?.rows ?? [], "stack_protector").state).toBe( + "unknown", + ); + const i386 = ( + await readMachoHardening(machoHardeningFixture({ bits: 32 })) + )[0]; + expect(findRow(i386?.rows ?? [], "arm64e").state).toBe("not_applicable"); +}); + +it("keeps Mach-O siblings when one slice is big-endian, mismatched or malformed", async () => { + await expect( + readMachoHardening(machoHardeningFixture({ bigEndian: true })), + ).rejects.toMatchObject({ kind: "unsupported" }); + const arm = machoHardeningFixture(); + const bigSlice = machoHardeningFixture({ bigEndian: true }); + const mixed = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: arm, cpuType: 0x0100000c, cpuSubtype: 0 }, + { bytes: bigSlice, cpuType: 0x0100000c, cpuSubtype: 0 }, + ]), + ); + expect(mixed[0]?.status).toBe("parsed"); + expect(mixed[1]).toMatchObject({ + status: "unknown", + rows: [], + byte_order: null, + }); + + const intel = machoHardeningFixture({ cpuType: 0x01000007 }); + const fat = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: arm, cpuType: 0x0100000c, cpuSubtype: 0 }, + { + bytes: intel, + cpuType: 0x01000007, + cpuSubtype: 0, + fatCpuType: 0x0100000c, + }, + ]), + ); + expect(fat[0]?.status).toBe("parsed"); + expect(fat[0]?.slice).not.toBeNull(); + expect(fat[1]).toMatchObject({ + status: "malformed", + rows: [], + byte_order: "little", + reason: expect.stringContaining("CPU type/subtype disagrees"), + }); + const oneSlice = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: arm, cpuType: 0x0100000c, cpuSubtype: 0 }, + ]), + ); + expect(oneSlice[0]?.slice?.architecture).toBe("arm64"); + + const hugeSymbols = ( + await readMachoHardening(machoHardeningFixture({ nsyms: 65537 })) + )[0]; + expect(hugeSymbols?.status).toBe("parsed"); + expect(findRow(hugeSymbols?.rows ?? [], "pie").state).toBe("enabled"); + expect(findRow(hugeSymbols?.rows ?? [], "stack_protector")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("structural budget"), + }); + const hugeSlice = machoHardeningFixture({ nsyms: 65537 }); + const kept = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: arm, cpuType: 0x0100000c, cpuSubtype: 0 }, + { bytes: hugeSlice, cpuType: 0x0100000c, cpuSubtype: 0 }, + ]), + ); + expect(kept[0]?.status).toBe("parsed"); + expect(findRow(kept[0]?.rows ?? [], "pie").state).toBe("enabled"); + expect(kept[1]?.status).toBe("parsed"); + expect(findRow(kept[1]?.rows ?? [], "stack_protector").state).toBe("unknown"); + await expect( + readMachoHardening(machoHardeningFixture({ brokenStrx: true })), + ).rejects.toMatchObject({ kind: "malformed" }); + // Symbol order must not decide whether a damaged table is admitted. + await expect( + readMachoHardening( + machoHardeningFixture({ stackChk: true, trailingBrokenStrx: true }), + ), + ).rejects.toMatchObject({ kind: "malformed" }); + const brokenHeader = machoHardeningFixture(); + brokenHeader.writeUInt32LE(4, 20); + await expect(readMachoHardening(brokenHeader)).rejects.toMatchObject({ + kind: "malformed", + }); + const badSlice = machoHardeningFixture(); + badSlice.writeUInt32LE(4, 20); + const siblings = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: arm, cpuType: 0x0100000c, cpuSubtype: 0 }, + { bytes: badSlice, cpuType: 0x0100000c, cpuSubtype: 0 }, + ]), + ); + expect(siblings[0]?.status).toBe("parsed"); + expect(siblings[0]?.rows.length).toBeGreaterThan(0); + expect(siblings[1]).toMatchObject({ status: "malformed", rows: [] }); + await expect(readMachoHardening(Buffer.from("not macho"))).rejects.toThrow( + expect.objectContaining({ kind: "unsupported" }), + ); +}); + +it("keeps repeated DT_NEEDED and still rejects a duplicated singular tag", () => { + const needed = readElfHardening( + elfHardeningFixture({ needed: ["libc.so.6", "libm.so.6"] }), + ); + expect(needed.status).toBe("parsed"); + expect(() => + readElfHardening(elfHardeningFixture({ duplicateFlags: true })), + ).toThrow(expect.objectContaining({ kind: "malformed" })); +}); + +it("reads a 4-aligned build-id note and bounds aliased note walks", () => { + const buildId = readElfHardening(elfHardeningFixture({ buildId: true })); + expect(findRow(buildId.rows, "cet_ibt_shstk").state).toBe("disabled"); + const aliased = readElfHardening( + elfHardeningFixture({ buildId: true, buildIdSections: 300 }), + ); + expect(aliased.status).toBe("parsed"); + const manyBuildIds = readElfHardening( + elfHardeningFixture({ buildIdCount: 257 }), + ); + expect(manyBuildIds.status).toBe("parsed"); + expect(findRow(manyBuildIds.rows, "nx").state).toBe("enabled"); + expect(findRow(manyBuildIds.rows, "pie").state).toBe("not_applicable"); + expect(findRow(manyBuildIds.rows, "relro").state).toBe("disabled"); + const copiedProperties = readElfHardening( + elfHardeningFixture({ + property: { arch: "x86", value: 0b11 }, + propertyCopies: 200, + }), + ); + expect(copiedProperties.status).toBe("parsed"); + expect(findRow(copiedProperties.rows, "cet_ibt_shstk").state).toBe("enabled"); + expect(findRow(copiedProperties.rows, "nx").state).toBe("enabled"); + expect(() => + readElfHardening( + elfHardeningFixture({ buildId: true }), + AbortSignal.abort(), + ), + ).toThrow(ArtifactReaderFailure); +}); + +it.each(["pt-note", "pt-gnu-property", "section-only"] as const)( + "identifies GNU properties by note owner and type in renamed sections (%s)", + (propertyCarrier) => { + const bytes = elfHardeningFixture({ + propertyCarrier: + propertyCarrier === "section-only" ? "pt-note" : propertyCarrier, + property: { arch: "x86", value: 3 }, + }); + const nameAt = bytes.indexOf(Buffer.from(".note.gnu.property\0")); + expect(nameAt).toBeGreaterThan(0); + bytes.write(".note.custom.abcde\0", nameAt, "latin1"); + if (propertyCarrier === "section-only") { + const phoff = Number(bytes.readBigUInt64LE(32)); + for (let index = 0; index < bytes.readUInt16LE(56); index += 1) { + const at = phoff + index * 56; + if (bytes.readUInt32LE(at) === 4) bytes.writeUInt32LE(0, at); + } + } + expect(findRow(readElfHardening(bytes).rows, "cet_ibt_shstk").state).toBe( + "enabled", + ); + }, +); + +it.each([0, 1, 2])( + "keeps partially covered PT_NOTE contents unknown (section note %i)", + (sectionNote) => { + const bytes = elfHardeningFixture({ + propertyCarrier: "pt-note", + property: { arch: "x86", value: 3 }, + propertyCopies: 3, + }); + const shoff = Number(bytes.readBigUInt64LE(40)); + let sectionAt = 0; + for (let index = 0; index < bytes.readUInt16LE(60); index += 1) { + const at = shoff + index * 64; + if (bytes.readUInt32LE(at + 4) === 7) sectionAt = at; + } + expect(sectionAt).toBeGreaterThan(0); + const noteAt = Number(bytes.readBigUInt64LE(sectionAt + 24)); + const noteBytes = Number(bytes.readBigUInt64LE(sectionAt + 32)) / 3; + const coveredAt = noteAt + sectionNote * noteBytes; + // The section aliases just a build-id; CET remains in the segment's + // uncovered prefix and/or suffix. + bytes.writeUInt32LE(3, coveredAt + 8); + bytes.writeBigUInt64LE(BigInt(coveredAt), sectionAt + 24); + bytes.writeBigUInt64LE(BigInt(noteBytes), sectionAt + 32); + const image = readElfHardening(bytes); + expect(findRow(image.rows, "cet_ibt_shstk")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("uncovered bytes"), + }); + expect(findRow(image.rows, "nx").state).toBe("enabled"); + // Without the alias, all segment notes are available to the walker. + bytes.writeUInt32LE(1, sectionAt + 4); + expect(findRow(readElfHardening(bytes).rows, "cet_ibt_shstk").state).toBe( + "enabled", + ); + }, +); + +it("keeps same-span note tables with different alignments unknown", () => { + const bytes = elfHardeningFixture({ + propertyCarrier: "pt-note", + property: { arch: "x86", value: 3 }, + }); + const phoff = Number(bytes.readBigUInt64LE(32)); + let programAt = 0; + for (let index = 0; index < bytes.readUInt16LE(56); index += 1) { + const at = phoff + index * 56; + if (bytes.readUInt32LE(at) === 4) programAt = at; + } + const shoff = Number(bytes.readBigUInt64LE(40)); + let sectionAt = 0; + for (let index = 0; index < bytes.readUInt16LE(60); index += 1) { + const at = shoff + index * 64; + if (bytes.readUInt32LE(at + 4) === 7) sectionAt = at; + } + expect(programAt).toBeGreaterThan(0); + expect(sectionAt).toBeGreaterThan(0); + expect(bytes.readBigUInt64LE(sectionAt + 24)).toBe( + bytes.readBigUInt64LE(programAt + 8), + ); + expect(bytes.readBigUInt64LE(sectionAt + 32)).toBe( + bytes.readBigUInt64LE(programAt + 32), + ); + expect(findRow(readElfHardening(bytes).rows, "cet_ibt_shstk").state).toBe( + "enabled", + ); + // Matching endpoints with a 4-aligned section and an 8-aligned segment do + // not show that both frame the same notes. + bytes.writeBigUInt64LE(4n, sectionAt + 48); + const image = readElfHardening(bytes); + expect(findRow(image.rows, "cet_ibt_shstk")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("different note alignments"), + }); + expect(findRow(image.rows, "nx").state).toBe("enabled"); +}); + +it("keeps a PT_NOTE that frames a note inside a section descriptor unknown", () => { + const original = elfHardeningFixture({ + propertyCarrier: "pt-note", + property: { arch: "x86", value: 3 }, + }); + const phoff = Number(original.readBigUInt64LE(32)); + let programAt = 0; + for (let index = 0; index < original.readUInt16LE(56); index += 1) { + const at = phoff + index * 56; + if (original.readUInt32LE(at) === 4) programAt = at; + } + const shoff = Number(original.readBigUInt64LE(40)); + let sectionAt = 0; + for (let index = 0; index < original.readUInt16LE(60); index += 1) { + const at = shoff + index * 64; + if (original.readUInt32LE(at + 4) === 7) sectionAt = at; + } + expect(programAt).toBeGreaterThan(0); + expect(sectionAt).toBeGreaterThan(0); + const innerAt = Number(original.readBigUInt64LE(programAt + 8)); + const innerBytes = Number(original.readBigUInt64LE(programAt + 32)); + const inner = original.subarray(innerAt, innerAt + innerBytes); + // A non-GNU note whose descriptor is a complete GNU property note. The + // section walks only the outer note; the segment names the inner one. + const outer = Buffer.alloc(16); + outer.writeUInt32LE(4, 0); + outer.writeUInt32LE(innerBytes, 4); + outer.writeUInt32LE(1, 8); + outer.write("XYZ\0", 12, "latin1"); + const padding = Buffer.alloc((8 - (original.length % 8)) % 8); + const outerAt = original.length + padding.length; + const bytes = Buffer.concat([original, padding, outer, inner]); + bytes.writeBigUInt64LE(BigInt(outerAt), sectionAt + 24); + bytes.writeBigUInt64LE(BigInt(16 + innerBytes), sectionAt + 32); + bytes.writeBigUInt64LE(BigInt(outerAt + 16), programAt + 8); + const image = readElfHardening(bytes); + expect(findRow(image.rows, "cet_ibt_shstk")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("inside another note table"), + }); + expect(findRow(image.rows, "nx").state).toBe("enabled"); + // A segment on the section's own boundaries walks the same notes once. + bytes.writeBigUInt64LE(BigInt(outerAt), programAt + 8); + bytes.writeBigUInt64LE(BigInt(16 + innerBytes), programAt + 32); + expect(findRow(readElfHardening(bytes).rows, "cet_ibt_shstk").state).toBe( + "disabled", + ); +}); + +it.each([true, false])( + "preserves sectionless imports with an empty GNU hash (SysV count: %s)", + (withSysv) => { + const original = elfHardeningFixture({ + sections: false, + stackChk: true, + fortify: true, + bindNowTag: true, + }); + const bytes = Buffer.concat([original, Buffer.alloc(32)]); + const phoff = Number(bytes.readBigUInt64LE(32)); + bytes.writeBigUInt64LE(BigInt(bytes.length), phoff + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length), phoff + 40); + // One bloom word and an empty bucket; symoffset is not a symbol count. + bytes.writeUInt32LE(1, original.length); + bytes.writeUInt32LE(1, original.length + 4); + bytes.writeUInt32LE(1, original.length + 8); + for (let index = 0; index < bytes.readUInt16LE(56); index += 1) { + const at = phoff + index * 56; + if (bytes.readUInt32LE(at) !== 2) continue; + const dynamicAt = Number(bytes.readBigUInt64LE(at + 8)); + for ( + let entry = dynamicAt; + bytes.readBigUInt64LE(entry) !== 0n; + entry += 16 + ) { + const tag = bytes.readBigUInt64LE(entry); + if (tag === 24n) { + bytes.writeBigUInt64LE(0x6ffffef5n, entry); + bytes.writeBigUInt64LE(BigInt(original.length), entry + 8); + } else if (tag === 4n && !withSysv) { + bytes.writeBigUInt64LE(21n, entry); // DT_DEBUG, no count information. + bytes.writeBigUInt64LE(0n, entry + 8); + } + } + } + const image = readElfHardening(bytes); + expect(image.rows.map(({ property }) => property)).toEqual([ + ...ELF_HARDENING_PROPERTIES, + ]); + expect(states(image.rows)).toMatchObject({ + nx: "enabled", + stack_protector: withSysv ? "enabled" : "unknown", + fortify: withSysv ? "enabled" : "unknown", + }); + if (withSysv) { + // A nonempty GNU chain still supplies an exact count that must agree. + bytes.writeUInt32LE(1, original.length + 24); + bytes.writeUInt32LE(1, original.length + 28); + expect(() => readElfHardening(bytes)).toThrow(/different symbol counts/u); + } + }, +); + +it("reads sectionless GNU hash buckets with many non-owning load declarations", () => { + const original = elfHardeningFixture({ sections: false, stackChk: true }); + const oldPhoff = Number(original.readBigUInt64LE(32)); + const oldPhnum = original.readUInt16LE(56); + const buckets = 65536; + const hashAt = original.length; + const hashBytes = 24 + buckets * 4 + 4; + const phoff = hashAt + hashBytes; + const phnum = 60000; + const bytes = Buffer.alloc(phoff + phnum * 56); + original.copy(bytes); + original.copy(bytes, phoff, oldPhoff, oldPhoff + oldPhnum * 56); + bytes.writeBigUInt64LE(BigInt(phoff), 32); + bytes.writeUInt16LE(phnum, 56); + bytes.writeBigUInt64LE(BigInt(bytes.length), phoff + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length), phoff + 40); + for (let index = oldPhnum; index < phnum; index += 1) { + const at = phoff + index * 56; + bytes.writeUInt32LE(1, at); + bytes.writeBigUInt64LE(0x10000000n + BigInt(index * 4096), at + 16); + bytes.writeBigUInt64LE(1n, at + 32); + bytes.writeBigUInt64LE(1n, at + 40); + } + bytes.writeUInt32LE(buckets, hashAt); + bytes.writeUInt32LE(1, hashAt + 4); + bytes.writeUInt32LE(1, hashAt + 8); + bytes.writeUInt32LE(1, hashAt + 24 + (buckets - 1) * 4); + bytes.writeUInt32LE(1, hashAt + hashBytes - 4); + for (let index = 0; index < oldPhnum; index += 1) { + const at = phoff + index * 56; + if (bytes.readUInt32LE(at) !== 2) continue; + const dynamicAt = Number(bytes.readBigUInt64LE(at + 8)); + for ( + let entry = dynamicAt; + bytes.readBigUInt64LE(entry) !== 0n; + entry += 16 + ) { + if (bytes.readBigUInt64LE(entry) !== 4n) continue; + bytes.writeBigUInt64LE(0x6ffffef5n, entry); + bytes.writeBigUInt64LE(BigInt(hashAt), entry + 8); + } + } + expect(findRow(readElfHardening(bytes).rows, "stack_protector").state).toBe( + "enabled", + ); + // Retaining the mapping must still reject an ambiguous table owner: this + // load maps the table's addresses from other file bytes. + const extraAt = phoff + oldPhnum * 56; + bytes.writeBigUInt64LE(0n, extraAt + 8); + bytes.writeBigUInt64LE(BigInt(hashAt), extraAt + 16); + bytes.writeBigUInt64LE(BigInt(hashBytes), extraAt + 32); + bytes.writeBigUInt64LE(BigInt(hashBytes), extraAt + 40); + expect(() => readElfHardening(bytes)).toThrow(/exactly one PT_LOAD/u); +}); + +it.each([ + ["string table", 5n, 1n, /crosses another PT_LOAD/u], + ["SysV hash table", 4n, 4n, /crosses another PT_LOAD/u], +] as const)( + "rejects a dynamic %s that a second PT_LOAD remaps partway through", + (_label, tag, inset, message) => { + const original = elfHardeningFixture({ sections: false, stackChk: true }); + const oldPhoff = Number(original.readBigUInt64LE(32)); + const phnum = original.readUInt16LE(56); + const phoff = original.length; + const bytes = Buffer.alloc(phoff + (phnum + 1) * 56); + original.copy(bytes); + original.copy(bytes, phoff, oldPhoff, oldPhoff + phnum * 56); + bytes.writeBigUInt64LE(BigInt(phoff), 32); + bytes.writeUInt16LE(phnum + 1, 56); + let table = 0n; + for (let index = 0; index < phnum; index += 1) { + const at = phoff + index * 56; + if (bytes.readUInt32LE(at) !== 2) continue; + for ( + let entry = Number(bytes.readBigUInt64LE(at + 8)); + bytes.readBigUInt64LE(entry) !== 0n; + entry += 16 + ) + if (bytes.readBigUInt64LE(entry) === tag) + table = bytes.readBigUInt64LE(entry + 8); + } + expect(table).toBeGreaterThan(0n); + expect(findRow(readElfHardening(bytes).rows, "stack_protector").state).toBe( + "enabled", + ); + // The extra load starts inside the table and maps it from other bytes. + const extraAt = phoff + phnum * 56; + bytes.writeUInt32LE(1, extraAt); + bytes.writeBigUInt64LE(0n, extraAt + 8); + bytes.writeBigUInt64LE(table + inset, extraAt + 16); + bytes.writeBigUInt64LE(16n, extraAt + 32); + bytes.writeBigUInt64LE(16n, extraAt + 40); + expect(() => readElfHardening(bytes)).toThrow(message); + // The same load with the owner's translation names the same bytes, so the + // table keeps one interpretation. + let translation: bigint | null = null; + for (let index = 0; index < phnum; index += 1) { + const at = phoff + index * 56; + const va = bytes.readBigUInt64LE(at + 16); + if ( + bytes.readUInt32LE(at) === 1 && + table >= va && + table < va + bytes.readBigUInt64LE(at + 32) + ) + translation = bytes.readBigUInt64LE(at + 8) - va; + } + expect(translation).not.toBeNull(); + bytes.writeBigUInt64LE(table + inset + (translation ?? 0n), extraAt + 8); + expect(findRow(readElfHardening(bytes).rows, "stack_protector").state).toBe( + "enabled", + ); + // A zero-filled tail of that equivalent load still remaps the table. + bytes.writeBigUInt64LE(0n, extraAt + 32); + expect(() => readElfHardening(bytes)).toThrow(message); + }, +); + +it.each([ + ["string table", 5, 18, null], + ["string table with only its first byte mapped", 5, 18, 1], + ["symbol table", 6, 48, null], + ["SysV hash header", 4, 20, 7], + ["SysV hash table", 4, 20, null], + ["GNU hash header", 0x6ffffef5, 32, 15], + ["GNU hash bloom filter", 0x6ffffef5, 32, 23], + ["GNU hash bucket", 0x6ffffef5, 32, 27], + ["GNU hash chain", 0x6ffffef5, 32, null], +] as const)( + "rejects a dynamic %s extending beyond its file-backed PT_LOAD", + (_label, tag, size, backedBytes) => { + const original = elfHardeningFixture({ sections: false, stackChk: true }); + const bytes = Buffer.concat([original, Buffer.alloc(size)]); + const phoff = Number(bytes.readBigUInt64LE(32)); + const phnum = bytes.readUInt16LE(56); + let dynamicAt = 0; + for (let index = 0; index < phnum; index += 1) { + const at = phoff + index * 56; + if (bytes.readUInt32LE(at) === 2) + dynamicAt = Number(bytes.readBigUInt64LE(at + 8)); + } + expect(dynamicAt).toBeGreaterThan(0); + const sourceTag = tag === 0x6ffffef5 ? 4 : tag; + for (let at = dynamicAt; bytes.readBigUInt64LE(at) !== 0n; at += 16) { + if (bytes.readBigUInt64LE(at) !== BigInt(sourceTag)) continue; + const source = Number(bytes.readBigUInt64LE(at + 8)); + if (tag === 0x6ffffef5) { + // One bloom word, one bucket and one terminated chain for symbol 1. + [1, 1, 1, 0, 0, 0, 1, 1].forEach((word, index) => + bytes.writeUInt32LE(word, original.length + index * 4), + ); + } else original.copy(bytes, original.length, source, source + size); + bytes.writeBigUInt64LE(BigInt(tag), at); + bytes.writeBigUInt64LE(BigInt(original.length), at + 8); + } + bytes.writeBigUInt64LE(BigInt(bytes.length), phoff + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length), phoff + 40); + expect(findRow(readElfHardening(bytes).rows, "stack_protector").state).toBe( + "enabled", + ); + bytes.writeBigUInt64LE( + BigInt(original.length + (backedBytes ?? size - 1)), + phoff + 32, + ); + expect(() => readElfHardening(bytes)).toThrow(/file-backed/u); + }, +); + +it("treats DT_BIND_NOW as full RELRO and leaves unsearched property notes unknown", () => { + const bound = readElfHardening( + elfHardeningFixture({ gnuRelro: true, bindNowTag: true }), + ); + expect(findRow(bound.rows, "relro").state).toBe("enabled"); + const bare = readElfHardening(elfHardeningFixture({ sections: false })); + expect(findRow(bare.rows, "cet_ibt_shstk").state).toBe("unknown"); + expect(findRow(bare.rows, "aarch64_bti_pac").state).toBe("not_applicable"); + const program = readElfHardening( + elfHardeningFixture({ + sections: false, + property: { arch: "x86", value: 0b11 }, + }), + ); + expect(findRow(program.rows, "cet_ibt_shstk").state).toBe("enabled"); + const note = readElfHardening( + elfHardeningFixture({ + sections: false, + propertyCarrier: "pt-note", + property: { arch: "x86", value: 0b11 }, + }), + ); + expect(findRow(note.rows, "cet_ibt_shstk").state).toBe("enabled"); +}); + +it("bounds aggregate ELF and Mach-O symbol-name scans", async () => { + const elfScan = readElfHardening( + elfHardeningFixture({ symbolScanBudget: true }), + ); + expect(elfScan.status).toBe("parsed"); + expect(findRow(elfScan.rows, "nx").state).toBe("enabled"); + expect(findRow(elfScan.rows, "stack_protector")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("structural budget"), + }); + expect(findRow(elfScan.rows, "fortify").state).toBe("unknown"); + const machoScan = ( + await readMachoHardening(machoHardeningFixture({ symbolScanBudget: true })) + )[0]; + expect(machoScan?.status).toBe("parsed"); + expect(findRow(machoScan?.rows ?? [], "pie").state).toBe("enabled"); + expect(findRow(machoScan?.rows ?? [], "stack_protector")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("structural budget"), + }); +}); + +it("keeps ELF rows when an import name has no NUL in the name budget", () => { + const image = readElfHardening(elfHardeningFixture({ longSymbolName: true })); + expect(image.status).toBe("parsed"); + expect(findRow(image.rows, "nx").state).toBe("enabled"); + expect(findRow(image.rows, "relro").state).toBe("disabled"); + for (const property of ["stack_protector", "fortify"] as const) + expect(findRow(image.rows, property)).toMatchObject({ + state: "unknown", + note: expect.stringContaining("65536-byte name budget"), + }); + const signed = readElfHardening( + elfHardeningFixture({ longSymbolName: true, stackChk: true }), + ); + expect(findRow(signed.rows, "stack_protector").state).toBe("enabled"); + expect(findRow(signed.rows, "fortify").state).toBe("unknown"); +}); + +it("keeps Mach-O rows when an import name has no NUL in the name budget", async () => { + const thin = ( + await readMachoHardening(machoHardeningFixture({ longSymbolName: true })) + )[0]; + expect(thin?.status).toBe("parsed"); + expect(findRow(thin?.rows ?? [], "pie").state).toBe("enabled"); + expect(findRow(thin?.rows ?? [], "stack_protector")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("65536-byte name budget"), + }); + const fat = await readMachoHardening( + fatMachoHardeningFixture([ + { + bytes: machoHardeningFixture({ longSymbolName: true }), + cpuType: 0x0100000c, + cpuSubtype: 0, + }, + ]), + ); + expect(fat[0]?.status).toBe("parsed"); + expect(fat[0]?.rows.length).toBe(MACHO_HARDENING_PROPERTIES.length); +}); + +it("notes each architecture's real default for a missing PT_GNU_STACK", () => { + const note = (machine: number, bits: 32 | 64) => + findRow( + readElfHardening( + elfHardeningFixture({ bits, machine, gnuStack: null, symtab: false }), + ).rows, + "nx", + ); + for (const [machine, bits] of [ + [3, 32], + [20, 32], + [40, 32], + ] as const) + expect(note(machine, bits)).toMatchObject({ + state: "unknown", + note: expect.stringContaining("read-implies-exec"), + }); + for (const [machine, bits] of [ + [21, 64], + [62, 64], + [183, 64], + [243, 64], + ] as const) + expect(note(machine, bits)).toMatchObject({ + state: "unknown", + note: expect.stringContaining("non-executable stack"), + }); + expect(note(8, 32).note).not.toContain("default for"); +}); + +it("does not read program-table absence from objects without them", () => { + for (const type of [1, 4]) { + const image = readElfHardening( + elfHardeningFixture({ type, gnuStack: null }), + ); + for (const property of ["nx", "relro", "rpath", "runpath"] as const) + expect(findRow(image.rows, property)).toMatchObject({ + state: "not_applicable", + location: { offset: 16, bytes: 2 }, + }); + } + const bytes = elfHardeningFixture({ type: 3 }); + bytes.writeUInt16LE(0, 56); + const image = readElfHardening(bytes); + for (const property of ["pie", "nx", "relro", "rpath", "runpath"] as const) + expect(findRow(image.rows, property).state).toBe("unknown"); +}); + +it("reports the note budget it hit and keeps the header rows", () => { + const entries = readElfHardening( + elfHardeningFixture({ disjointNoteSections: 300 }), + ); + expect(entries.status).toBe("parsed"); + expect(findRow(entries.rows, "nx").state).toBe("enabled"); + expect(findRow(entries.rows, "cet_ibt_shstk")).toMatchObject({ + state: "unknown", + note: "ELF note tables exceed the 256-entry budget.", + }); + const text = readElfHardening( + elfHardeningFixture({ longNoteName: true, type: 2 }), + ); + expect(findRow(text.rows, "pie").state).toBe("disabled"); + expect(findRow(text.rows, "cet_ibt_shstk")).toMatchObject({ + state: "unknown", + note: expect.stringMatching( + /^ELF note text exceeds the \d+-byte budget\.$/u, + ), + }); + expect(findRow(text.rows, "aarch64_bti_pac").state).toBe("not_applicable"); +}); + +it("skips an empty SHT_NOTE section instead of rejecting the image", () => { + const bytes = elfHardeningFixture({ + propertyCarrier: "pt-note", + property: { arch: "x86", value: 3 }, + }); + const shoff = Number(bytes.readBigUInt64LE(40)); + let sectionAt = 0; + for (let index = 0; index < bytes.readUInt16LE(60); index += 1) { + const at = shoff + index * 64; + if (bytes.readUInt32LE(at + 4) === 7) sectionAt = at; + } + expect(sectionAt).toBeGreaterThan(0); + bytes.writeBigUInt64LE(0n, sectionAt + 32); + expect(() => readElfImage(bytes)).not.toThrow(); + const image = readElfHardening(bytes); + expect(image.status).toBe("parsed"); + expect(findRow(image.rows, "nx").state).toBe("enabled"); + expect(findRow(image.rows, "cet_ibt_shstk").state).toBe("enabled"); +}); + +it("leaves GNU property rows unknown behind a compressed note section", () => { + const image = readElfHardening( + elfHardeningFixture({ + compressedPropertyNote: true, + property: { arch: "x86", value: 0b11 }, + propertyCarrier: "pt-note", + }), + ); + expect(image.status).toBe("parsed"); + expect(findRow(image.rows, "nx").state).toBe("enabled"); + expect(findRow(image.rows, "cet_ibt_shstk")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("compressed SHT_NOTE"), + }); +}); + +it("admits a hostile section table without rescanning every span", () => { + const started = performance.now(); + const image = readElfHardening( + elfHardeningFixture({ disjointNoteSections: 65000 }), + ); + expect(performance.now() - started).toBeLessThan(5000); + expect(findRow(image.rows, "cet_ibt_shstk").state).toBe("unknown"); +}, 60000); + +it("cites a non-ASCII import name by its source bytes", () => { + const name = "__\u00ff_chk"; + const bytes = elfHardeningFixture({ fortify: true, fortifyName: name }); + const image = readElfHardening(bytes); + const row = findRow(image.rows, "fortify"); + expect(row).toMatchObject({ state: "enabled", location: { bytes: 7 } }); + const at = row.location?.offset ?? 0; + expect(bytes.subarray(at, at + 7).toString("latin1")).toBe(name); +}); + +it("keeps malformed name offsets malformed in both formats", async () => { + await expect( + readMachoHardening(machoHardeningFixture({ brokenStrx: true })), + ).rejects.toMatchObject({ + kind: "malformed", + message: "Mach-O symbol name offset is outside the string table", + }); +}); + +it("names one CPU family per image and keeps the exact slice identity", async () => { + const thin = ( + await readMachoHardening(machoHardeningFixture({ cpuSubtype: 2 })) + )[0]; + expect(thin?.architecture).toBe("arm64"); + expect(findRow(thin?.rows ?? [], "arm64e").state).toBe("enabled"); + const fat = await readMachoHardening( + fatMachoHardeningFixture([ + { + bytes: machoHardeningFixture({ cpuSubtype: 2 }), + cpuType: 0x0100000c, + cpuSubtype: 2, + }, + ]), + ); + expect(fat[0]).toMatchObject({ + architecture: "arm64", + slice: { architecture: "arm64e" }, + }); + const narrow = peHardeningImage({ bits: 32 }); + expect((await readPeHardening(narrow, peArtifact(narrow))).architecture).toBe( + "x86", + ); + const encoded = peHardeningImage(); + const pe = encoded.readUInt32LE(0x3c); + encoded.writeUInt16LE(0x8664 ^ 0x7b79, pe + 4); + expect( + (await readPeHardening(encoded, peArtifact(encoded))).architecture, + ).toBeNull(); +}); + +it("names the load-config failure behind an unknown GuardFlags row", async () => { + const bytes = peHardeningImage({ malformed: "tls-unmapped" }); + const row = findRow( + (await readPeHardening(bytes, peArtifact(bytes))).rows, + "cfg_instrumented", + ); + expect(row).toMatchObject({ + state: "unknown", + note: expect.stringMatching(/unknown here: \S/u), + }); +}); + +it("reports MH_PIE as not applicable on dylibs and bundles", async () => { + for (const fileType of [6, 8]) { + const image = ( + await readMachoHardening(machoHardeningFixture({ fileType })) + )[0]; + expect(findRow(image?.rows ?? [], "pie")).toMatchObject({ + state: "not_applicable", + basis: expect.stringContaining(fileType === 6 ? "MH_DYLIB" : "MH_BUNDLE"), + }); + } +}); + +it("keeps header rows when a slice has conflicting encryption commands", async () => { + const image = ( + await readMachoHardening( + machoHardeningFixture({ encrypted: false, duplicateEncryption: true }), + ) + )[0]; + expect(image?.status).toBe("parsed"); + expect(findRow(image?.rows ?? [], "pie").state).toBe("enabled"); + expect(findRow(image?.rows ?? [], "encrypted")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("encryption load commands"), + }); +}); + +it("keeps a parsed fat slice when another slice is empty or past EOF", async () => { + const arm = machoHardeningFixture(); + const images = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: arm, cpuType: 0x0100000c, cpuSubtype: 0 }, + { + bytes: arm, + cpuType: 0x0100000c, + cpuSubtype: 0, + declaredSize: 0, + includePayload: false, + }, + { + bytes: arm, + cpuType: 0x0100000c, + cpuSubtype: 0, + declaredSize: arm.length + 64, + }, + ]), + ); + expect(images.map((image) => image.status)).toEqual([ + "parsed", + "malformed", + "malformed", + ]); + expect(images[1]?.slice?.size).toBe(0); + expect(images[2]?.slice?.size).toBe(arm.length + 64); + expect(images[1]?.reason).toEqual( + expect.stringContaining("invalid slice range"), + ); + expect(images[2]?.reason).toEqual( + expect.stringContaining("extends beyond the file"), + ); +}); + +/** Locate the 64-bit little-endian program header of a given type. */ +const elfProgramAt = (bytes: Buffer, type: number): number => { + const phoff = Number(bytes.readBigUInt64LE(32)); + for (let index = 0; index < bytes.readUInt16LE(56); index += 1) { + const at = phoff + index * 56; + if (bytes.readUInt32LE(at) === type) return at; + } + throw new Error(`Missing program type ${String(type)}`); +}; + +const elfSectionAt = (bytes: Buffer, type: number): number => { + const shoff = Number(bytes.readBigUInt64LE(40)); + for (let index = 0; index < bytes.readUInt16LE(60); index += 1) { + const at = shoff + index * 64; + if (bytes.readUInt32LE(at + 4) === type) return at; + } + throw new Error(`Missing section type ${String(type)}`); +}; + +const elfDynamicEntryAt = (bytes: Buffer, tag: bigint): number => { + const start = Number(bytes.readBigUInt64LE(elfProgramAt(bytes, 2) + 8)); + for (let at = start; bytes.readBigUInt64LE(at) !== 0n; at += 16) + if (bytes.readBigUInt64LE(at) === tag) return at; + throw new Error(`Missing dynamic tag ${tag.toString()}`); +}; + +const PT_LOAD = 1; +const PT_INTERP = 3; +const SHT_DYNSYM = 11; +const SHT_STRTAB = 3; +const DT_HASH = 4n; +const DT_GNU_HASH = 0x6ffffef5n; +const DT_SYMTAB = 6n; + +/** Grow the image so its only PT_LOAD maps `extra`, returning where it landed. */ +const appendMapped = ( + image: Buffer, + extra: Buffer, +): { readonly bytes: Buffer; readonly at: number } => { + const bytes = Buffer.concat([image, extra]); + const load = elfProgramAt(bytes, PT_LOAD); + bytes.writeBigUInt64LE(BigInt(bytes.length), load + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length), load + 40); + return { bytes, at: image.length }; +}; + +/** Replace DT_HASH with a DT_GNU_HASH whose only bucket is empty. */ +const withEmptyGnuHash = (image: Buffer): Buffer => { + const words = Buffer.alloc(32); + [1, 1, 1, 0, 0, 0, 0, 0].forEach((word, index) => + words.writeUInt32LE(word, index * 4), + ); + const { bytes, at } = appendMapped(image, words); + const entry = elfDynamicEntryAt(bytes, DT_HASH); + bytes.writeBigUInt64LE(DT_GNU_HASH, entry); + bytes.writeBigUInt64LE(BigInt(at), entry + 8); + return bytes; +}; + +it.each([ + ["PT_LOAD past EOF", PT_LOAD, 8, 0n, /program 0/u], + ["PT_INTERP past EOF", PT_INTERP, 8, 1n << 40n, /program 1/u], +] as const)("rejects a %s", (_label, type, field, value, message) => { + const bytes = elfHardeningFixture({ interp: true, bindNow: true }); + const at = elfProgramAt(bytes, type); + if (value !== 0n) bytes.writeBigUInt64LE(value, at + field); + else bytes.writeBigUInt64LE(BigInt(bytes.length + 1), at + 32); + if (value === 0n) bytes.writeBigUInt64LE(BigInt(bytes.length + 1), at + 40); + expect(() => readElfHardening(bytes)).toThrow( + expect.objectContaining({ + kind: "malformed", + message: expect.stringMatching(message), + }), + ); +}); + +it("rejects a section payload past EOF and impossible load sizes", () => { + const section = elfHardeningFixture(); + section.writeBigUInt64LE( + BigInt(section.length + 1), + elfSectionAt(section, 2) + 32, + ); + expect(() => readElfHardening(section)).toThrow(/truncated ELF section/u); + const larger = elfHardeningFixture(); + larger.writeBigUInt64LE(1n, elfProgramAt(larger, PT_LOAD) + 40); + expect(() => readElfHardening(larger)).toThrow( + /load segment sizes or addresses/u, + ); + const wrapped = elfHardeningFixture(); + wrapped.writeBigUInt64LE( + 0xffffffffffffffffn, + elfProgramAt(wrapped, PT_LOAD) + 16, + ); + expect(() => readElfHardening(wrapped)).toThrow( + /load segment sizes or addresses/u, + ); +}); + +it.each([ + ["empty", 0n, null], + ["unterminated", null, 0x41], + ["NUL-leading", null, null], +] as const)("rejects an %s PT_INTERP", (label, size, last) => { + const bytes = elfHardeningFixture({ interp: true }); + const at = elfProgramAt(bytes, PT_INTERP); + if (size !== null) bytes.writeBigUInt64LE(size, at + 32); + if (last !== null) + bytes[ + Number(bytes.readBigUInt64LE(at + 8)) + + Number(bytes.readBigUInt64LE(at + 32)) - + 1 + ] = last; + if (label === "NUL-leading") bytes[Number(bytes.readBigUInt64LE(at + 8))] = 0; + expect(() => readElfHardening(bytes)).toThrow(/PT_INTERP/u); +}); + +it("reads imports from DT_SYMTAB when section metadata names other symbols", () => { + // The section names a protector the dynamic table does not declare. + const donor = elfHardeningFixture({ stackChk: true, fortify: true }); + const donorSymbols = elfSectionAt(donor, SHT_DYNSYM); + const donorStrings = elfSectionAt(donor, SHT_STRTAB); + const slice = (header: number): Buffer => { + const offset = Number(donor.readBigUInt64LE(header + 24)); + return donor.subarray( + offset, + offset + Number(donor.readBigUInt64LE(header + 32)), + ); + }; + const { bytes, at } = appendMapped( + elfHardeningFixture(), + Buffer.concat([slice(donorSymbols), slice(donorStrings)]), + ); + const symbols = elfSectionAt(bytes, SHT_DYNSYM); + const strings = elfSectionAt(bytes, SHT_STRTAB); + const symbolBytes = slice(donorSymbols).length; + bytes.writeBigUInt64LE(BigInt(at), symbols + 16); + bytes.writeBigUInt64LE(BigInt(at), symbols + 24); + bytes.writeBigUInt64LE(BigInt(symbolBytes), symbols + 32); + bytes.writeBigUInt64LE(BigInt(at + symbolBytes), strings + 16); + bytes.writeBigUInt64LE(BigInt(at + symbolBytes), strings + 24); + bytes.writeBigUInt64LE(BigInt(slice(donorStrings).length), strings + 32); + const rows = readElfHardening(bytes).rows; + expect(findRow(rows, "stack_protector").state).toBe("unknown"); + expect(findRow(rows, "fortify").state).toBe("unknown"); + // The reverse lie cannot hide a declared import. + const declared = elfHardeningFixture({ stackChk: true }); + declared.writeBigUInt64LE(0n, elfSectionAt(declared, SHT_DYNSYM) + 32); + expect( + findRow(readElfHardening(declared).rows, "stack_protector").state, + ).toBe("enabled"); +}); + +it("does not treat SHT_DYNSYM names as imports without DT_SYMTAB", () => { + const bytes = elfHardeningFixture({ stackChk: true }); + bytes.writeBigUInt64LE(0x60000000n, elfDynamicEntryAt(bytes, DT_SYMTAB)); + expect( + findRow(readElfHardening(bytes).rows, "stack_protector"), + ).toMatchObject({ + state: "unknown", + note: expect.stringContaining("DT_SYMTAB"), + }); +}); + +it("sizes an empty DT_GNU_HASH table only from an agreeing SHT_DYNSYM", () => { + const agreeing = withEmptyGnuHash(elfHardeningFixture({ stackChk: true })); + expect( + findRow(readElfHardening(agreeing).rows, "stack_protector"), + ).toMatchObject({ state: "enabled" }); + const moved = Buffer.from(agreeing); + const section = elfSectionAt(moved, SHT_DYNSYM); + moved.writeBigUInt64LE( + moved.readBigUInt64LE(section + 16) + 8n, + section + 16, + ); + const offset = Buffer.from(agreeing); + offset.writeBigUInt64LE( + offset.readBigUInt64LE(section + 24) + 8n, + section + 24, + ); + const strings = Buffer.from(agreeing); + strings.writeBigUInt64LE( + strings.readBigUInt64LE(elfSectionAt(strings, SHT_STRTAB) + 32) - 1n, + elfSectionAt(strings, SHT_STRTAB) + 32, + ); + for (const bytes of [moved, offset, strings]) + expect( + findRow(readElfHardening(bytes).rows, "stack_protector"), + ).toMatchObject({ + state: "unknown", + note: expect.stringContaining("DT_GNU_HASH"), + }); +}); + +it("keeps an unresolved symbol count distinct from an absent table", () => { + const image = readElfHardening( + withEmptyGnuHash(elfHardeningFixture({ sections: false, stackChk: true })), + ); + for (const property of ["stack_protector", "fortify"] as const) { + const row = findRow(image.rows, property); + expect(row).toMatchObject({ + state: "unknown", + note: expect.stringContaining("DT_GNU_HASH"), + }); + expect(row.basis).not.toContain("absent"); + } + const noHash = elfHardeningFixture({ sections: false, stackChk: true }); + noHash.writeBigUInt64LE(0x60000000n, elfDynamicEntryAt(noHash, DT_HASH)); + expect(findRow(readElfHardening(noHash).rows, "fortify")).toMatchObject({ + state: "unknown", + note: expect.stringContaining("DT_HASH"), + }); + const absent = readElfHardening(elfHardeningFixture({ dynamic: false })); + expect(findRow(absent.rows, "fortify").basis).toContain("absent"); +}); + +const machoCommand = (command: number, size: number, ...words: number[]) => { + const body = Buffer.alloc(size); + body.writeUInt32LE(command, 0); + body.writeUInt32LE(size, 4); + words.forEach((word, index) => body.writeUInt32LE(word, 8 + index * 4)); + return body; +}; + +const LC_CODE_SIGNATURE = 0x1d; +const LC_SEGMENT_64 = 0x19; +const LC_ENCRYPTION_INFO = 0x21; +const LC_ENCRYPTION_INFO_64 = 0x2c; +const CPU_ARM64 = 0x0100000c; + +it.each([ + [ + "an eight-byte LC_ENCRYPTION_INFO_64", + {}, + machoCommand(LC_ENCRYPTION_INFO_64, 8), + /LC_ENCRYPTION_INFO_64 has cmdsize 8, expected 24/u, + ], + [ + "an encrypted range past the slice", + {}, + machoCommand(LC_ENCRYPTION_INFO_64, 24, 0xfffffff0, 0x1000, 1), + /encrypted range ends at .* beyond the/u, + ], + [ + "a 32-bit LC_ENCRYPTION_INFO in a 64-bit image", + {}, + machoCommand(LC_ENCRYPTION_INFO, 24), + /LC_ENCRYPTION_INFO appears in a 64-bit image/u, + ], + [ + "an eight-byte LC_CODE_SIGNATURE", + { codeSignature: false }, + machoCommand(LC_CODE_SIGNATURE, 8), + /too short for LC_CODE_SIGNATURE/u, + ], + [ + "a LC_CODE_SIGNATURE range outside the slice", + {}, + machoCommand(LC_CODE_SIGNATURE, 16, 0xfffffff0, 0x1000), + /LC_CODE_SIGNATURE.*outside/u, + ], + [ + "a truncated segment after __RESTRICT", + { restrict: true }, + machoCommand(LC_SEGMENT_64, 8), + /segment header is truncated/u, + ], +] as const)("rejects %s", async (_label, options, command, message) => { + const bytes = machoHardeningFixture({ + ...options, + extraCommands: [command], + }); + await expect(readMachoHardening(bytes)).rejects.toMatchObject({ + kind: "malformed", + message: expect.stringMatching(message), + }); + const images = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: machoHardeningFixture(), cpuType: CPU_ARM64, cpuSubtype: 0 }, + { bytes, cpuType: CPU_ARM64, cpuSubtype: 0 }, + ]), + ); + expect(images.map((image) => image.status)).toEqual(["parsed", "malformed"]); + expect(images[1]?.reason).toMatch(message); + expect(images[1]?.rows).toEqual([]); +}); + +it("reads facts past a large table of unrelated Mach-O commands", async () => { + const filler = Array.from({ length: 200_000 }, () => machoCommand(0x7fff, 8)); + const image = ( + await readMachoHardening( + machoHardeningFixture({ + extraCommands: [...filler, machoCommand(LC_CODE_SIGNATURE, 16)], + stackChk: true, + }), + ) + )[0]; + expect(image?.status).toBe("parsed"); + expect(findRow(image?.rows ?? [], "code_signature_present").state).toBe( + "enabled", + ); + expect(findRow(image?.rows ?? [], "stack_protector").state).toBe("enabled"); +}); + +it("rejects p_filesz one above p_memsz like the ELF image reader", () => { + const bytes = elfHardeningFixture({ interp: true, bindNow: true }); + const load = elfProgramAt(bytes, PT_LOAD); + bytes.writeBigUInt64LE(bytes.readBigUInt64LE(load + 32) - 1n, load + 40); + const message = "ELF load segment sizes or addresses are invalid"; + expect(() => readElfImage(bytes)).toThrow(message); + expect(() => readElfHardening(bytes)).toThrow( + expect.objectContaining({ kind: "malformed", message }), + ); +}); + +const machoSegment = ( + bits: 32 | 64, + { + commandSize = bits === 64 ? 72 : 56, + name = "__RESTRICT", + vmSize = 0, + fileOffset = 0, + fileSize = 0, + sections = 0, + }: { + readonly commandSize?: number; + readonly name?: string; + readonly vmSize?: number; + readonly fileOffset?: number; + readonly fileSize?: number; + readonly sections?: number; + }, +): Buffer => { + const body = machoCommand(bits === 64 ? LC_SEGMENT_64 : 1, commandSize); + body.write(name, 8, "latin1"); + if (bits === 64) { + body.writeBigUInt64LE(BigInt(vmSize), 32); + body.writeBigUInt64LE(BigInt(fileOffset), 40); + body.writeBigUInt64LE(BigInt(fileSize), 48); + body.writeUInt32LE(sections, 64); + } else { + body.writeUInt32LE(vmSize, 28); + body.writeUInt32LE(fileOffset, 32); + body.writeUInt32LE(fileSize, 36); + body.writeUInt32LE(sections, 48); + } + return body; +}; + +it.each([ + [ + "a section count above cmdsize", + 64, + { sections: 1 }, + "Mach-O section table exceeds its segment command", + ], + [ + "a 32-bit section count above cmdsize", + 32, + { sections: 1 }, + "Mach-O section table exceeds its segment command", + ], + [ + "a file size above the VM size", + 64, + { vmSize: 8, fileSize: 16 }, + "Mach-O segment addresses or sizes are invalid", + ], + [ + "a payload past EOF", + 64, + { vmSize: 0x100000, fileOffset: 0x100000, fileSize: 16 }, + "Mach-O segment range is outside the file", + ], +] as const)( + "rejects %s like the Mach-O image reader", + async (_label, bits, segment, message) => { + const bytes = machoHardeningFixture({ + bits, + extraCommands: [machoSegment(bits, segment)], + }); + expect(() => readMachoImage(bytes)).toThrow(message); + await expect(readMachoHardening(bytes)).rejects.toMatchObject({ + kind: "malformed", + message, + }); + const images = await readMachoHardening( + fatMachoHardeningFixture([ + { + bytes: machoHardeningFixture({ bits }), + cpuType: bits === 64 ? CPU_ARM64 : 7, + cpuSubtype: 0, + }, + { bytes, cpuType: bits === 64 ? CPU_ARM64 : 7, cpuSubtype: 0 }, + ]), + ); + expect(images.map((image) => image.status)).toEqual([ + "parsed", + "malformed", + ]); + expect(images[1]?.reason).toBe(message); + expect(images[1]?.rows).toEqual([]); + }, +); + +it.each([ + [ + "a file-backed payload past EOF", + { offset: 0x10000000, size: 16, flags: 0 }, + ], + ["a payload straddling EOF", { offset: -8, size: 64, flags: 0 }], + [ + "a zero-fill section with an offset past EOF", + { offset: 0x10000000, size: 0, flags: 1 }, + ], +] as const)("handles Mach-O section %s", async (label, spec) => { + const size = 72 + 80; + const probe = machoHardeningFixture({ + extraCommands: [machoSegment(64, { commandSize: size })], + }); + const build = (offset: number) => { + const segment = machoSegment(64, { + commandSize: size, + sections: 1, + vmSize: probe.length, + fileSize: probe.length, + }); + segment.writeBigUInt64LE(BigInt(spec.size), 72 + 40); + segment.writeUInt32LE(offset, 72 + 48); + segment.writeUInt32LE(spec.flags, 72 + 64); + return machoHardeningFixture({ extraCommands: [segment] }); + }; + const bytes = build( + spec.offset < 0 ? probe.length + spec.offset : spec.offset, + ); + const message = "Mach-O section payload is outside the file"; + if (spec.flags === 1) { + // Zero-fill sections own no file bytes, so their offset is not a range. + const image = (await readMachoHardening(bytes))[0]; + expect(image?.status, label).toBe("parsed"); + return; + } + await expect(readMachoHardening(bytes)).rejects.toMatchObject({ + kind: "malformed", + message, + }); + const images = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes, cpuType: CPU_ARM64, cpuSubtype: 0 }, + { bytes: machoHardeningFixture(), cpuType: CPU_ARM64, cpuSubtype: 0 }, + ]), + ); + expect(images.map((image) => image.status)).toEqual(["malformed", "parsed"]); + expect(images[0]?.reason).toBe(message); + expect(images[0]?.rows).toEqual([]); +}); + +it("measures a segment file range against its slice, not the container", async () => { + // In a fat file this range lands inside the container but past its own slice. + const slice = machoHardeningFixture(); + const bad = machoHardeningFixture({ + extraCommands: [ + machoSegment(64, { + vmSize: 0x10000, + fileOffset: slice.length * 2, + fileSize: 16, + }), + ], + }); + const images = await readMachoHardening( + fatMachoHardeningFixture([ + { bytes: bad, cpuType: CPU_ARM64, cpuSubtype: 0 }, + { bytes: slice, cpuType: CPU_ARM64, cpuSubtype: 0 }, + ]), + ); + expect(images.map((image) => image.status)).toEqual(["malformed", "parsed"]); +}); + +it("accepts well-formed segments with sections and file payloads", async () => { + const size = 72 + 80; + const probe = machoHardeningFixture({ + extraCommands: [machoSegment(64, { commandSize: size })], + }); + const image = ( + await readMachoHardening( + machoHardeningFixture({ + extraCommands: [ + machoSegment(64, { + commandSize: size, + sections: 1, + vmSize: probe.length, + fileSize: probe.length, + }), + ], + }), + ) + )[0]; + expect(findRow(image?.rows ?? [], "restrict_segment").state).toBe("enabled"); +}); diff --git a/src/native/hardening/BinaryHardeningProvider.ts b/src/native/hardening/BinaryHardeningProvider.ts new file mode 100644 index 0000000000..707af54a82 --- /dev/null +++ b/src/native/hardening/BinaryHardeningProvider.ts @@ -0,0 +1,244 @@ +import { + projectStableArtifactFailure, + projectArtifactCloseFailure, +} from "../../artifacts/StableArtifactFailure.js"; +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, + assertArtifactReadActive, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import type { BinaryHardeningPort } from "../../application/binaryDiagnostics/BinaryHardeningPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; +import { + BINARY_HARDENING_LIMITATIONS, + MAX_INSPECT_BINARY_HARDENING_FILE_BYTES, + type BinaryHardening, + type InspectBinaryHardeningInput, +} from "../../domain/native/binaryHardening.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { err, ok } from "../../domain/result.js"; +import { BINARY_HARDENING_PROVIDER } from "../../domain/investigationProviders.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { readElfHardening } from "./ElfHardening.js"; +import { readMachoHardening } from "./MachoHardening.js"; +import { readPeHardening } from "./PeHardening.js"; + +const OPERATION = "inspect_binary_hardening"; + +/** Portable ELF, PE, and Mach-O hardening reader over one stable regular file. */ +export class BinaryHardeningProvider implements BinaryHardeningPort { + readonly identity = BINARY_HARDENING_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw projectArtifactCloseFailure( + cause, + BINARY_HARDENING_PROVIDER.id, + OPERATION, + ); + } + } + + async inspect( + input: InspectBinaryHardeningInput, + options?: ExecutionOptions, + ) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + assertArtifactReadActive(options?.signal); + const report = await readBinaryHardening( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + options?.signal, + ); + assertArtifactReadActive(options?.signal); + return ok(report); + }); + } catch (cause) { + return err(binaryHardeningFailure(cause, input)); + } + } +} + +/** Dispatch one stable buffer to the ELF, PE, or Mach-O hardening reader. */ +export const readBinaryHardening = async ( + bytes: Buffer, + artifact: BinaryHardening["artifact"], + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + if ( + bytes.length >= 4 && + bytes[0] === 0x7f && + bytes[1] === 0x45 && + bytes[2] === 0x4c && + bytes[3] === 0x46 + ) + return { + artifact, + format: "elf", + images: [readElfHardening(bytes, signal)], + limitations: [...BINARY_HARDENING_LIMITATIONS], + }; + if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) + return { + artifact, + format: "pe", + images: [await readPeHardening(bytes, artifact, signal)], + limitations: [...BINARY_HARDENING_LIMITATIONS], + }; + return { + artifact, + format: "mach-o", + images: await readMachoHardening(bytes, signal), + limitations: [...BINARY_HARDENING_LIMITATIONS], + }; +}; + +const fileSizeRemediation = (input: InspectBinaryHardeningInput): string => + input.max_file_bytes >= MAX_INSPECT_BINARY_HARDENING_FILE_BYTES + ? `The image exceeds the ${String(MAX_INSPECT_BINARY_HARDENING_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + +const binaryHardeningFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectBinaryHardeningInput, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof BinaryResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `Selected artifact ${input.path}: ${cause.message}`, + { boundary: cause.boundary, maximum: cause.maximum_bytes }, + { + cause, + ...fixedAllowanceOptions( + "The binary hardening structural allowance", + "parser", + ), + }, + ); + if (cause instanceof BinaryFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${input.path}: ${cause.message}`, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: fileSizeRemediation(input), + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ProviderAdapterError(BINARY_HARDENING_PROVIDER.id, OPERATION, { + cause, + diagnostics: { + path: input.path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const binaryHardeningFailure = ( + cause: unknown, + input: InspectBinaryHardeningInput, +): AnalysisError => + projectStableArtifactFailure( + cause, + binaryHardeningFailurePrimary(artifactFilePrimaryFacts(cause), input), + input.path, + BINARY_HARDENING_PROVIDER.id, + OPERATION, + ); diff --git a/src/native/hardening/ElfHardening.ts b/src/native/hardening/ElfHardening.ts new file mode 100644 index 0000000000..83cb446ba2 --- /dev/null +++ b/src/native/hardening/ElfHardening.ts @@ -0,0 +1,1277 @@ +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readElfDynamicEntry } from "../../domain/elfDynamicEntry.js"; +import { elfArchitecture } from "../../domain/elfFixedHeader.js"; +import { admitElfLoadSegment } from "../../domain/elfProgramHeader.js"; +import { readElfSymbol } from "../../domain/elfSymbol.js"; +import { + readElfGnuHashSymbolCount, + readElfHashSymbolCount, + type ElfGnuHashCountWalk, + type ElfHashCountFailure, + type ElfHashWordReader, +} from "../../domain/elfHashSymbolCount.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + type BinaryHardeningImage, + type BinaryHardeningRow, + ELF_HARDENING_PROPERTIES, + MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES, + MAX_BINARY_HARDENING_SYMBOL_SCAN_BYTES, + MAX_BINARY_HARDENING_TABLE_ENTRIES, +} from "../../domain/native/binaryHardening.js"; +import { elfNoteAlignment } from "../../domain/native/elfNoteLayout.js"; +import { MAX_INSPECT_ELF_NOTE_ENTRIES } from "../../domain/native/elfNotes.js"; +import { + elfStringTableText, + observeElfStringTableEntry, +} from "../../domain/elfStringTable.js"; +import { + type ElfNoteTableWalk, + type GnuPropertyWord, + readGnuPropertyWords, + walkElfNoteTables, +} from "../elf/ElfNoteParser.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { + ELF_PT_GNU_PROPERTY, + ELF_PT_GNU_RELRO, + ELF_PT_GNU_STACK, + ELF_PT_INTERP, + ELF_PT_NOTE, + assertElfDeclaredRangesFromBuffer, + readElfDirectoryFromBuffer, + type ElfProgramDeclaration, + type NativeSection, +} from "../metadata/NativeSectionDirectory.js"; + +const ET_EXEC = 2; +const ET_DYN = 3; +const SHT_SYMTAB = 2; +const SHT_NOTE = 7; +const SHT_DYNSYM = 11; +const DT_NULL = 0n; +const DT_HASH = 4n; +const DT_STRTAB = 5n; +const DT_SYMTAB = 6n; +const DT_STRSZ = 10n; +const DT_SYMENT = 11n; +const DT_RPATH = 15n; +const DT_RUNPATH = 29n; +const DT_FLAGS = 30n; +const DT_GNU_HASH = 0x6ffffef5n; +const DT_FLAGS_1 = 0x6ffffffbn; +const DT_BIND_NOW = 24n; +const DF_BIND_NOW = 0x8n; +const DF_1_NOW = 0x1n; +const DF_1_PIE = 0x08000000n; +const PF_X = 1; +const GNU_PROPERTY_AARCH64_FEATURE_1_AND = 0xc0000000; +const GNU_PROPERTY_X86_FEATURE_1_AND = 0xc0000002; +const SHF_COMPRESSED = 0x800n; +/** Tags hardening reads as a single value. Repeating tags such as DT_NEEDED stay. */ +const SINGULAR_DYNAMIC_TAGS = new Set([ + DT_HASH, + DT_STRTAB, + DT_SYMTAB, + DT_STRSZ, + DT_SYMENT, + DT_RPATH, + DT_RUNPATH, + DT_FLAGS, + DT_GNU_HASH, + DT_FLAGS_1, + DT_BIND_NOW, +]); +const EM_386 = 3; +const EM_PPC = 20; +const EM_PPC64 = 21; +const EM_ARM = 40; +const EM_X86_64 = 62; +const EM_AARCH64 = 183; +const EM_RISCV = 243; +/** Linux treats a missing PT_GNU_STACK as read-implies-exec on these machines. */ +const EXECUTABLE_STACK_DEFAULT = new Set([EM_386, EM_PPC, EM_ARM]); +const NON_EXECUTABLE_STACK_DEFAULT = new Set([ + EM_PPC64, + EM_X86_64, + EM_AARCH64, + EM_RISCV, +]); + +function malformed(message: string): never { + throw new BinaryFormatFailure("malformed", message); +} + +const exact = (value: bigint, label: string): number => { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) + malformed(`${label} exceeds exact host precision`); + return Number(value); +}; + +const budgetFailure = (count: number, label: string): BinaryResourceFailure => + new BinaryResourceFailure( + "structure", + MAX_BINARY_HARDENING_TABLE_ENTRIES, + `${label} has ${String(count)} entries, above the ${String(MAX_BINARY_HARDENING_TABLE_ENTRIES)}-entry structural budget. This release has no runtime override.`, + ); + +const budget = (count: number, label: string): void => { + if (count > MAX_BINARY_HARDENING_TABLE_ENTRIES) + throw budgetFailure(count, label); +}; + +type RowInput = { + readonly property: BinaryHardeningRow["property"]; + readonly state: BinaryHardeningRow["state"]; + readonly confidence: BinaryHardeningRow["confidence"]; + readonly basis: string; + readonly location?: BinaryHardeningRow["location"]; + readonly note?: string | null; +}; + +const row = ({ + property, + state, + confidence, + basis, + location = null, + note = null, +}: RowInput): BinaryHardeningRow => ({ + property, + state, + confidence, + basis, + location, + note, + next_probe: null, +}); + +type LinkedProperty = "nx" | "relro" | "rpath" | "runpath"; +type SpanProgram = Exclude; +type FileMapping = { + readonly offset: number; + /** File-backed bytes left in the owning PT_LOAD from this address. */ + readonly backed: bigint; + /** Bytes before another PT_LOAD maps the same addresses again. */ + readonly available: bigint; +}; +type MapVa = (va: bigint, size: bigint) => FileMapping; + +const isSpan = (program: ElfProgramDeclaration): program is SpanProgram => + program.type !== 2; + +/** Read ELF hardening from headers, program headers, notes, and dynamic symbols. */ +export const readElfHardening = ( + bytes: Buffer, + signal?: AbortSignal, +): BinaryHardeningImage => { + assertArtifactReadActive(signal); + const directory = readElfDirectoryFromBuffer(bytes); + // A report needs every declared payload, not only the tables that name them. + assertElfDeclaredRangesFromBuffer(bytes); + const { bits, little, machine, programs, sections } = directory; + const headerWidth = bits === 64 ? 56 : 32; + const symbolWidth = bits === 64 ? 24 : 16; + const dynamicWidth = bits === 64 ? 16 : 8; + const propertyAlign = bits === 64 ? 8 : 4; + const loads = programs.filter(isSpan).filter((program) => program.type === 1); + for (const load of loads) { + const admitted = admitElfLoadSegment( + { + virtualAddress: load.virtual_address, + memorySize: load.memory_size, + fileSize: load.file_size, + }, + bits, + ); + if (!admitted.ok) malformed(admitted.error.message); + } + const mapVa: MapVa = (va, size) => mapVirtualAddress(loads, va, size); + const one = ( + type: SpanProgram["type"], + label: string, + ): SpanProgram | null => { + const found = programs.filter( + (program): program is SpanProgram => program.type === type, + ); + if (found.length > 1) malformed(`ELF image has more than one ${label}`); + return found[0] ?? null; + }; + const dynamicPrograms = programs.filter((program) => program.type === 2); + if (dynamicPrograms.length > 1) + malformed("ELF image has more than one PT_DYNAMIC"); + const interp = one(ELF_PT_INTERP, "PT_INTERP"); + if (interp !== null) admitInterp(bytes, interp); + const stack = one(ELF_PT_GNU_STACK, "PT_GNU_STACK"); + const relro = one(ELF_PT_GNU_RELRO, "PT_GNU_RELRO"); + const propertyProgram = one(ELF_PT_GNU_PROPERTY, "PT_GNU_PROPERTY"); + const dynamic = + dynamicPrograms[0] === undefined + ? null + : readDynamic( + bytes, + dynamicPrograms[0], + bits, + little, + dynamicWidth, + mapVa, + ); + let symbols: SymbolScan; + try { + symbols = readSymbols( + bytes, + sections, + dynamic, + little, + mapVa, + bits, + signal, + ); + } catch (cause) { + // One import table above the entry budget must not drop PIE, NX, or RELRO. + // The import rows stay unknown. + if (!(cause instanceof BinaryResourceFailure)) throw cause; + symbols = { state: "unresolved", reason: cause.message, names: [] }; + } + const features = readFeatureNotes( + bytes, + sections, + propertyProgram, + programs.filter( + (program): program is SpanProgram => program.type === ELF_PT_NOTE, + ), + little, + propertyAlign, + signal, + ); + const linkedRow = ( + property: LinkedProperty, + build: () => BinaryHardeningRow, + ): BinaryHardeningRow => + directory.elf_type !== ET_EXEC && directory.elf_type !== ET_DYN + ? row({ + property, + state: "not_applicable", + confidence: "observed", + basis: `e_type ${String(directory.elf_type)} is neither ET_EXEC nor ET_DYN, so this linked-image property does not apply.`, + location: { offset: 16, bytes: 2 }, + }) + : programs.length === 0 + ? unreadProgramsRow(property) + : build(); + const rows = [ + pieRow(directory.elf_type, interp, dynamic, headerWidth, programs.length), + linkedRow("nx", () => nxRow(stack, machine, bits)), + linkedRow("relro", () => relroRow(relro, dynamic, headerWidth)), + symbolRow( + "stack_protector", + symbols, + (name) => + name === "__stack_chk_fail" || name === "__stack_chk_fail_local", + "No __stack_chk_fail symbol was found in the dynamic symbol table. A static or stripped image can omit that import while still using a stack protector.", + "The dynamic symbol table is absent, so stack protector use is unknown.", + ), + symbolRow( + "fortify", + symbols, + (name) => + name === "__fortify_fail" || + (name.startsWith("__") && name.endsWith("_chk")), + "No fortify symbol was found in the dynamic symbol table. A static or stripped image can omit those imports while still using FORTIFY.", + "The dynamic symbol table is absent, so FORTIFY use is unknown.", + ), + featureRow( + "cet_ibt_shstk", + machine === EM_386 || machine === EM_X86_64, + features.words.get(GNU_PROPERTY_X86_FEATURE_1_AND) ?? null, + "CET IBT and SHSTK", + features, + ), + featureRow( + "aarch64_bti_pac", + machine === EM_AARCH64, + features.words.get(GNU_PROPERTY_AARCH64_FEATURE_1_AND) ?? null, + "AArch64 BTI and PAC", + features, + ), + linkedRow("rpath", () => pathRow("rpath", "DT_RPATH", dynamic)), + linkedRow("runpath", () => pathRow("runpath", "DT_RUNPATH", dynamic)), + strippedRow(sections, bits), + ]; + if ( + rows.map(({ property }) => property).join() !== + ELF_HARDENING_PROPERTIES.join() + ) + malformed("ELF hardening rows are incomplete"); + return { + format: "elf", + status: "parsed", + reason: null, + architecture: elfArchitecture(machine) ?? null, + bits, + byte_order: little ? "little" : "big", + machine, + slice: null, + rows, + }; +}; + +/** PIE reads this header's presence, so it must name a loadable path. */ +const admitInterp = (bytes: Buffer, interp: SpanProgram): void => { + const end = exact(interp.file_offset + interp.file_size, "PT_INTERP range"); + const start = exact(interp.file_offset, "PT_INTERP range"); + if (interp.file_size < 2n || bytes[start] === 0 || bytes[end - 1] !== 0) + malformed("ELF PT_INTERP is not a nonempty NUL-terminated path"); +}; + +const mapVirtualAddress = ( + loads: readonly SpanProgram[], + va: bigint, + size: bigint, +): FileMapping => { + const matches: { + readonly load: SpanProgram; + readonly offset: number; + readonly backed: bigint; + }[] = []; + for (const load of loads) { + if ( + va < load.virtual_address || + va >= load.virtual_address + load.memory_size + ) + continue; + const delta = va - load.virtual_address; + if (delta >= load.file_size || size > load.file_size - delta) + malformed("ELF virtual address range is not file-backed in its PT_LOAD"); + matches.push({ + load, + offset: exact(load.file_offset + delta, "ELF virtual address"), + backed: load.file_size - delta, + }); + } + // Loads with the same address-to-file translation name the same bytes, so + // they are one interpretation rather than a conflict. + const first = matches[0]; + if ( + first === undefined || + matches.some(({ offset }) => offset !== first.offset) + ) + malformed("ELF virtual address does not map through exactly one PT_LOAD"); + const { load: owner, offset, backed } = first; + const translation = owner.file_offset - owner.virtual_address; + // Another PT_LOAD inside the span maps those addresses again. Different file + // bytes, or a zero-filled tail of an equivalent load, change their + // interpretation, so the whole range must end before that point. + let available = backed; + for (const load of loads) { + if (load === owner || load.memory_size === 0n) continue; + const equivalent = load.file_offset - load.virtual_address === translation; + if (equivalent && load.memory_size <= load.file_size) continue; + const start = + (equivalent + ? load.virtual_address + load.file_size + : load.virtual_address) - va; + if (start > 0n && start < available) available = start; + } + if (size > available) + malformed("ELF virtual address range crosses another PT_LOAD mapping"); + return { offset, backed, available }; +}; + +type DynamicEntry = { + readonly value: bigint; + readonly offset: number; + readonly bytes: number; +}; + +type DynamicTable = { + readonly flags: bigint; + readonly flags1: bigint; + readonly entries: ReadonlyMap; + readonly strings: Buffer | null; + readonly stringFileOffset: number | null; +}; + +const readDynamic = ( + bytes: Buffer, + program: Extract, + bits: 32 | 64, + little: boolean, + width: number, + mapVa: MapVa, +): DynamicTable => { + const offset = exact(program.offset, "PT_DYNAMIC offset"); + const size = exact(program.size, "PT_DYNAMIC size"); + if (size % width !== 0) + malformed("ELF PT_DYNAMIC size is not a whole number of entries"); + const count = size / width; + budget(count, "ELF dynamic table"); + if (size > bytes.length - offset) + malformed("ELF PT_DYNAMIC extends outside the file"); + const entries = new Map(); + let terminated = false; + for (let index = 0; index < count; index += 1) { + const at = offset + index * width; + const parsed = readElfDynamicEntry( + bytes.subarray(at, at + width), + bits, + little, + ); + if (!parsed.ok) malformed(parsed.error.message); + if (parsed.value.tag === DT_NULL) { + terminated = true; + break; + } + if (!SINGULAR_DYNAMIC_TAGS.has(parsed.value.tag)) continue; + if (entries.has(parsed.value.tag)) + malformed(`ELF dynamic tag ${parsed.value.tag.toString()} is duplicated`); + entries.set(parsed.value.tag, { + value: parsed.value.value, + offset: at, + bytes: width, + }); + } + if (!terminated) malformed("ELF dynamic table has no DT_NULL terminator"); + const strtab = entries.get(DT_STRTAB); + const strsz = entries.get(DT_STRSZ); + if ((strtab === undefined) !== (strsz === undefined)) + malformed("ELF dynamic string table is missing DT_STRTAB or DT_STRSZ"); + let strings: Buffer | null = null; + let stringFileOffset: number | null = null; + if (strtab !== undefined && strsz !== undefined) { + const stringSize = exact(strsz.value, "DT_STRSZ"); + stringFileOffset = mapVa(strtab.value, strsz.value).offset; + if (stringSize > bytes.length - stringFileOffset) + malformed("ELF dynamic string table extends outside the file"); + strings = bytes.subarray(stringFileOffset, stringFileOffset + stringSize); + } + return { + flags: entries.get(DT_FLAGS)?.value ?? 0n, + flags1: entries.get(DT_FLAGS_1)?.value ?? 0n, + entries, + strings, + stringFileOffset, + }; +}; + +type SymbolName = { + readonly name: string; + readonly offset: number; + readonly bytes: number; +}; +/** `unresolved` keeps the names read so far. A sign among them still counts. */ +type SymbolScan = + | { + readonly state: "absent" | "scanned"; + readonly names: readonly SymbolName[]; + } + | { + readonly state: "unresolved"; + readonly reason: string; + readonly names: readonly SymbolName[]; + }; + +/** + * The loader reads DT_SYMTAB and DT_STRTAB, so section headers cannot declare + * imports. An SHT_DYNSYM section only supplies a count DT_HASH and DT_GNU_HASH + * leave open, and only while it names the same tables. + */ +const readSymbols = ( + bytes: Buffer, + sections: readonly NativeSection[], + dynamic: DynamicTable | null, + little: boolean, + mapVa: MapVa, + bits: 32 | 64, + signal?: AbortSignal, +): SymbolScan => { + const dynsym = sections.filter((section) => section.type === SHT_DYNSYM); + if (dynsym.length > 1) malformed("ELF image has more than one SHT_DYNSYM"); + const section = dynsym[0]; + const symtab = dynamic?.entries.get(DT_SYMTAB); + if (dynamic === null || symtab === undefined) + return section === undefined + ? { state: "absent", names: [] } + : { + state: "unresolved", + reason: + "An SHT_DYNSYM section exists, but no DT_SYMTAB declares it, so its names are not established as imports.", + names: [], + }; + const width = bits === 64 ? 24 : 16; + const syment = dynamic.entries.get(DT_SYMENT); + if (syment !== undefined && syment.value !== BigInt(width)) + malformed("ELF DT_SYMENT does not match the ELF class symbol width"); + if (dynamic.strings === null || dynamic.stringFileOffset === null) + malformed("ELF DT_SYMTAB is present without a dynamic string table"); + const count = + symbolCount(bytes, dynamic, bits, little, mapVa, signal) ?? + agreeingSectionCount(sections, section, dynamic, symtab, width, mapVa); + if (count === null) + return { + state: "unresolved", + reason: dynamic.entries.has(DT_GNU_HASH) + ? "DT_GNU_HASH has no hashed symbols and no agreeing SHT_DYNSYM section gives the count, so the dynamic symbol table could not be read." + : "Neither DT_HASH nor DT_GNU_HASH is present and no agreeing SHT_DYNSYM section gives the count, so the dynamic symbol table could not be read.", + names: [], + }; + budget(count, "ELF dynamic symbol table"); + const base = mapVa(symtab.value, BigInt(count * width)).offset; + if (count * width > bytes.length - base) + malformed("ELF dynamic symbol table extends outside the file"); + return readSymbolNames( + bytes, + base, + count, + bits, + little, + dynamic.strings, + dynamic.stringFileOffset, + ); +}; + +/** The SHT_DYNSYM count, or null unless it names the dynamic tables' bytes. */ +const agreeingSectionCount = ( + sections: readonly NativeSection[], + dynsym: NativeSection | undefined, + dynamic: DynamicTable, + symtab: DynamicEntry, + width: number, + mapVa: MapVa, +): number | null => { + if (dynsym === undefined) return null; + const strings = sections[dynsym.link]; + if ( + dynsym.address !== symtab.value || + strings?.address !== dynamic.entries.get(DT_STRTAB)?.value || + strings?.size !== dynamic.entries.get(DT_STRSZ)?.value || + strings?.offset !== BigInt(dynamic.stringFileOffset ?? -1) + ) + return null; + const entry = dynsym.entry_size === 0n ? BigInt(width) : dynsym.entry_size; + if (entry !== BigInt(width)) + malformed("ELF SHT_DYNSYM entry size does not match the ELF class"); + const size = exact(dynsym.size, "SHT_DYNSYM size"); + if (size % width !== 0) + malformed("ELF SHT_DYNSYM size is not a whole number of symbols"); + const count = size / width; + budget(count, "ELF dynamic symbol table"); + return mapVa(symtab.value, dynsym.size).offset === + exact(dynsym.offset, "SHT_DYNSYM offset") + ? count + : null; +}; + +const STB_LOCAL = 0; + +const readSymbolNames = ( + bytes: Buffer, + offset: number, + count: number, + bits: 32 | 64, + little: boolean, + strings: Buffer, + stringFileOffset: number, +): SymbolScan => { + const width = bits === 64 ? 24 : 16; + const names: SymbolName[] = []; + let scanned = 0; + let unterminated = false; + for (let index = 0; index < count; index += 1) { + const at = offset + index * width; + const symbol = readElfSymbol(bytes.subarray(at, at + width), bits, little); + if (!symbol.ok) malformed(symbol.error.message); + const nameOffset = symbol.value.name_offset; + if (nameOffset === 0) continue; + // SHN_UNDEF is an import. A defined export of the same name is not a sign + // that this image references the protector. + if (symbol.value.section_index !== 0) continue; + // An STB_LOCAL symbol is never resolved against another object, so it + // does not establish an external import. + if (symbol.value.binding === STB_LOCAL) continue; + const observed = observeElfStringTableEntry( + strings, + nameOffset, + MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES, + ); + scanned += observed.examinedBytes; + if (scanned > MAX_BINARY_HARDENING_SYMBOL_SCAN_BYTES) + return { + state: "unresolved", + reason: `ELF symbol name scans examined more than the ${String(MAX_BINARY_HARDENING_SYMBOL_SCAN_BYTES)}-byte structural budget, so this import sign is unknown.`, + names, + }; + if (observed.state === "outside") + malformed("ELF dynamic symbol name offset is outside the string table"); + // A name past the window is unresolved. Later symbols can still carry the sign. + if (observed.state === "truncated") { + unterminated = true; + continue; + } + names.push({ + name: elfStringTableText(observed) ?? "", + offset: stringFileOffset + nameOffset, + bytes: observed.contentBytes, + }); + } + return unterminated + ? { + state: "unresolved", + reason: `An undefined dynamic symbol name has no NUL within the string table or the ${String(MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES)}-byte name budget, so this import sign is unknown.`, + names, + } + : { state: "scanned", names }; +}; + +const hashWords = ( + bytes: Buffer, + va: bigint, + mapVa: MapVa, + little: boolean, +): ElfHashWordReader => { + const mapping = mapVa(va, 4n); + return (relative) => { + // Hash readers may jump over bloom filters or chains. Validate the whole + // prefix, including skipped bytes, in the same file-backed mapping. + const end = BigInt(relative + 4); + if (end > mapping.backed) return err("is not file-backed in its PT_LOAD"); + if (end > mapping.available) return err("crosses another PT_LOAD mapping"); + const at = mapping.offset + relative; + if (at > bytes.length - 4) return err("extends outside the file"); + return ok(little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at)); + }; +}; + +const finishGnuHashCount = ( + walk: ElfGnuHashCountWalk, + signal: AbortSignal | undefined, +): Result => { + for (let step = walk.next(); ; step = walk.next()) { + if (step.done === true) return step.value; + assertArtifactReadActive(signal); + } +}; + +const admitHashCount = ( + counted: Result, +): number | null => { + if (counted.ok) return counted.value; + if (counted.error.kind === "budget") + throw budgetFailure(counted.error.count, `ELF ${counted.error.label}`); + return malformed(`ELF ${counted.error.message}`); +}; + +const symbolCount = ( + bytes: Buffer, + dynamic: DynamicTable, + bits: 32 | 64, + little: boolean, + mapVa: MapVa, + signal?: AbortSignal, +): number | null => { + const hash = dynamic.entries.get(DT_HASH); + const gnu = dynamic.entries.get(DT_GNU_HASH); + const classic = + hash === undefined + ? null + : admitHashCount( + readElfHashSymbolCount( + hashWords(bytes, hash.value, mapVa, little), + MAX_BINARY_HARDENING_TABLE_ENTRIES, + ), + ); + const gnuCount = + gnu === undefined + ? null + : admitHashCount( + finishGnuHashCount( + readElfGnuHashSymbolCount( + hashWords(bytes, gnu.value, mapVa, little), + bits === 64 ? 8 : 4, + MAX_BINARY_HARDENING_TABLE_ENTRIES, + ), + signal, + ), + ); + if (classic !== null && gnuCount !== null && classic !== gnuCount) + malformed("ELF DT_HASH and DT_GNU_HASH report different symbol counts"); + return classic ?? gnuCount; +}; + +interface FeatureNotes { + readonly words: Map; + /** True when section headers or PT_GNU_PROPERTY were available to search. */ + readonly searched: boolean; + /** Why the search stopped short, so an absent word is not an absent feature. */ + readonly incomplete: string | null; +} + +const noteTable = ( + bytes: Buffer, + offset: number, + size: number, + alignment: number | bigint, + label: string, +): ElfNoteTableWalk => { + if (size > bytes.length - offset) + malformed(`ELF ${label} extends outside the file`); + return { offset, size, alignment }; +}; + +type NoteSpan = { readonly offset: number; readonly size: number }; + +const byOffset = (spans: readonly T[]): T[] => + spans.filter(({ size }) => size > 0).toSorted((a, b) => a.offset - b.offset); + +/** + * Overlap queries over spans sorted once. A section table can hold hundreds of + * thousands of headers, so a query must not rescan the spans. + */ +const overlapIndex = (spans: readonly NoteSpan[]) => { + const sorted: NoteSpan[] = []; + for (const span of byOffset(spans)) { + const previous = sorted.at(-1); + if ( + previous !== undefined && + span.offset <= previous.offset + previous.size + ) + sorted[sorted.length - 1] = { + offset: previous.offset, + size: Math.max( + previous.size, + span.offset + span.size - previous.offset, + ), + }; + else sorted.push(span); + } + return ({ offset, size }: NoteSpan): "none" | "covered" | "partial" => { + if (size <= 0) return "none"; + let low = 0; + let high = sorted.length; + while (low < high) { + const middle = (low + high) >>> 1; + if ((sorted[middle]?.offset ?? 0) < offset + size) low = middle + 1; + else high = middle; + } + const span = sorted[low - 1]; + if (span === undefined || span.offset + span.size <= offset) return "none"; + return span.offset <= offset && span.offset + span.size >= offset + size + ? "covered" + : "partial"; + }; +}; + +type NoteOverlap = "uncovered" | "cut" | "framing"; + +/** Effective alignment at a table boundary, or `null` when tables there disagree. */ +const recordBoundary = ( + boundaries: Map, + at: number, + alignment: number, +): void => { + const known = boundaries.get(at); + boundaries.set( + at, + known === undefined || known === alignment ? alignment : null, + ); +}; + +/** + * Offsets inside the accepted streams where the effective note alignment + * changes. A candidate that spans one of them cannot frame every note the way + * the accepted tables do. + */ +const alignmentChanges = (accepted: readonly ElfNoteTableWalk[]): number[] => { + const changes: number[] = []; + let previous: number | null = null; + for (const table of byOffset(accepted)) { + const alignment = elfNoteAlignment(table.alignment); + if (previous !== null && previous !== alignment) changes.push(table.offset); + previous = alignment; + } + return changes; +}; + +const changesInside = ( + changes: readonly number[], + offset: number, + end: number, +): boolean => { + let low = 0; + let high = changes.length; + while (low < high) { + const middle = (low + high) >>> 1; + if ((changes[middle] ?? 0) <= offset) low = middle + 1; + else high = middle; + } + return (changes[low] ?? end) < end; +}; + +/** + * A section and a program header often name the same bytes, and walking both + * charges every note twice. Earlier tiers win, and the lowest offset wins + * within a tier. Matching endpoints establish an alias only when both tables + * frame notes with the same effective alignment. + */ +const admitDisjointTables = ( + accepted: readonly ElfNoteTableWalk[], + candidates: readonly ElfNoteTableWalk[], + unresolvedOverlap: (overlap: NoteOverlap) => void, +): ElfNoteTableWalk[] => { + const overlapsAccepted = overlapIndex(accepted); + const changes = alignmentChanges(accepted); + const admitted = [...accepted]; + // A table's first byte and its end are note boundaries of the stream walked + // from it. A byte inside a table need not be: a descriptor can hold bytes + // that parse as a complete note. + const starts = new Map(); + const ends = new Map(); + for (const { offset, size, alignment } of accepted) { + recordBoundary(starts, offset, elfNoteAlignment(alignment)); + recordBoundary(ends, offset + size, elfNoteAlignment(alignment)); + } + let end = -1; + for (const candidate of byOffset(candidates)) { + const candidateEnd = candidate.offset + candidate.size; + const alignment = elfNoteAlignment(candidate.alignment); + const overlap = overlapsAccepted(candidate); + if (overlap === "none" && candidate.offset >= end) { + admitted.push(candidate); + recordBoundary(starts, candidate.offset, alignment); + recordBoundary(ends, candidateEnd, alignment); + end = candidateEnd; + continue; + } + // Only an alias that starts and ends on admitted table boundaries walks + // the notes already walked. A cut elsewhere need not be a note boundary; + // do not interpret it as an independent stream, and do not claim that the + // notes it frames are absent. + const covered = + overlap === "covered" || (overlap === "none" && candidateEnd <= end); + if (!covered) unresolvedOverlap("uncovered"); + else if (!starts.has(candidate.offset) || !ends.has(candidateEnd)) + unresolvedOverlap("cut"); + // The same span walked with another alignment can frame different notes. + else if ( + starts.get(candidate.offset) !== alignment || + ends.get(candidateEnd) !== alignment || + (overlap === "covered" && + changesInside(changes, candidate.offset, candidateEnd)) + ) + unresolvedOverlap("framing"); + } + return admitted; +}; + +const readFeatureNotes = ( + bytes: Buffer, + sections: readonly NativeSection[], + program: SpanProgram | null, + notePrograms: readonly SpanProgram[], + little: boolean, + propertyAlign: number, + signal?: AbortSignal, +): FeatureNotes => { + assertArtifactReadActive(signal); + const searched = sections.length > 0 || program !== null; + const propertyProgram = + program === null + ? [] + : [ + noteTable( + bytes, + exact(program.file_offset, "PT_GNU_PROPERTY offset"), + exact(program.file_size, "PT_GNU_PROPERTY size"), + program.alignment, + "PT_GNU_PROPERTY", + ), + ]; + const propertySections: ElfNoteTableWalk[] = []; + const otherSpans: NoteSpan[] = []; + let incomplete: string | null = null; + for (const section of sections) { + if (section.type !== SHT_NOTE) continue; + // An empty note section owns no bytes, so it has nothing to walk. + if (section.size === 0n) continue; + const compressed = (section.flags & SHF_COMPRESSED) !== 0n; + if (compressed) + incomplete ??= + "A compressed SHT_NOTE section may hold GNU property notes, and its descriptors were not read."; + if (compressed) { + if (!section.file_backed) continue; + otherSpans.push({ + offset: exact(section.offset, "SHT_NOTE offset"), + size: exact(section.size, "SHT_NOTE size"), + }); + continue; + } + if (!section.file_backed) malformed("ELF SHT_NOTE is not file-backed"); + propertySections.push( + noteTable( + bytes, + exact(section.offset, "SHT_NOTE offset"), + exact(section.size, "SHT_NOTE size"), + section.alignment, + "SHT_NOTE", + ), + ); + } + const overlapsProperty = overlapIndex([ + ...propertyProgram, + ...propertySections, + ]); + const overlapsOther = overlapIndex(otherSpans); + const programTables: ElfNoteTableWalk[] = []; + for (const noteProgram of notePrograms) { + const span = { + offset: exact(noteProgram.file_offset, "PT_NOTE offset"), + size: exact(noteProgram.file_size, "PT_NOTE size"), + }; + const property = overlapsProperty(span) !== "none"; + // Do not reinterpret compressed section bytes through a program alias. + if (overlapsOther(span) !== "none" && !property) continue; + programTables.push( + noteTable( + bytes, + span.offset, + span.size, + noteProgram.alignment, + "PT_NOTE", + ), + ); + } + const unresolvedOverlap = (overlap: NoteOverlap): void => { + incomplete ??= { + cut: "An ELF note table starts or ends inside another note table, so its notes may not be notes of the covering stream.", + framing: + "ELF note tables name the same bytes with different note alignments, so they may frame different notes.", + uncovered: + "Overlapping ELF note tables leave uncovered bytes whose note boundaries were not resolved.", + }[overlap]; + }; + const tables = admitDisjointTables( + admitDisjointTables( + byOffset(propertyProgram), + propertySections, + unresolvedOverlap, + ), + programTables, + unresolvedOverlap, + ); + try { + return { + words: readGnuPropertyWords( + walkElfNoteTables( + bytes, + little, + tables, + MAX_INSPECT_ELF_NOTE_ENTRIES, + signal, + ), + little, + propertyAlign, + ), + searched, + incomplete, + }; + } catch (cause) { + if (!(cause instanceof ArtifactReaderFailure)) throw cause; + if (cause.reason === "cancelled") throw cause; + // A note budget must not drop PIE, NX, or RELRO. The walker names the + // budget it hit, entry or text, and the feature rows stay unknown. + if (cause.reason === "limit") + return { words: new Map(), searched, incomplete: cause.message }; + throw new BinaryFormatFailure("malformed", cause.message); + } +}; + +const unreadProgramsRow = ( + property: LinkedProperty | "pie", +): BinaryHardeningRow => + row({ + property, + state: "unknown", + confidence: "observed", + basis: "The image declares no program headers this inspection reads.", + note: "Missing program headers do not establish that this property is absent.", + }); + +const pieRow = ( + elfType: number, + interp: SpanProgram | null, + dynamic: DynamicTable | null, + headerWidth: number, + programCount: number, +): BinaryHardeningRow => { + const pieFlag = dynamic?.entries.get(DT_FLAGS_1); + const pie = pieFlag !== undefined && (pieFlag.value & DF_1_PIE) !== 0n; + if (elfType === ET_EXEC) + return row({ + property: "pie", + state: "disabled", + confidence: "observed", + basis: "e_type is ET_EXEC.", + location: { offset: 16, bytes: 2 }, + }); + if (elfType === ET_DYN && (interp !== null || pie)) + return row({ + property: "pie", + state: "enabled", + confidence: "derived", + basis: pie + ? "e_type is ET_DYN and DF_1_PIE is set." + : "e_type is ET_DYN and PT_INTERP is present.", + location: + pie && pieFlag !== undefined + ? { offset: pieFlag.offset, bytes: pieFlag.bytes } + : interp === null + ? { offset: 16, bytes: 2 } + : { offset: interp.header_offset, bytes: headerWidth }, + }); + if (elfType === ET_DYN && programCount === 0) return unreadProgramsRow("pie"); + if (elfType === ET_DYN) + return row({ + property: "pie", + state: "not_applicable", + confidence: "observed", + basis: + "e_type is ET_DYN without PT_INTERP or DF_1_PIE, so this is a shared object rather than a PIE executable.", + location: { offset: 16, bytes: 2 }, + }); + return row({ + property: "pie", + state: "not_applicable", + confidence: "observed", + basis: `e_type ${String(elfType)} is neither ET_EXEC nor ET_DYN.`, + location: { offset: 16, bytes: 2 }, + }); +}; + +const nxRow = ( + stack: SpanProgram | null, + machine: number, + bits: 32 | 64, +): BinaryHardeningRow => { + if (stack === null) + return row({ + property: "nx", + state: "unknown", + confidence: "observed", + basis: "PT_GNU_STACK is absent.", + note: EXECUTABLE_STACK_DEFAULT.has(machine) + ? "PT_GNU_STACK is absent. Linux treats that as read-implies-exec on this architecture, so the usual default is an executable stack. This inspection does not apply that default." + : NON_EXECUTABLE_STACK_DEFAULT.has(machine) + ? "PT_GNU_STACK is absent. The usual Linux default for this architecture is a non-executable stack. This inspection does not apply that default." + : "PT_GNU_STACK is absent. This inspection does not apply an architecture default.", + }); + const executable = (stack.flags & PF_X) !== 0; + return row({ + property: "nx", + state: executable ? "disabled" : "enabled", + confidence: "observed", + basis: executable + ? "PT_GNU_STACK p_flags includes PF_X." + : "PT_GNU_STACK p_flags does not include PF_X.", + location: { + offset: stack.header_offset + (bits === 64 ? 4 : 24), + bytes: 4, + }, + }); +}; + +const relroRow = ( + relro: SpanProgram | null, + dynamic: DynamicTable | null, + headerWidth: number, +): BinaryHardeningRow => { + if (relro === null) + return row({ + property: "relro", + state: "disabled", + confidence: "observed", + basis: "PT_GNU_RELRO is absent.", + }); + const bindNow = + dynamic !== null && + (dynamic.entries.has(DT_BIND_NOW) || + (dynamic.flags & DF_BIND_NOW) !== 0n || + (dynamic.flags1 & DF_1_NOW) !== 0n); + return row({ + property: "relro", + state: bindNow ? "enabled" : "partial", + confidence: "derived", + basis: bindNow + ? "PT_GNU_RELRO is present and BIND_NOW is set." + : "PT_GNU_RELRO is present and BIND_NOW is not set.", + location: { offset: relro.header_offset, bytes: headerWidth }, + }); +}; + +const symbolRow = ( + property: "stack_protector" | "fortify", + symbols: SymbolScan, + match: (name: string) => boolean, + missing: string, + absent: string, +): BinaryHardeningRow => { + const found = symbols.names.find(({ name }) => match(name)); + if (found !== undefined) + return row({ + property, + state: "enabled", + confidence: "observed", + basis: `Undefined dynamic symbol ${found.name} is present.`, + location: { offset: found.offset, bytes: found.bytes }, + }); + const reason = + symbols.state === "unresolved" + ? symbols.reason + : symbols.state === "absent" + ? absent + : missing; + return row({ + property, + state: "unknown", + confidence: "observed", + basis: reason, + note: reason, + }); +}; + +const featureRow = ( + property: "cet_ibt_shstk" | "aarch64_bti_pac", + applicable: boolean, + feature: GnuPropertyWord | null, + label: string, + { searched, incomplete }: FeatureNotes, +): BinaryHardeningRow => { + if (!applicable) + return row({ + property, + state: "not_applicable", + confidence: "observed", + basis: `${label} notes apply to a different architecture.`, + }); + if (incomplete !== null) + return row({ + property, + state: "unknown", + confidence: "observed", + basis: `${label} GNU property notes were not fully read.`, + note: incomplete, + }); + if (feature?.conflict === true) + return row({ + property, + state: "unknown", + confidence: "observed", + basis: `${label} GNU property notes disagree.`, + location: { offset: feature.offset, bytes: 4 }, + }); + if (feature === null && !searched) + return row({ + property, + state: "unknown", + confidence: "observed", + basis: `${label} GNU property note is unknown because section headers and PT_GNU_PROPERTY are absent.`, + note: `${label} GNU property note is unknown because section headers and PT_GNU_PROPERTY are absent.`, + }); + if (feature === null || (feature.value & 0b11) === 0) + return row({ + property, + state: "disabled", + confidence: "observed", + basis: + feature === null + ? `${label} GNU property note is absent. These bits are opt-in.` + : `${label} GNU property word has neither feature bit set.`, + ...(feature === null + ? {} + : { location: { offset: feature.offset, bytes: 4 } }), + }); + const both = (feature.value & 0b11) === 0b11; + return row({ + property, + state: both ? "enabled" : "partial", + confidence: "derived", + basis: `${label} GNU property word is 0x${feature.value.toString(16)}.`, + location: { offset: feature.offset, bytes: 4 }, + }); +}; + +const pathRow = ( + property: "rpath" | "runpath", + tag: "DT_RPATH" | "DT_RUNPATH", + dynamic: DynamicTable | null, +): BinaryHardeningRow => { + const entry = dynamic?.entries.get( + tag === "DT_RPATH" ? DT_RPATH : DT_RUNPATH, + ); + if (entry === undefined || dynamic === null) + return row({ + property, + state: "disabled", + confidence: "observed", + basis: `${tag} is absent.`, + }); + if (dynamic.strings === null) + malformed(`${tag} is present without a dynamic string table`); + const stringOffset = exact(entry.value, tag); + const observed = observeElfStringTableEntry( + dynamic.strings, + stringOffset, + MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES, + ); + if (observed.state === "outside") + malformed(`${tag} string offset is outside the dynamic string table`); + if (observed.state !== "terminated") + return row({ + property, + state: "unknown", + confidence: "observed", + basis: `${tag} is present, but its string is not a terminated name within the symbol-name budget.`, + note: `${tag} string has no NUL within the string table or the ${String(MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES)}-byte name budget.`, + }); + const text = elfStringTableText(observed) ?? ""; + return row({ + property, + state: "enabled", + confidence: "observed", + basis: `${tag} is present.`, + location: { offset: entry.offset, bytes: entry.bytes }, + note: text.length === 0 ? `${tag} value is an empty string.` : text, + }); +}; + +const strippedRow = ( + sections: readonly NativeSection[], + bits: 32 | 64, +): BinaryHardeningRow => { + if (sections.length === 0) + return row({ + property: "stripped", + state: "unknown", + confidence: "observed", + basis: "Section headers are absent, so SHT_SYMTAB presence is unknown.", + note: "A missing section header table does not establish that the image was stripped.", + }); + const symtab = sections.find((section) => section.type === SHT_SYMTAB); + if (symtab === undefined) + return row({ + property: "stripped", + state: "enabled", + confidence: "observed", + basis: "Section headers are present and none has type SHT_SYMTAB.", + }); + return row({ + property: "stripped", + state: "disabled", + confidence: "observed", + basis: "A section header has type SHT_SYMTAB.", + location: { offset: symtab.header_offset, bytes: bits === 64 ? 64 : 40 }, + }); +}; diff --git a/src/native/hardening/MachoHardening.ts b/src/native/hardening/MachoHardening.ts new file mode 100644 index 0000000000..08eba9e373 --- /dev/null +++ b/src/native/hardening/MachoHardening.ts @@ -0,0 +1,645 @@ +import { validateFatSliceIdentity } from "../../domain/apple/machoContainer.js"; +import { machoArchitectureName } from "../../domain/apple/machoCpuIdentity.js"; +import { + MACHO_HEADER_FLAGS_OFFSET, + readMachoLinkeditDataCommand, + readMachoSymtabCommand, +} from "../../domain/apple/machoLoadCommands.js"; +import type { MachoSliceEncryption } from "../../domain/apple/machoEncryption.js"; +import { + admitMachoSectionPayload, + readMachoSectionHeader, +} from "../../domain/apple/machoSectionHeader.js"; +import { + admitMachoSegmentCommand, + readMachoSegmentHeader, +} from "../../domain/apple/machoSegmentHeader.js"; +import { machoBinaryArchitecture } from "../../domain/binaryTarget.js"; +import { decodeMachoFixedName } from "../AppleMachoNames.js"; +import { + elfStringTableText, + observeElfStringTableEntry, +} from "../../domain/elfStringTable.js"; +import { + type BinaryHardeningImage, + type BinaryHardeningRow, + type BinaryHardeningSlice, + MACHO_HARDENING_PROPERTIES, + MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES, + MAX_BINARY_HARDENING_SYMBOL_SCAN_BYTES, + MAX_BINARY_HARDENING_TABLE_ENTRIES, +} from "../../domain/native/binaryHardening.js"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; +import { readMachoEncryption } from "../../artifacts/apple/MachoEncryptionReader.js"; +import { + frameLoadCommands, + readMachoContainer, + readSliceCommandTable, + type MachoLoadCommand, + type MachoSliceHeader, + type ReadAt, +} from "../../artifacts/apple/MachoLoadCommandReader.js"; + +const MH_EXECUTE = 0x2; +const MH_DYLIB = 0x6; +const MH_BUNDLE = 0x8; +const MH_ALLOW_STACK_EXECUTION = 0x20000; +const MH_PIE = 0x200000; +const MH_NO_HEAP_EXECUTION = 0x1000000; +const CPU_TYPE_ARM64 = 0x0100000c; +const LC_SEGMENT = 0x1; +const LC_SYMTAB = 0x2; +const LC_SEGMENT_64 = 0x19; +const LC_CODE_SIGNATURE = 0x1d; +const MAPPING_BUDGET = 4096; + +function malformed(message: string): never { + throw new BinaryFormatFailure("malformed", message); +} + +const row = ( + property: BinaryHardeningRow["property"], + state: BinaryHardeningRow["state"], + basis: string, + location: BinaryHardeningRow["location"] = null, + note: string | null = null, + nextProbe: string | null = null, +): BinaryHardeningRow => ({ + property, + state, + confidence: "observed", + basis, + location, + note, + next_probe: nextProbe, +}); + +type SliceRef = { + readonly thin: boolean; + readonly index: number; + readonly offset: number; + readonly size: number; + readonly cpuType: number; + readonly cpuSubtype: number; + readonly rangeIssue: { + readonly status: "malformed" | "unknown"; + readonly reason: string; + } | null; +}; + +/** Read Mach-O hardening per slice. One slice's defect leaves the others intact. */ +export const readMachoHardening = async ( + bytes: Buffer, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const readAt: ReadAt = async (offset, length) => { + assertArtifactReadActive(signal); + if (offset < 0 || length < 0 || offset > bytes.length) + return Buffer.alloc(0); + return bytes.subarray(offset, Math.min(bytes.length, offset + length)); + }; + const container = await readMachoContainer(readAt, bytes.length); + if (container.kind === "not-mach-o") + throw new BinaryFormatFailure( + "unsupported", + "File is not ELF, PE, or Mach-O", + ); + if (container.kind === "issue") + throw new BinaryFormatFailure(container.status, container.reason); + if ( + container.kind === "fat" && + container.hidden !== null && + container.hidden.status !== "declared" + ) + throw new BinaryFormatFailure( + container.hidden.status, + container.hidden.reason, + ); + const refs = sliceRefs(container, bytes.length); + const encryption = await readMachoEncryption( + readAt, + bytes.length, + MAPPING_BUDGET, + ); + if (encryption.status === "not-mach-o") + throw new BinaryFormatFailure( + "unsupported", + "File is not ELF, PE, or Mach-O", + ); + if (encryption.status !== "parsed") + throw new BinaryFormatFailure(encryption.status, encryption.reason); + if (encryption.slices.length !== refs.length) + malformed("Mach-O encryption slice coverage disagrees with the container"); + const images: BinaryHardeningImage[] = []; + for (const [index, ref] of refs.entries()) { + const encrypted = encryption.slices[index]; + if ( + encrypted === undefined || + encrypted.container_file_offset !== ref.offset + ) + malformed("Mach-O encryption slice order disagrees with the container"); + assertArtifactReadActive(signal); + images.push(await readSliceImage(bytes, ref, encrypted)); + } + return images; +}; + +const sliceRefs = ( + container: Exclude< + Awaited>, + { kind: "not-mach-o" | "issue" } + >, + size: number, +): SliceRef[] => { + if (container.kind === "thin") + return [ + { + thin: true, + index: 0, + offset: 0, + size, + cpuType: 0, + cpuSubtype: 0, + rangeIssue: null, + }, + ]; + const refs: SliceRef[] = container.header.slices.map((declaration, index) => { + const issue = container.header.rangeIssues[index] ?? null; + return { + thin: false, + index: declaration.index, + offset: declaration.offset, + size: declaration.size, + cpuType: declaration.cpuType, + cpuSubtype: declaration.cpuSubtype, + rangeIssue: + issue === null + ? null + : { + status: + issue.category === "unsupported" ? "unknown" : "malformed", + reason: issue.reason, + }, + }; + }); + const hidden = container.hidden; + if (hidden !== null && hidden.status === "declared") + refs.push({ + thin: false, + index: hidden.declaration.index, + offset: hidden.declaration.offset, + size: hidden.declaration.size, + cpuType: hidden.declaration.cpuType, + cpuSubtype: hidden.declaration.cpuSubtype, + rangeIssue: + hidden.rangeIssue === null + ? null + : { status: "malformed", reason: hidden.rangeIssue }, + }); + return refs; +}; + +const sliceRecord = ( + ref: SliceRef, + cpuType = ref.cpuType, + cpuSubtype = ref.cpuSubtype, +): BinaryHardeningSlice => ({ + index: ref.index, + file_offset: ref.offset, + size: ref.size, + cpu_type: cpuType, + cpu_subtype: cpuSubtype, + architecture: machoArchitectureName(cpuType, cpuSubtype), +}); + +const cpuFamily = (cpuType: number): BinaryHardeningImage["architecture"] => + machoBinaryArchitecture(cpuType) ?? null; + +const failedImage = ( + ref: SliceRef, + status: "malformed" | "unknown", + reason: string, + header: MachoSliceHeader | null = null, +): BinaryHardeningImage => ({ + format: "mach-o", + status, + reason, + architecture: cpuFamily(header?.cpuType ?? ref.cpuType), + bits: null, + byte_order: header === null ? null : "little", + machine: ref.thin ? null : ref.cpuType, + slice: ref.thin ? null : sliceRecord(ref), + rows: [], +}); + +const readSliceImage = async ( + bytes: Buffer, + ref: SliceRef, + encrypted: MachoSliceEncryption, +): Promise => { + if (ref.rangeIssue !== null) + return failedImage(ref, ref.rangeIssue.status, ref.rangeIssue.reason); + const readAt: ReadAt = async (offset, length) => + bytes.subarray(offset, Math.min(bytes.length, offset + length)); + const table = await readSliceCommandTable(readAt, ref.offset, ref.size); + if (table.status !== "read") { + // A thin image has no sibling slice. A bad header is a failed file. + if (ref.thin) + throw new BinaryFormatFailure( + table.status === "unsupported" ? "unsupported" : "malformed", + table.reason, + ); + return failedImage( + ref, + table.status === "unsupported" ? "unknown" : "malformed", + table.reason, + table.header, + ); + } + const { header } = table; + if (!ref.thin) { + const identity = validateFatSliceIdentity( + { + index: ref.index, + offset: ref.offset, + size: ref.size, + alignmentExponent: 0, + cpuType: ref.cpuType, + cpuSubtype: ref.cpuSubtype, + reserved: null, + }, + { cpuType: header.cpuType, cpuSubtype: header.cpuSubtype }, + ); + if (identity !== null) + return failedImage(ref, "malformed", identity, header); + } + try { + const facts = readCommandFacts(table, header.wide, ref.size); + admitEncryption(encrypted); + const rows = buildRows(bytes, ref, header, facts, encrypted); + return { + format: "mach-o", + status: "parsed", + reason: null, + architecture: cpuFamily(header.cpuType), + bits: header.wide ? 64 : 32, + byte_order: "little", + machine: header.cpuType, + slice: ref.thin + ? null + : sliceRecord(ref, header.cpuType, header.cpuSubtype), + rows, + }; + } catch (cause) { + if (cause instanceof BinaryFormatFailure && cause.kind === "malformed") { + if (ref.thin) throw cause; + return failedImage(ref, "malformed", cause.message, header); + } + throw cause; + } +}; + +/** Facts the rows need. A load command table can hold millions of small frames. */ +type CommandFacts = { + /** Table-relative offset of the first `__RESTRICT` segment command. */ + restrict: number | null; + /** Table-relative offset of the first `LC_CODE_SIGNATURE` command. */ + signature: number | null; + symtab: MachoLoadCommand | null; +}; + +/** Check every relevant command before any row exists, retaining only facts. */ +const readCommandFacts = ( + table: Parameters[0], + wide: boolean, + sliceSize: number, +): CommandFacts => { + const facts: CommandFacts = { restrict: null, signature: null, symtab: null }; + const framed = frameLoadCommands(table, (command) => { + if (segmentName(command, wide, sliceSize) === "__RESTRICT") + facts.restrict ??= command.offset; + if (command.command === LC_CODE_SIGNATURE) { + admitCodeSignature(command, sliceSize); + facts.signature ??= command.offset; + } else if (command.command === LC_SYMTAB) { + if (facts.symtab !== null) + malformed("Mach-O slice has more than one LC_SYMTAB"); + facts.symtab = command; + } + }); + if (framed.status !== "framed") malformed(framed.reason); + return facts; +}; + +/** + * The encryption reader reports every defect as a malformed slice status. Two + * encryption commands are conflicting declarations and stay an `unknown` row. + * Any other defect, such as a short command, a range past the slice, or the + * wrong width, is structural and fails the slice before any row exists. + */ +const admitEncryption = (encrypted: MachoSliceEncryption): void => { + if (encrypted.status === "malformed" && encrypted.commands.length < 2) + malformed( + encrypted.reason ?? "Mach-O encryption load command is malformed", + ); +}; + +const admitCodeSignature = ( + { body, index }: MachoLoadCommand, + sliceSize: number, +): void => { + const data = readMachoLinkeditDataCommand(LC_CODE_SIGNATURE, body, true); + if (data === null) + return malformed( + `load command ${index} is too short for LC_CODE_SIGNATURE`, + ); + // Both fields are 32-bit, so their sum is a safe integer. + if (data.dataOffset + data.dataSize > sliceSize) + malformed( + `load command ${index} LC_CODE_SIGNATURE data is outside the slice`, + ); +}; + +const buildRows = ( + bytes: Buffer, + ref: SliceRef, + header: MachoSliceHeader, + { restrict, signature, symtab }: CommandFacts, + encrypted: MachoSliceEncryption, +): BinaryHardeningRow[] => { + const flagLocation = { + offset: ref.offset + MACHO_HEADER_FLAGS_OFFSET, + bytes: 4, + }; + const bit = ( + property: BinaryHardeningRow["property"], + mask: number, + name: string, + ): BinaryHardeningRow => { + const set = (header.flags & mask) !== 0; + return row( + property, + set ? "enabled" : "disabled", + `${name} is ${set ? "set" : "clear"}.`, + flagLocation, + ); + }; + const architecture = machoArchitectureName(header.cpuType, header.cpuSubtype); + const pie = + header.fileType === MH_DYLIB || header.fileType === MH_BUNDLE + ? row( + "pie", + "not_applicable", + header.fileType === MH_DYLIB + ? "MH_DYLIB images are position-independent and do not use MH_PIE." + : "MH_BUNDLE images are position-independent and do not use MH_PIE.", + flagLocation, + ) + : header.fileType === MH_EXECUTE + ? bit("pie", MH_PIE, "MH_PIE") + : row( + "pie", + "not_applicable", + `MH_PIE applies to MH_EXECUTE. This slice file type is ${String(header.fileType)}.`, + flagLocation, + ); + const rows = [ + pie, + bit( + "allow_stack_execution", + MH_ALLOW_STACK_EXECUTION, + "MH_ALLOW_STACK_EXECUTION", + ), + bit("no_heap_execution", MH_NO_HEAP_EXECUTION, "MH_NO_HEAP_EXECUTION"), + restrict === null + ? row("restrict_segment", "disabled", "No segment is named __RESTRICT.") + : row("restrict_segment", "enabled", "A segment is named __RESTRICT.", { + offset: ref.offset + header.headerSize + restrict + 8, + bytes: 16, + }), + signature === null + ? row( + "code_signature_present", + "disabled", + "LC_CODE_SIGNATURE is absent.", + ) + : row( + "code_signature_present", + "enabled", + "LC_CODE_SIGNATURE is present. This is presence, not validity.", + { + offset: ref.offset + header.headerSize + signature, + bytes: 16, + }, + ), + encryptionRow(encrypted), + arm64Row(header.cpuType, architecture, ref.offset), + stackRow(bytes, ref, header.wide, symtab), + row( + "hardened_runtime", + "unknown", + "CodeDirectory flags are not read here.", + null, + "inspect_signature owns Mach-O hardened runtime.", + "inspect_signature", + ), + ]; + if ( + rows.map(({ property }) => property).join() !== + MACHO_HARDENING_PROPERTIES.join() + ) + malformed("Mach-O hardening rows are incomplete"); + return rows; +}; + +const segmentName = ( + command: MachoLoadCommand, + wide: boolean, + sliceSize: number, +): string | null => { + const expected = wide ? LC_SEGMENT_64 : LC_SEGMENT; + if (command.command !== expected) { + if (command.command === LC_SEGMENT || command.command === LC_SEGMENT_64) + malformed("Mach-O segment command width does not match the slice header"); + return null; + } + const header = readMachoSegmentHeader(command.body, wide, true); + if (!header.ok) malformed(header.error.message); + // Segment file offsets are relative to the slice, not its container. + const admitted = admitMachoSegmentCommand( + header.value, + command.body.byteLength, + wide ? 64 : 32, + sliceSize, + ); + if (!admitted.ok) malformed(admitted.error.message); + const sectionWidth = wide ? 80 : 68; + for (let index = 0; index < header.value.sectionCount; index += 1) { + const at = header.value.headerSize + index * sectionWidth; + const section = readMachoSectionHeader( + command.body.subarray(at, at + sectionWidth), + wide, + true, + ); + if (!section.ok) malformed(section.error.message); + const payload = admitMachoSectionPayload(section.value, sliceSize); + if (!payload.ok) malformed(payload.error.message); + } + // The fixed 16-byte name ends at the first NUL. Trailing-only stripping + // would keep junk after an embedded NUL and miss __RESTRICT. + return decodeMachoFixedName(header.value.nameBytes); +}; + +const unknownStackBudget = ( + reason = "The symbol table exceeds its structural budget, so this import sign is unknown.", +): BinaryHardeningRow => + row("stack_protector", "unknown", reason, null, reason); + +const encryptionRow = (encrypted: MachoSliceEncryption): BinaryHardeningRow => { + if (encrypted.status === "encrypted") + return row( + "encrypted", + "enabled", + "LC_ENCRYPTION_INFO cryptid is nonzero.", + ); + if (encrypted.status === "not-encrypted") + return row("encrypted", "disabled", "LC_ENCRYPTION_INFO cryptid is zero."); + if (encrypted.status === "absent") + return row( + "encrypted", + "disabled", + "No LC_ENCRYPTION_INFO command is present.", + ); + return row( + "encrypted", + "unknown", + encrypted.reason ?? "Mach-O encryption state is unknown.", + null, + encrypted.reason, + ); +}; + +const arm64Row = ( + cpuType: number, + architecture: string, + sliceOffset: number, +): BinaryHardeningRow => { + const location = { offset: sliceOffset + 8, bytes: 4 }; + if (cpuType !== CPU_TYPE_ARM64) + return row( + "arm64e", + "not_applicable", + `CPU type 0x${cpuType.toString(16)} is not ARM64.`, + location, + ); + const enabled = architecture === "arm64e" || architecture === "arm64e.x1"; + return row( + "arm64e", + enabled ? "enabled" : "disabled", + `CPU subtype names this slice ${architecture}.`, + location, + ); +}; + +const stackRow = ( + bytes: Buffer, + ref: SliceRef, + wide: boolean, + symtab: MachoLoadCommand | null, +): BinaryHardeningRow => { + if (symtab === null) + return row( + "stack_protector", + "unknown", + "LC_SYMTAB is absent, so stack protector use is unknown.", + null, + "LC_SYMTAB is absent, so stack protector use is unknown.", + ); + const fields = readMachoSymtabCommand(symtab.body, true); + if (fields === null) malformed("Mach-O LC_SYMTAB command is truncated"); + const { symoff, nsyms, stroff, strsize } = fields; + // One oversized symbol table must not drop the header rows or sibling slices. + if (nsyms > MAX_BINARY_HARDENING_TABLE_ENTRIES) return unknownStackBudget(); + const width = wide ? 16 : 12; + if ( + symoff > ref.size || + stroff > ref.size || + nsyms * width > ref.size - symoff || + strsize > ref.size - stroff + ) + malformed("Mach-O symbol or string table extends outside the slice"); + const symbolAt = ref.offset + symoff; + const stringAt = ref.offset + stroff; + if ( + nsyms * width > bytes.length - symbolAt || + strsize > bytes.length - stringAt + ) + malformed("Mach-O symbol or string table extends outside the file"); + const strings = bytes.subarray(stringAt, stringAt + strsize); + let scanned = 0; + let unterminated = false; + let found: BinaryHardeningRow | null = null; + for (let index = 0; index < nsyms; index += 1) { + const entry = symbolAt + index * width; + const nameOffset = bytes.readUInt32LE(entry); + if (nameOffset === 0) continue; + // N_EXT with N_TYPE clear is an undefined external. A defined export of + // the same name, including libc itself, is not an import into this image. + const nType = bytes.readUInt8(entry + 4); + if ((nType & 0x0e) !== 0 || (nType & 0x01) === 0) continue; + // A common definition shares those type bits but carries its size in + // n_value, so only a zero n_value names an import. + if ( + bytes.readUInt32LE(entry + 8) !== 0 || + (wide && bytes.readUInt32LE(entry + 12) !== 0) + ) + continue; + // After a match, later records are still admitted so symbol order cannot + // hide a damaged table; their names need not be read. + if (found !== null) { + if (nameOffset >= strings.length) + malformed("Mach-O symbol name offset is outside the string table"); + continue; + } + const observed = observeElfStringTableEntry( + strings, + nameOffset, + MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES, + ); + scanned += observed.examinedBytes; + if (scanned > MAX_BINARY_HARDENING_SYMBOL_SCAN_BYTES) + return unknownStackBudget(); + if (observed.state === "outside") + malformed("Mach-O symbol name offset is outside the string table"); + // A name past the window is unresolved. Later symbols can still carry the sign. + if (observed.state === "truncated") { + unterminated = true; + continue; + } + const name = elfStringTableText(observed) ?? ""; + if (name === "__stack_chk_fail" || name === "___stack_chk_fail") + found = row( + "stack_protector", + "enabled", + `Undefined symbol ${name} is present.`, + { + offset: stringAt + nameOffset, + bytes: Buffer.byteLength(name), + }, + ); + } + if (found !== null) return found; + if (unterminated) + return unknownStackBudget( + `An undefined symbol name has no NUL within the string table or the ${String(MAX_BINARY_HARDENING_SYMBOL_NAME_BYTES)}-byte name budget, so this import sign is unknown.`, + ); + return row( + "stack_protector", + "unknown", + "No __stack_chk_fail or ___stack_chk_fail symbol was found.", + null, + "No __stack_chk_fail or ___stack_chk_fail symbol was found. A stripped image can omit that symbol while still using a stack protector.", + ); +}; diff --git a/src/native/hardening/PeHardening.ts b/src/native/hardening/PeHardening.ts new file mode 100644 index 0000000000..3d78fb7670 --- /dev/null +++ b/src/native/hardening/PeHardening.ts @@ -0,0 +1,226 @@ +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + type BinaryHardeningImage, + type BinaryHardeningRow, + PE_HARDENING_PROPERTIES, +} from "../../domain/native/binaryHardening.js"; +import { + loadConfigFieldLayout, + MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS, + type PeLoadConfig, +} from "../../domain/native/peLoadConfig.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { peSubjectArchitecture } from "../../domain/peMachine.js"; +import type { PeDataDirectory } from "../../domain/peOptionalHeader.js"; +import { readPeImage } from "../metadata/PeImage.js"; +import { parsePeLoadConfig } from "../pe/PeLoadConfigParser.js"; + +const IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA = 0x0020; +const IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE = 0x0040; +const IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY = 0x0080; +const IMAGE_DLLCHARACTERISTICS_NX_COMPAT = 0x0100; +const IMAGE_DLLCHARACTERISTICS_NO_SEH = 0x0400; +const IMAGE_DLLCHARACTERISTICS_GUARD_CF = 0x4000; +const CERTIFICATE_DIRECTORY = 4; +const DEBUG_DIRECTORY = 6; + +function malformed(message: string): never { + throw new BinaryFormatFailure("malformed", message); +} + +const row = ( + property: BinaryHardeningRow["property"], + state: BinaryHardeningRow["state"], + basis: string, + location: BinaryHardeningRow["location"] = null, + note: string | null = null, +): BinaryHardeningRow => ({ + property, + state, + confidence: "observed", + basis, + location, + note, + next_probe: null, +}); + +/** + * Read PE hardening from DllCharacteristics, data directories, and the load + * configuration GuardFlags word owned by the load-config parser. + */ +export const readPeHardening = async ( + bytes: Buffer, + artifact: PeLoadConfig["artifact"], + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const image = readPeImage(bytes); + const characteristics = image.dll_characteristics; + const flagAt = image.dll_characteristics_file_offset; + const bit = ( + property: BinaryHardeningRow["property"], + mask: number, + note: string | null = null, + ): BinaryHardeningRow => { + const set = (characteristics & mask) !== 0; + return row( + property, + set ? "enabled" : "disabled", + `DllCharacteristics bit 0x${mask.toString(16)} is ${set ? "set" : "clear"}.`, + { offset: flagAt, bytes: 2 }, + note, + ); + }; + let loadConfig: PeLoadConfig | undefined; + let loadConfigFailure = ""; + try { + loadConfig = await parsePeLoadConfig( + bytes, + artifact, + { + path: artifact.path, + max_file_bytes: Math.max(artifact.bytes, 64), + max_callbacks: MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS, + }, + signal, + ); + } catch (cause) { + // GuardFlags is the only row that needs the load-config parser. TLS and + // callback failures must not discard the DllCharacteristics rows. + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + throw cause; + if ( + !(cause instanceof BinaryFormatFailure) && + !(cause instanceof BinaryResourceFailure) + ) + throw cause; + loadConfigFailure = cause.message; + } + assertArtifactReadActive(signal); + const rows = [ + bit("dynamic_base", IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE), + image.bits === 64 + ? bit( + "high_entropy_va", + IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA, + "HIGH_ENTROPY_VA is defined for 64-bit images.", + ) + : row( + "high_entropy_va", + "not_applicable", + "HIGH_ENTROPY_VA is defined for 64-bit images.", + { offset: flagAt, bytes: 2 }, + "HIGH_ENTROPY_VA is defined for 64-bit images.", + ), + bit("nx_compat", IMAGE_DLLCHARACTERISTICS_NX_COMPAT), + bit("force_integrity", IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY), + bit("no_seh", IMAGE_DLLCHARACTERISTICS_NO_SEH), + bit("guard_cf", IMAGE_DLLCHARACTERISTICS_GUARD_CF), + loadConfig === undefined + ? row( + "cfg_instrumented", + "unknown", + "The load configuration could not be read.", + null, + `IMAGE_GUARD_CF_INSTRUMENTED is a linker claim, and it is unknown here: ${loadConfigFailure}`, + ) + : cfgRow(image.bits, loadConfig), + directoryRow( + "authenticode_present", + image.pe_data_directories[CERTIFICATE_DIRECTORY], + image.pe_optional_header_file_offset, + "IMAGE_DIRECTORY_ENTRY_SECURITY", + ), + directoryRow( + "debug_record_present", + image.pe_data_directories[DEBUG_DIRECTORY], + image.pe_optional_header_file_offset, + "IMAGE_DIRECTORY_ENTRY_DEBUG", + ), + ]; + if ( + rows.map(({ property }) => property).join() !== + PE_HARDENING_PROPERTIES.join() + ) + malformed("PE hardening rows are incomplete"); + return { + format: "pe", + status: "parsed", + reason: null, + architecture: + peSubjectArchitecture( + image.pe_machine, + image.bits === 64 ? "pe32-plus" : "pe32", + ) ?? null, + bits: image.bits, + byte_order: "little", + machine: image.pe_machine, + slice: null, + rows, + }; +}; + +const cfgRow = (bits: 32 | 64, report: PeLoadConfig): BinaryHardeningRow => { + const loadConfig = report.load_config; + if (loadConfig === null) + return row( + "cfg_instrumented", + "unknown", + "No load configuration directory is present.", + null, + "IMAGE_GUARD_CF_INSTRUMENTED is a linker claim. No load configuration directory is present, so the claim is unknown.", + ); + const flags = loadConfig.guard_flags; + const field = loadConfigFieldLayout(bits, "guard_flags"); + const location = { + offset: loadConfig.structure_location.offset + field.offset, + bytes: field.width, + }; + if (flags === null) + return row( + "cfg_instrumented", + "unknown", + "Load configuration Size does not cover GuardFlags.", + null, + "IMAGE_GUARD_CF_INSTRUMENTED is a linker claim. GuardFlags is outside Size, so the claim is unknown.", + ); + const set = flags.flags.includes("IMAGE_GUARD_CF_INSTRUMENTED"); + return row( + "cfg_instrumented", + set ? "enabled" : "disabled", + `GuardFlags bit IMAGE_GUARD_CF_INSTRUMENTED is ${set ? "set" : "clear"}.`, + location, + "IMAGE_GUARD_CF_INSTRUMENTED is a linker claim, not a check that CFG checks run.", + ); +}; + +const directoryRow = ( + property: "authenticode_present" | "debug_record_present", + entry: PeDataDirectory | undefined, + optionalOffset: number, + name: string, +): BinaryHardeningRow => { + if (entry === undefined) + return row(property, "disabled", `${name} is absent.`); + const { address, size } = entry; + if ((address === 0) !== (size === 0)) + return row( + property, + "unknown", + `${name} declares a zero address or size while the other field is nonzero.`, + { offset: optionalOffset + entry.offset, bytes: 8 }, + `${name} is not a resolved directory, so presence is unknown.`, + ); + return row( + property, + address === 0 ? "disabled" : "enabled", + address === 0 + ? `${name} address and size are zero.` + : `${name} address and size are both nonzero. This is presence, not validity.`, + { offset: optionalOffset + entry.offset, bytes: 8 }, + ); +}; diff --git a/src/native/metadata/ElfImage.ts b/src/native/metadata/ElfImage.ts index c48058d9ba..414300fce7 100644 --- a/src/native/metadata/ElfImage.ts +++ b/src/native/metadata/ElfImage.ts @@ -1,3 +1,4 @@ +import { admitElfLoadSegment } from "../../domain/elfProgramHeader.js"; import { declaredFileRange, BinaryFormatFailure, @@ -30,6 +31,8 @@ export const readElfImage = ( dynamicSegments.push(program); continue; } + // PT_INTERP and GNU program headers are not load segments. + if (program.type !== 1) continue; const { flags, file_offset: fileOffset, @@ -37,11 +40,12 @@ export const readElfImage = ( file_size: size, memory_size: memorySize, } = program; - if (size > memorySize || address + memorySize > 1n << BigInt(bits)) - throw new BinaryFormatFailure( - "malformed", - "ELF load segment sizes or addresses are invalid", - ); + const admitted = admitElfLoadSegment( + { virtualAddress: address, memorySize, fileSize: size }, + bits, + ); + if (!admitted.ok) + throw new BinaryFormatFailure("malformed", admitted.error.message); const mapping = { ...(size === 0n ? { offset: 0, size: 0 } diff --git a/src/native/metadata/MachoImage.ts b/src/native/metadata/MachoImage.ts index c2178615e7..a9fcec4c72 100644 --- a/src/native/metadata/MachoImage.ts +++ b/src/native/metadata/MachoImage.ts @@ -1,10 +1,11 @@ import { decodeMachoFixedName } from "../AppleMachoNames.js"; import { + isMachoZeroFillSection, readMachoSectionHeader, type MachoSectionHeader, } from "../../domain/apple/machoSectionHeader.js"; import { - admitMachoSegmentMapping, + admitMachoSegmentCommand, readMachoSegmentHeader, } from "../../domain/apple/machoSegmentHeader.js"; import { @@ -80,18 +81,18 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => { sectionCount, nameBytes, } = parsedSegment.value; - const admitted = admitMachoSegmentMapping(parsedSegment.value, bits); + const admitted = admitMachoSegmentCommand( + parsedSegment.value, + size, + bits, + bytes.length, + ); if (!admitted.ok) throw new BinaryFormatFailure( admitted.error.kind, admitted.error.message, ); const { unmapped } = admitted.value; - if (sectionCount > Math.floor((size - segmentHeader) / sectionWidth)) - throw new BinaryFormatFailure( - "malformed", - "Mach-O section table exceeds its segment command", - ); const fileRange = fileSize === 0n ? undefined @@ -203,7 +204,7 @@ const machoImageSection = ({ const sectionOffset = BigInt(section.offset); const sectionName = decodeMachoFixedName(section.nameBytes); const name = `${decodeMachoFixedName(section.segmentNameBytes)},${sectionName}`; - const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff); + const zeroFill = isMachoZeroFillSection(flags); const addressDelta = sectionAddress - segmentAddress; const fileDelta = sectionOffset - segmentFileOffset; const inSegmentMemory = diff --git a/src/native/metadata/NativeSectionDirectory.test.ts b/src/native/metadata/NativeSectionDirectory.test.ts index a3e2e69992..df71988a9d 100644 --- a/src/native/metadata/NativeSectionDirectory.test.ts +++ b/src/native/metadata/NativeSectionDirectory.test.ts @@ -1,6 +1,13 @@ import { expect, it } from "vitest"; import { pe } from "../../domain/binaryTarget.fixture.js"; -import { readPeSectionDirectory } from "./NativeSectionDirectory.js"; +import { elfHardeningFixture } from "../hardening/BinaryHardening.fixture.js"; +import { + ELF_PT_GNU_RELRO, + ELF_PT_GNU_STACK, + ELF_PT_INTERP, + readElfDirectoryFromBuffer, + readPeSectionDirectory, +} from "./NativeSectionDirectory.js"; const readDirectory = (bytes: Buffer) => { const reader = readPeSectionDirectory(bytes.length); @@ -41,6 +48,22 @@ it("does not classify an unconfirmed pointer overlapping DOS data as a PE layout ); }); +it("admits PT_INTERP, PT_GNU_STACK and PT_GNU_RELRO program headers", () => { + const directory = readElfDirectoryFromBuffer( + elfHardeningFixture({ interp: true, gnuRelro: true }), + ); + expect(directory.elf_type).toBe(3); + expect(directory.programs.map((program) => program.type)).toEqual( + expect.arrayContaining([ + 1, + 2, + ELF_PT_INTERP, + ELF_PT_GNU_STACK, + ELF_PT_GNU_RELRO, + ]), + ); +}); + it("retains a confirmed overlapping PE when the COFF record extends past EOF", () => { const bytes = Buffer.alloc(64); bytes.write("MZ", 0, "ascii"); diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 76000b1391..14d1f08ccf 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -11,7 +11,10 @@ import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; import { readPeSectionHeader } from "../../domain/peSectionHeader.js"; -import { readElfTableDeclarations } from "../../domain/elfDeclaredRanges.js"; +import { + readElfTableDeclarations, + validateElfDeclaredRanges, +} from "../../domain/elfDeclaredRanges.js"; import { readPeOptionalHeaderStructure } from "../../domain/peOptionalHeader.js"; import { BinaryFormatFailure, @@ -40,19 +43,36 @@ export interface NativeSection { readonly link: number; readonly info: number; readonly entry_size: bigint; + /** `sh_addralign` for ELF. PE sections have no note alignment and use 1. */ + readonly alignment: bigint; readonly file_backed: boolean; } +/** Program header types retained for load, dynamic, and hardening consumers. */ +export const ELF_PT_NOTE = 4 as const; +export const ELF_PT_INTERP = 3 as const; +export const ELF_PT_GNU_STACK = 0x6474e551 as const; +export const ELF_PT_GNU_RELRO = 0x6474e552 as const; +export const ELF_PT_GNU_PROPERTY = 0x6474e553 as const; + +type ElfProgramSpan = { + readonly header_offset: number; + readonly flags: number; + readonly file_offset: bigint; + readonly file_size: bigint; + readonly virtual_address: bigint; + readonly memory_size: bigint; + readonly alignment: bigint; +}; + /** Raw relevant program declarations; consumers own load/dynamic admission. */ export type ElfProgramDeclaration = - | { - readonly type: 1; - readonly flags: number; - readonly file_offset: bigint; - readonly file_size: bigint; - readonly virtual_address: bigint; - readonly memory_size: bigint; - } - | ({ readonly type: 2 } & ElfDynamicSegment); + | ({ readonly type: 1 } & ElfProgramSpan) + | ({ readonly type: 2; readonly header_offset: number } & ElfDynamicSegment) + | ({ readonly type: typeof ELF_PT_NOTE } & ElfProgramSpan) + | ({ readonly type: typeof ELF_PT_INTERP } & ElfProgramSpan) + | ({ readonly type: typeof ELF_PT_GNU_STACK } & ElfProgramSpan) + | ({ readonly type: typeof ELF_PT_GNU_RELRO } & ElfProgramSpan) + | ({ readonly type: typeof ELF_PT_GNU_PROPERTY } & ElfProgramSpan); export interface NativeSectionDirectory { readonly format: "elf" | "pe"; @@ -142,21 +162,34 @@ export function* readElfSectionDirectory( if (!parsedProgram.ok) return reject(parsedProgram.error.message); const program = parsedProgram.value; const { type } = program; - if (type === 1) - programDeclarations.push({ - type, - flags: program.flags, - file_offset: program.offset, - virtual_address: program.virtualAddress, - file_size: program.fileSize, - memory_size: program.memorySize, - }); + const header_offset = exact(program_offset) + at; + const span = { + header_offset, + flags: program.flags, + file_offset: program.offset, + virtual_address: program.virtualAddress, + file_size: program.fileSize, + memory_size: program.memorySize, + alignment: program.alignment, + }; + if (type === 1) programDeclarations.push({ type, ...span }); else if (type === 2) programDeclarations.push({ type, + header_offset, offset: program.offset, size: program.fileSize, }); + else if (type === ELF_PT_NOTE) + programDeclarations.push({ type: ELF_PT_NOTE, ...span }); + else if (type === ELF_PT_INTERP) + programDeclarations.push({ type: ELF_PT_INTERP, ...span }); + else if (type === ELF_PT_GNU_STACK) + programDeclarations.push({ type: ELF_PT_GNU_STACK, ...span }); + else if (type === ELF_PT_GNU_RELRO) + programDeclarations.push({ type: ELF_PT_GNU_RELRO, ...span }); + else if (type === ELF_PT_GNU_PROPERTY) + programDeclarations.push({ type: ELF_PT_GNU_PROPERTY, ...span }); } } const sections: NativeSection[] = []; @@ -268,6 +301,7 @@ export function* readElfSectionDirectory( link: section.link, info: section.info, entry_size: section.entrySize, + alignment: section.alignment, file_backed: type !== 8 && declaredFileRange(fileSize, offset, size).file_backed, }); @@ -366,6 +400,7 @@ export function* readPeSectionDirectory( link: 0, info: 0, entry_size: 0n, + alignment: 1n, file_backed: declaredFileRange( fileSize, BigInt(row.offset), @@ -385,11 +420,10 @@ export function* readPeSectionDirectory( }; } -/** Borrow ELF bytes; file drivers satisfy the identical requests without loading payloads. */ -export const readElfDirectoryFromBuffer = ( +const driveFromBuffer = ( bytes: Buffer, -): ElfSectionDirectory => { - const steps = readElfSectionDirectory(bytes.length); + steps: Generator, +): T => { let next = steps.next(); while (!next.done) { const { offset, size } = next.value; @@ -397,3 +431,18 @@ export const readElfDirectoryFromBuffer = ( } return next.value; }; + +/** Borrow ELF bytes; file drivers satisfy the identical requests without loading payloads. */ +export const readElfDirectoryFromBuffer = ( + bytes: Buffer, +): ElfSectionDirectory => + driveFromBuffer(bytes, readElfSectionDirectory(bytes.length)); + +/** Reject a program or section payload the declared tables place outside these bytes. */ +export const assertElfDeclaredRangesFromBuffer = (bytes: Buffer): void => { + const checked = driveFromBuffer( + bytes, + validateElfDeclaredRanges(bytes.subarray(0, 64), bytes.length), + ); + if (!checked.ok) reject(checked.error); +}; diff --git a/src/native/metadata/PeImage.test.ts b/src/native/metadata/PeImage.test.ts index baaad17772..8db7150c02 100644 --- a/src/native/metadata/PeImage.test.ts +++ b/src/native/metadata/PeImage.test.ts @@ -208,6 +208,17 @@ it.each([0x014c, 0x8664] as const)( }, ); +it("reads DllCharacteristics from the optional header", () => { + const bytes = pe(0x8664); + const peOffset = bytes.readUInt32LE(60); + bytes.writeUInt16LE(0x4160, peOffset + 24 + 70); + expect(readPeImage(bytes)).toMatchObject({ + dll_characteristics: 0x4160, + pe_optional_header_file_offset: peOffset + 24, + dll_characteristics_file_offset: peOffset + 24 + 70, + }); +}); + it.each([0xffff, 0x8664 ^ 0x7b79])( "retains raw unknown machine %s without managed override proof", (machine) => { diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index 89a66873a6..34885ee8fe 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -69,6 +69,9 @@ export type PeImage = BinaryContainer & { readonly pe_data_directories: NonNullable< BinaryContainer["pe_data_directories"] >; + readonly pe_optional_header_file_offset: number; + readonly dll_characteristics: number; + readonly dll_characteristics_file_offset: number; readonly sections: readonly PeImageSection[]; }; @@ -85,12 +88,15 @@ export const readPeImage = (bytes: Buffer): PeImage => { sectionCount: count, optionalHeaderSize: optionalSize, } = coff.value; - const facts = readPeOptionalHeader( - reader.range(optional, optionalSize, "PE optional header"), - machine, + const optionalBytes = reader.range( + optional, + optionalSize, + "PE optional header", ); + const facts = readPeOptionalHeader(optionalBytes, machine); if (!facts.ok) throw new BinaryFormatFailure(facts.error.kind, facts.error.message); + const dllCharacteristics = facts.value.dllCharacteristics; const { bits, dataDirectories, @@ -174,6 +180,9 @@ export const readPeImage = (bytes: Buffer): PeImage => { pe_section_alignment: sectionAlignment, pe_file_alignment: fileAlignment, pe_data_directories: dataDirectories, + pe_optional_header_file_offset: optional, + dll_characteristics: dllCharacteristics, + dll_characteristics_file_offset: optional + 70, pe_header_mapping: headerMapping, architecture: architecture === "x86" diff --git a/src/server/createServer.ts b/src/server/createServer.ts index ee9227d082..e016e7f7b6 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -22,6 +22,7 @@ import { registerElfNotesTool } from "./registerElfNotesTool.js"; import { registerAppleIpsTool } from "./registerAppleIpsTool.js"; import { registerKernelModuleTool } from "./registerKernelModuleTool.js"; import { registerPeLoadConfigTool } from "./registerPeLoadConfigTool.js"; +import { registerBinaryHardeningTool } from "./registerBinaryHardeningTool.js"; import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js"; import { registerPeImportsTool } from "./registerPeImportsTool.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; @@ -211,6 +212,7 @@ export const createServer = ( appleIps, kernelModule, peLoadConfig, + binaryHardening, peCertificates, peImports, electronFuses, @@ -482,6 +484,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerBinaryHardeningTool( + server, + binaryHardening, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerPeCertificatesTool( server, peCertificates, diff --git a/src/server/registerBinaryHardeningTool.ts b/src/server/registerBinaryHardeningTool.ts new file mode 100644 index 0000000000..e6e517a593 --- /dev/null +++ b/src/server/registerBinaryHardeningTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { BinaryHardeningService } from "../application/binaryDiagnostics/BinaryHardeningService.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 ELF, PE, and Mach-O hardening workflow. */ +export const registerBinaryHardeningTool = ( + server: EvidenceMcpServer, + service: BinaryHardeningService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_binary_hardening"); + 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/binaryHardeningCli.test.ts b/tests/acceptance/binaryHardeningCli.test.ts new file mode 100644 index 0000000000..643708e121 --- /dev/null +++ b/tests/acceptance/binaryHardeningCli.test.ts @@ -0,0 +1,87 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + elfHardeningFixture, + peHardeningImage, +} from "../../src/native/hardening/BinaryHardening.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const states = ( + rows: readonly { property: string; state: string }[], +): Record => + Object.fromEntries(rows.map((row) => [row.property, row.state])); + +cliTest( + "reads ELF and PE hardening through the CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-binary-hardening-cli-"); + const call = await cli.sharedMcpCall!(); + const elfPath = join(root, "app"); + const elfBytes = elfHardeningFixture({ interp: true, gnuRelro: true }); + await writeFile(elfPath, elfBytes); + const elfResult = await cli.run({ + arguments: ["inspect-binary-hardening", "app", "--json"], + cwd: root, + }); + expect(elfResult.exitCode, elfResult.stderr).toBe(0); + const elfResponse = await call("inspect_binary_hardening", { + path: elfPath, + }); + expect(elfResponse.isError).not.toBe(true); + for (const value of [elfResult.json, elfResponse.structuredContent]) { + const report = toolContract( + "inspect_binary_hardening", + ).outputSchema.parse(value).normalized_result; + expect(report.format).toBe("elf"); + expect(states(report.images[0]?.rows ?? [])).toMatchObject({ + pie: "enabled", + nx: "enabled", + relro: "partial", + stack_protector: "unknown", + }); + } + expect( + toolContract("inspect_binary_hardening").outputSchema.parse( + elfResult.json, + ).subject, + ).toMatchObject({ local_path: elfPath }); + expect((await readFile(elfPath)).equals(elfBytes)).toBe(true); + + const pePath = join(root, "app.exe"); + const peBytes = peHardeningImage(); + await writeFile(pePath, peBytes); + const peResult = await cli.run({ + arguments: ["inspect-binary-hardening", pePath, "--json"], + }); + expect(peResult.exitCode, peResult.stderr).toBe(0); + const peReport = toolContract( + "inspect_binary_hardening", + ).outputSchema.parse(peResult.json).normalized_result; + expect(states(peReport.images[0]?.rows ?? [])).toMatchObject({ + dynamic_base: "enabled", + nx_compat: "enabled", + guard_cf: "enabled", + cfg_instrumented: "enabled", + authenticode_present: "disabled", + }); + expect((await readFile(pePath)).equals(peBytes)).toBe(true); + + const foreign = join(root, "notes.txt"); + await writeFile(foreign, Buffer.from("not a binary")); + const rejected = await cli.run({ + arguments: ["inspect-binary-hardening", foreign, "--json"], + }); + expect(rejected.exitCode).toBe(1); + expect(rejected.json).toMatchObject({ + details: { operation: "inspect_binary_hardening" }, + }); + const response = await call("inspect_binary_hardening", { path: foreign }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response)).toEqual({ error: rejected.json }); + }, + 60_000, +); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 6f0b7f9c70..d3c1be400c 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -242,6 +242,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_devicetree", { path: nul }], ["inspect_kernel_module", { path: nul }], ["inspect_pe_load_config", { path: nul }], + ["inspect_binary_hardening", { path: nul }], ["inspect_python_pyc", { path: nul }], ["inspect_erlang_beam", { path: nul }], ["inspect_binary_layout", { path: nul }], @@ -333,7 +334,7 @@ it("rejects OS-invalid local inputs through the real advertised MCP contracts", expect(JSON.stringify(relative.content)).toContain( "absolute local filesystem path", ); -}); +}, 60_000); const ignoredOptions = [ { terminal: { columns: 80, row: 12 } }, diff --git a/tests/boundary/filesystem/binaryHardening.test.ts b/tests/boundary/filesystem/binaryHardening.test.ts new file mode 100644 index 0000000000..dd88a7d6b1 --- /dev/null +++ b/tests/boundary/filesystem/binaryHardening.test.ts @@ -0,0 +1,72 @@ +import { readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createBinaryHardeningService } from "../../../src/composition/binaryDiagnostics.js"; +import { elfHardeningFixture } from "../../../src/native/hardening/BinaryHardening.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("binary hardening stable filesystem boundary", () => { + it("reports non-file, missing and malformed selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-binary-hardening-files-"); + const path = join(root, "app"); + const alias = join(root, "alias"); + const malformed = join(root, "malformed"); + const bytes = elfHardeningFixture(); + await writeFile(path, bytes); + await writeFile(malformed, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0])); + await symlink(path, alias); + const service = createBinaryHardeningService(); + try { + for (const [candidate, reason] of [ + [root, "invalid_format"], + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing"), "invalid_value"], + [join(path, "child"), "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_binary_hardening", + issues: [{ path: ["path"], reason, message: expect.any(String) }], + }); + } + const foreign = join(root, "foreign"); + await writeFile(foreign, Buffer.from("not a binary")); + const unsupported = await service.inspect({ path: foreign }); + if (unsupported.ok) throw new Error("Expected an unsupported target"); + expect(unsupported.error._tag).toBe("AnalysisUnsupportedTargetError"); + } finally { + await service.close(); + } + }); + + it("returns typed resource and cancellation failures and leaves bytes unchanged", async () => { + const root = await createTestTempDirectory("rea-binary-hardening-budget-"); + const path = join(root, "app"); + const bytes = elfHardeningFixture(); + await writeFile(path, bytes); + const service = createBinaryHardeningService(); + try { + 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"); + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + format: "elf", + images: [{ status: "parsed", architecture: "x86_64" }], + }); + expect(Buffer.compare(await readFile(path), bytes)).toBe(0); + } finally { + await service.close(); + } + }); +}); diff --git a/tests/boundary/mcp/binaryHardeningMcp.test.ts b/tests/boundary/mcp/binaryHardeningMcp.test.ts new file mode 100644 index 0000000000..2188163f2c --- /dev/null +++ b/tests/boundary/mcp/binaryHardeningMcp.test.ts @@ -0,0 +1,65 @@ +import { readFile, 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 { elfHardeningFixture } from "../../../src/native/hardening/BinaryHardening.fixture.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +mcpTest( + "advertises and reads binary hardening without executing or changing the image", + async ({ mcp }) => { + const session = createTestBinarySession(() => { + throw new Error("Must not start a deep provider"); + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + try { + const definition = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_binary_hardening", + ); + if (definition === undefined) + throw new Error("Missing advertised contract"); + expect(definition.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-binary-hardening-mcp-"); + const path = join(root, "app"); + const bytes = elfHardeningFixture({ + pieFlag: true, + gnuStack: { flags: 7 }, + }); + await writeFile(path, bytes); + const response = await client.callTool( + { name: "inspect_binary_hardening", arguments: { path } }, + { toolDefinition: definition }, + ); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract( + "inspect_binary_hardening", + ).outputSchema.parse(response.structuredContent); + const rows = evidence.normalized_result.images[0]?.rows ?? []; + expect(rows.find((row) => row.property === "pie")).toMatchObject({ + state: "enabled", + confidence: "derived", + }); + expect(rows.find((row) => row.property === "nx")?.state).toBe("disabled"); + expect(Buffer.compare(await readFile(path), bytes)).toBe(0); + const bad = await client.callTool( + { + name: "inspect_binary_hardening", + arguments: { path: "relative" }, + }, + { toolDefinition: definition }, + ); + expect(bad.isError).toBe(true); + } finally { + await session.close(); + } + }, + 60_000, +); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index 17895b7a12..4a14bb03c4 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -1,5 +1,7 @@ import { PeLoadConfigProvider } from "../../src/native/pe/PeLoadConfigProvider.js"; +import { BinaryHardeningProvider } from "../../src/native/hardening/BinaryHardeningProvider.js"; import { inspectPeLoadConfigInputSchema } from "../../src/domain/native/peLoadConfig.js"; +import { inspectBinaryHardeningInputSchema } from "../../src/domain/native/binaryHardening.js"; import { open, writeFile, type FileHandle } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join } from "node:path"; @@ -1000,6 +1002,7 @@ const stableArtifactProviders = [ ["kernel module", () => new KernelModuleProvider()], ["PE certificates", () => new PeCertificatesProvider()], ["PE load configuration", () => new PeLoadConfigProvider()], + ["binary hardening", () => new BinaryHardeningProvider()], ["PE imports", () => new PeImportsProvider()], ["native exports", () => new NativeExportsProvider()], ["Python pyc", () => new PythonPycProvider()], @@ -1046,6 +1049,11 @@ const inspectFileFailure = ( inspectPeLoadConfigInputSchema.parse({ path }), options, ); + if (provider instanceof BinaryHardeningProvider) + return provider.inspect( + inspectBinaryHardeningInputSchema.parse({ path }), + options, + ); if (provider instanceof PeImportsProvider) return provider.inspect( inspectPeImportsInputSchema.parse({ path }),