From 560674e2de851cec3f670be8297d28835009247a Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 5 Nov 2026 09:07:00 -0800 Subject: [PATCH] feat(native): report binary hardening for ELF, PE and Mach-O (#8524) * feat(native): report binary hardening for ELF, PE and Mach-O Documents inspect_binary_hardening and its CLI equivalent, including per-property states, supporting evidence, read budgets, and one report per Mach-O slice. Explains that missing protection imports remain unknown, signature presence does not establish validity, and hardened runtime requires inspect_signature. Adds hardening links throughout the documentation. The supplied diff also removes the advanced CodeQL workflow and configuration, along with snapshot-binding mismatch guidance. * fix(native): validate declared payloads and dynamic tables before hardening rows Run the shared range checks for ELF, read imports from the dynamic tables rather than section metadata, and keep an unresolved symbol count apart from an absent table. Check every Mach-O command before building rows and retain only the offsets the rows need. Make the verifier's readelf output wide, check the emitted format before parsing Mach-O, and place the sources before the libraries in the link. * fix(native): share Mach-O segment admission with hardening reads A segment with a section count its cmdsize cannot hold passed the hardening reader and could enable restrict_segment. Move the size, section count and file range checks to a shared admission that readMachoImage and the hardening reader both use, and pin the ELF load-size case with a regression test. * fix(hardening): reject Mach-O section payloads past the slice and empty ELF interpreters Segment admission never checked each section's payload range, so a file-backed section past the slice still produced rows. Decode sections with the section header owner and mark the slice malformed when a payload leaves it; zero-fill and empty sections own no file bytes and are exempt. admitInterp accepted a NUL first byte in a longer extent, which counted an empty interpreter as PIE evidence. It now requires a nonempty path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KuWHGvzJkrhCqjtEHUwqyH * fix(native): read CFG instrumentation from decoded GuardFlags Use the IMAGE_GUARD_CF_INSTRUMENTED flag that guardFlagRecord already decodes instead of masking the raw GuardFlags word a second time. * fix(native): reject ELF table spans a second PT_LOAD remaps The hardening reader mapped DT_STRTAB, DT_SYMTAB and hash tables through the one PT_LOAD containing the table's first address. A second PT_LOAD starting inside the table could map the rest of it from other file bytes and the reader still reported parsed import evidence. Cap each mapping at the next PT_LOAD that starts inside it, reject a requested range that crosses that boundary, and stop hash reads at the same limit, matching the conflicting-mapping refusal in readElfSymbols. * fix(native): dedupe ELF note tables only on shared boundaries The hardening reader dropped any note table whose bytes another table already covered. A covering SHT_NOTE can hold a non-GNU note whose descriptor is itself a complete GNU property note, while PT_NOTE names that inner note directly. Walking only the outer stream missed the GNU marker and reported CET disabled. Drop an alias only when it starts and ends on boundaries of admitted tables, which are note boundaries of the walked streams. Any other covered alias leaves the feature rows unknown, as a partial overlap already does. * fix(native): admit every Mach-O symbol record after a canary match The Mach-O stack protector scan returned as soon as it found an undefined ___stack_chk_fail, so a later undefined symbol whose n_strx lay outside the string table was never checked. The same damaged table parsed or failed as malformed depending only on record order. Keep the match and finish the bounded record walk. Records after the match are only checked against the string table bounds, so their names are not read or charged to the scan budget. The ELF reader already admits its whole symbol table before matching names. * fix(native): accept ELF load aliases with the same file translation The hardening reader treated every PT_LOAD that starts inside a table, or that also contains the table's address, as a conflicting mapping. A load with the same address-to-file translation names the same file bytes, so tables it overlaps still have one interpretation, and the canonical ElfLoadMap already merges such spans. Treat equivalent loads as one interpretation. A load with a different translation, or the zero-filled tail of an equivalent load, still ends the usable range, so a remapped table is still rejected. * fix(native): keep same-span ELF note tables with different alignments unknown A section and a program header that share endpoints were treated as one note stream even when their effective note alignments differed. Walking the same bytes at 4 and 8 can frame different notes, so dropping the second table could hide a GNU property word and report CET or BTI as disabled. Require matching effective alignment at both endpoints, and no alignment change inside the covered span, before treating a table as an alias. Otherwise mark the feature rows unknown. The fixture's .note.gnu.property section now declares the class's note alignment, as toolchains emit it. * refactor(native): read LC_SYMTAB through the shared fixed-word decoder Move the Mach-O fixed uint32 command layouts out of MachoCommandNames into domain/apple/machoLoadCommands so there is one owner of those offsets. MachoCommandNames and the hardening reader now both decode through it; the hardening reader keeps its own bounds and import policy. * fix(native): skip Mach-O common definitions in canary imports A common definition sets N_UNDF|N_EXT like an import but stores its allocation size in n_value. An object holding only `int __stack_chk_fail;` therefore reported stack_protector: enabled. Require a zero n_value before an undefined external counts as an import, for both nlist widths. * refactor(native): read linkedit data commands through the shared fixed-word decoder readMachoLinkeditDataCommand reproduced the dataoff/datasize offsets that FIXED_WORD_FIELDS already owns. Take the command and decode through decodeMachoFixedWordFields, as readMachoSymtabCommand does, keeping the truncation and endianness behavior. * fix(native): skip empty SHT_NOTE sections in ELF hardening A zero-sized SHT_NOTE owns no bytes, so the section directory marks it not file-backed and the hardening reader rejected the whole image as malformed, while readElfImage accepted the same bytes. Skip empty note sections before the file-backing check; they have nothing to walk. * fix(scripts): run hardening verifier commands through the shared owner The binary hardening verifier started its probes and compiler drivers with raw spawnSync, which bypassed the src/process/ supervision that AGENTS.md requires. Every launch now goes through createVerifierCommands, and the temporary root is removed only after the owner closes cleanly. * fix(scripts): clean up hardening verifier commands on Ctrl-C SIGINT or SIGTERM ended Node before withVerifierCleanup ran, so detached compiler trees and the temporary root could outlive the verifier. The verifier now turns those signals into an abort that every owned command receives, so cleanup closes the owner and removes the root before exit. * fix(scripts): fail the hardening verifier when a signal lands outside a command Only the owned commands read the interruption signal, so a SIGINT or SIGTERM during the final cleanup or between commands was recorded and then dropped, and the verifier exited 0. After cleanup settles, the verifier now throws the interruption reason. A failure the operation or its cleanup already raised still leaves first and is not replaced. * fix(native): fail Mach-O slices with a malformed encryption command The hardening reader turned every malformed status from the shared encryption reader into an unknown `encrypted` row, so a short LC_ENCRYPTION_INFO_64 or an encrypted range past the slice still left the slice parsed with a full report. Only two conflicting encryption commands stay an unknown row now. Any other encryption defect fails the slice like other structural command failures. * fix(native): ignore STB_LOCAL undefined ELF symbols as imports A local undefined __stack_chk_fail or __memcpy_chk is never resolved against another object, so it does not establish an external import. Skip STB_LOCAL bindings when reading protector signs, and give the fixture's positive symbols STB_GLOBAL binding. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../skills/reverse-engineer-anything/SKILL.md | 9 + .../references/native-and-artifacts.md | 12 + README.md | 1 + docs/.vitepress/config.ts | 1 + docs/binary-hardening.md | 62 + docs/cli.md | 10 + docs/index.md | 2 +- docs/readme/README_ar.md | 1 + docs/readme/README_de.md | 1 + docs/readme/README_el.md | 1 + docs/readme/README_es.md | 1 + docs/readme/README_fa.md | 1 + docs/readme/README_fr.md | 1 + docs/readme/README_id.md | 1 + docs/readme/README_it.md | 1 + docs/readme/README_ja.md | 1 + docs/readme/README_ko.md | 1 + docs/readme/README_ml.md | 1 + docs/readme/README_pl.md | 1 + docs/readme/README_pt-BR.md | 1 + docs/readme/README_ro.md | 1 + docs/readme/README_ru.md | 1 + docs/readme/README_th.md | 1 + docs/readme/README_tr.md | 1 + docs/readme/README_uk.md | 1 + docs/readme/README_ur-Latn.md | 1 + docs/readme/README_vi.md | 1 + docs/readme/README_zh-TW.md | 1 + docs/readme/README_zh.md | 1 + .../binary-hardening-2026-10-31.md | 121 ++ package.json | 1 + scripts/verify-binary-hardening.mjs | 633 ++++++ src/application/CapabilityInventory.ts | 3 +- .../binaryDiagnostics/BinaryHardeningPort.ts | 20 + .../BinaryHardeningService.test.ts | 383 ++++ .../BinaryHardeningService.ts | 204 ++ src/artifacts/apple/MachoLoadCommandReader.ts | 17 +- src/cli/binaryDiagnosticsCommands.ts | 36 + src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 6 + src/composition/capabilityDeclarations.ts | 6 + src/composition/capabilityServices.ts | 2 + .../native/binaryDiagnosticsToolContracts.ts | 19 + src/contracts/toolEffects.test.ts | 1 + src/contracts/toolEffects.ts | 1 + src/domain/apple/machoLoadCommands.test.ts | 49 + src/domain/apple/machoLoadCommands.ts | 156 ++ src/domain/apple/machoSectionHeader.test.ts | 21 +- src/domain/apple/machoSectionHeader.ts | 19 + src/domain/apple/machoSegmentHeader.test.ts | 27 +- src/domain/apple/machoSegmentHeader.ts | 35 + src/domain/binaryTarget.ts | 11 +- src/domain/elfHashSymbolCount.test.ts | 89 + src/domain/elfHashSymbolCount.ts | 92 + src/domain/elfProgramHeader.test.ts | 14 +- src/domain/elfProgramHeader.ts | 17 + src/domain/investigationProviders.ts | 7 + src/domain/native/binaryHardening.test.ts | 152 ++ src/domain/native/binaryHardening.ts | 187 ++ src/domain/peOptionalHeader.test.ts | 41 +- src/domain/peOptionalHeader.ts | 26 +- src/flutter/DartElf.test.ts | 2 + src/flutter/DartElf.ts | 102 +- src/native/MachoCommandNames.ts | 104 +- src/native/elf/ElfNoteParser.ts | 117 ++ .../hardening/BinaryHardening.fixture.ts | 1302 +++++++++++++ .../hardening/BinaryHardeningParsers.test.ts | 1721 +++++++++++++++++ .../hardening/BinaryHardeningProvider.ts | 244 +++ src/native/hardening/ElfHardening.ts | 1277 ++++++++++++ src/native/hardening/MachoHardening.ts | 645 ++++++ src/native/hardening/PeHardening.ts | 226 +++ src/native/metadata/ElfImage.ts | 14 +- src/native/metadata/MachoImage.ts | 17 +- .../metadata/NativeSectionDirectory.test.ts | 25 +- src/native/metadata/NativeSectionDirectory.ts | 95 +- src/native/metadata/PeImage.test.ts | 11 + src/native/metadata/PeImage.ts | 15 +- src/server/createServer.ts | 9 + src/server/registerBinaryHardeningTool.ts | 31 + tests/acceptance/binaryHardeningCli.test.ts | 87 + .../tools/localToolContracts.test.ts | 3 +- .../filesystem/binaryHardening.test.ts | 72 + tests/boundary/mcp/binaryHardeningMcp.test.ts | 65 + .../stableArtifactConsumers.test.ts | 8 + 84 files changed, 8506 insertions(+), 202 deletions(-) create mode 100644 docs/binary-hardening.md create mode 100644 docs/verification/binary-hardening-2026-10-31.md create mode 100644 scripts/verify-binary-hardening.mjs create mode 100644 src/application/binaryDiagnostics/BinaryHardeningPort.ts create mode 100644 src/application/binaryDiagnostics/BinaryHardeningService.test.ts create mode 100644 src/application/binaryDiagnostics/BinaryHardeningService.ts create mode 100644 src/domain/elfHashSymbolCount.test.ts create mode 100644 src/domain/elfHashSymbolCount.ts create mode 100644 src/domain/native/binaryHardening.test.ts create mode 100644 src/domain/native/binaryHardening.ts create mode 100644 src/native/hardening/BinaryHardening.fixture.ts create mode 100644 src/native/hardening/BinaryHardeningParsers.test.ts create mode 100644 src/native/hardening/BinaryHardeningProvider.ts create mode 100644 src/native/hardening/ElfHardening.ts create mode 100644 src/native/hardening/MachoHardening.ts create mode 100644 src/native/hardening/PeHardening.ts create mode 100644 src/server/registerBinaryHardeningTool.ts create mode 100644 tests/acceptance/binaryHardeningCli.test.ts create mode 100644 tests/boundary/filesystem/binaryHardening.test.ts create mode 100644 tests/boundary/mcp/binaryHardeningMcp.test.ts 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 }),