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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-06 01:07:00 +08:00
1 parent ec3410998b
commit 560674e2de
84 files changed
+8506 -202

No files matched your search

@@ -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
@@ -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
+1
View File
@@ -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) |
+1
View File
@@ -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" },
+62
View File
@@ -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.
+10
View File
@@ -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
+1 -1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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` |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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` |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
@@ -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`.
+1
View File
@@ -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",
+633
View File
@@ -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,
})),
});
}
+2 -1
View File
@@ -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") {
@@ -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<void>;
inspect(
input: InspectBinaryHardeningInput,
options?: ExecutionOptions,
): Promise<Result<BinaryHardening, AnalysisError>>;
}
@@ -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> = {},
): 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> = {},
): 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<BinaryHardening> = {},
imagePatch: Partial<BinaryHardeningImage> = {},
): 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<BinaryHardeningImage["slice"]> => ({
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<BinaryHardeningImage["slice"]> => ({
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");
});
@@ -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<void> {
return this.provider.close?.() ?? Promise.resolve();
}
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
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,
),
);
+13 -4
View File
@@ -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`,
+36
View File
@@ -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:
+1
View File
@@ -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",
+6
View File
@@ -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());
@@ -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"]),
+2
View File
@@ -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(
@@ -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",
+1
View File
@@ -40,6 +40,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
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",
+1
View File
@@ -266,6 +266,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
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,
@@ -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,
);
});
+156
View File
@@ -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<number, readonly string[]> = 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<Record<string, number>> | 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";
+20 -1
View File
@@ -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);
},
);
+19
View File
@@ -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<MachoSectionHeader, "size" | "offset" | "flags">,
imageBytes: number,
): Result<null, { readonly kind: "malformed"; readonly message: string }> =>
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",
});
+26 -1
View File
@@ -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,
);
});
+35
View File
@@ -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<typeof admitMachoSegmentMapping>[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;
};
+7 -4
View File
@@ -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:
+89
View File
@@ -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);
});
+92
View File
@@ -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<number, string>;
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<number | null, ElfHashCountFailure>,
void
>;
const CHECKPOINT_STEPS = 4096;
const malformed = (message: string): Result<never, ElfHashCountFailure> =>
err({ kind: "malformed", message });
const overBudget = (
label: string,
count: number,
): Result<never, ElfHashCountFailure> => 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<number, ElfHashCountFailure> => {
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);
}
}
+13 -1
View File
@@ -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);
});
+17
View File
@@ -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);
+7
View File
@@ -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);
+152
View File
@@ -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);
});
+187
View File
@@ -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<typeof rowSchema>;
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<typeof sliceSchema>;
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<typeof imageSchema>;
/**
* 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<typeof binaryHardeningSchema>;
+40 -1
View File
@@ -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 }],
+24 -2
View File
@@ -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 });
};
+2
View File
@@ -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);
});
});
+47 -55
View File
@@ -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)
+14 -90
View File
@@ -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<number, readonly string[]> = 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<string, number> | undefined;
const fixedNames = machoFixedWordFieldNames(frame.command);
let fixedFields: Readonly<Record<string, number>> | 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;
+117
View File
@@ -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<number, GnuPropertyWord> => {
const found = new Map<number, GnuPropertyWord>();
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;
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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<void> {
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<BinaryHardening> => {
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,
);
File diff suppressed because it is too large. Load diff
+645
View File
@@ -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<BinaryHardeningImage[]> => {
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<ReturnType<typeof readMachoContainer>>,
{ 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<BinaryHardeningImage> => {
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<typeof frameLoadCommands>[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.",
);
};
+226
View File
@@ -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<BinaryHardeningImage> => {
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 },
);
};
+9 -5
View File
@@ -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 }
+9 -8
View File
@@ -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 =
@@ -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");
+72 -23
View File
@@ -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 = <T>(
bytes: Buffer,
): ElfSectionDirectory => {
const steps = readElfSectionDirectory(bytes.length);
steps: Generator<NativeReadRequest, T, Buffer>,
): 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);
};
+11
View File
@@ -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) => {
+12 -3
View File
@@ -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"
+9
View File
@@ -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,
+31
View File
@@ -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),
);
};
@@ -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<string, string> =>
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,
);
@@ -242,6 +242,7 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
["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 } },
@@ -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();
}
});
});
@@ -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,
);
@@ -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 }),