mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
feat(native): list ELF and Mach-O initializer and finalizer functions (#9574)
* feat(native): list ELF and Mach-O initializer and finalizer functions inspect_native_initializers and inspect-native-initializers list the functions an ELF or Mach-O image runs before and after main: ELF DT_INIT, DT_FINI, preinit, init and fini arrays and .ctors/.dtors, and Mach-O __mod_init_func, __mod_term_func and __init_offsets, with slot addresses, resolved targets, symbols where available and relocation facts for relocatable objects. Nothing is executed. Slot, symbol, relocation and note budgets are shared across tables, sentinels are matched at the image width, and target-derived names are bounded and marked untrusted. * fix(native): keep ELF initializer slots unresolved when relocation coverage is partial A skipped, unreadable or over-budget section relocation table now keeps every slot it could reach unresolved instead of reading the stored word as raw or dropping a .ctors slot as a sentinel. Dynamic writes that only partly overlap a slot, or write it twice, leave it unresolved. Linked images no longer reapply retained non-SHF_ALLOC link-time records. The dynamic relocation walk now pauses at the shared reader's step boundaries and observes cancellation there, resolved targets need only their first byte mapped, and unreadable symbol names make the report incomplete. * fix(native): stop reporting unchecked Mach-O initializer words as resolved Object files now apply their own section relocations: an UNSIGNED entry against a defined symbol gives symbol_addend, one against an undefined symbol gives unresolved_import, and any other entry leaves the slot unresolved. Linked images without a fixup decoder, such as 32-bit ones, keep pointer slots unresolved when an LC_DYLD_INFO bind stream or LC_DYSYMTAB external relocations could replace the stored word. LC_DYLD_INFO rebases now report dyld_info_rebase instead of chained_fixup, and unreadable symbol names make the report incomplete. Section headers keep their declared reloff and nreloc for this. * docs(native): move initializer guide under docs/guides/native The documentation guide assigns analyst workflows to docs/guides/<domain>/. Move the guide there, place its navigation and directory entries in the Native binaries group, and update links from the docs index, CLI guide, spec and authored skill. The public /native-initializers route is unchanged. The guide and spec also describe per-slot relocation coverage, retained link-time records, Mach-O object relocations, undecoded bind streams, the dyld_info_rebase resolution and unreadable names. * fix(native): keep IFUNC and partly relocated ELF initializer slots unresolved An absolute relocation against an STT_GNU_IFUNC symbol no longer reports the resolver address as the target. Section relocation records now use their real write width, so a record that writes only part of a slot, starts inside it or repeats it leaves the slot unresolved instead of reading a raw zero or dropping a .ctors slot as a sentinel. On 32-bit ARM the Thumb bit is cleared when locating a target's section and file offset, while value_va keeps the stored callable value. * fix(native): locate Thumb Mach-O initializer targets and reject arm64_32 with a 64-bit header On CPU_TYPE_ARM the Thumb bit is cleared when locating and naming a target, since nlist values do not carry it. An arm64_32 image that declares a 64-bit header is rejected as malformed rather than read with 8-byte slots. The guide and spec describe IFUNC relocations, partly relocated section slots, Thumb targets and this rejection. * fix(native): index initializer address lookups and keep ambiguous mappings unknown ELF and Mach-O initializer lookups now build the shared file mapping index once per image, so each slot and target lookup is logarithmic in the number of load segments rather than a scan of every mapping. An address that load segments map ambiguously no longer falls back to a section header offset. Its file offset is reported as unknown and the report is incomplete; only genuinely unmapped spans use the section fallback. * fix(native): report Mach-O bind names over the name budget A bound import name longer than the 1024-byte name budget was dropped without a trace, although it is the only identity of an unresolved initializer target. It now counts toward the over-long name limitation and makes the report incomplete. * test(native): count mapping lookups instead of timing the load index regression The load-mapping regression asserted a wall-clock bound, which is sensitive to machine load. It now spies on the shared mapping module and requires the same number of mapping scans and index builds for 8 and 64 slots, so a per-slot scan of every load segment fails deterministically on a small fixture. * fix(native): yield while decoding legacy rebase runs * fix(native): preserve initializer mapping and coverage * fix(native): index legacy fixup spans * fix(native): preserve relocation uncertainty and bound bind lookup work * fix(native): validate ELF slot mappings and yield section relocation scans --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
61 files changed
+6938
-99
No files matched your search
@@ -313,6 +313,14 @@ explicit path or endpoint and do not need it.
|
||||
build-id or ABI-tag fields. A missing table is observed absence of those
|
||||
declarations, not proof the image never carried notes. See the
|
||||
[ELF notes guide](https://github.com/morluto/rea/blob/main/docs/elf-notes.md).
|
||||
- Code that runs before `main` or at exit in ELF or Mach-O:
|
||||
`inspect_native_initializers` with the explicit local file (and
|
||||
`architecture` for a universal Mach-O). This built-in inspection requires no
|
||||
disassembler. Preserve slot and target addresses, the `resolution` of each
|
||||
entry, and its limitations. An `unresolved` slot is not a zero pointer, and a
|
||||
missing table is not proof that no constructor exists. See the
|
||||
[initializer guide](https://github.com/morluto/rea/blob/main/docs/guides/native/native-initializers.md).
|
||||
|
||||
- ELF unwind function ranges: `inspect_elf_unwind_ranges` with the explicit
|
||||
local ELF32/ELF64 file. This built-in target-free inspection requires no
|
||||
disassembler. Preserve FDE start, end, size, containing section, exact
|
||||
|
||||
@@ -199,6 +199,20 @@ absence of those declarations, not proof the image never carried notes in
|
||||
another container. See
|
||||
[ELF note tables](https://github.com/morluto/rea/blob/main/docs/elf-notes.md).
|
||||
|
||||
## ELF and Mach-O initializer functions
|
||||
|
||||
Use `inspect_native_initializers` for an explicit local ELF or Mach-O image
|
||||
when the question is which functions run before `main` or at exit: ELF
|
||||
`DT_PREINIT_ARRAY`, `DT_INIT`, `DT_INIT_ARRAY`, `DT_FINI`, `DT_FINI_ARRAY` and
|
||||
legacy `.ctors`/`.dtors`, or Mach-O `__mod_init_func`, `__init_offsets` and
|
||||
`__mod_term_func`. Pass `architecture` for a universal Mach-O. The CLI
|
||||
equivalent is `inspect-native-initializers PATH`. PIE slots are resolved
|
||||
through RELATIVE, RELR and section relocations, and Mach-O slots through
|
||||
chained fixups. Keep each entry's `resolution`: `unresolved` means the value was
|
||||
not established, and `unresolved_import` names an external symbol. Page with
|
||||
`offset` and `limit`. See
|
||||
[ELF and Mach-O initializer functions](https://github.com/morluto/rea/blob/main/docs/guides/native/native-initializers.md).
|
||||
|
||||
## ELF unwind function ranges
|
||||
|
||||
Use `inspect_elf_unwind_ranges` for an explicit local ELF32/ELF64 image when
|
||||
|
||||
@@ -43,6 +43,11 @@ export const documentationSections = [
|
||||
source: "guides/native/binary-diagnostics.md",
|
||||
route: "/binary-diagnostics",
|
||||
},
|
||||
{
|
||||
text: "ELF and Mach-O initializer functions",
|
||||
source: "guides/native/native-initializers.md",
|
||||
route: "/native-initializers",
|
||||
},
|
||||
{
|
||||
text: "ELF unwind function ranges",
|
||||
source: "guides/native/elf-unwind-ranges.md",
|
||||
|
||||
@@ -16,6 +16,7 @@ product pages. The VitePress documentation source is this `docs/` tree.
|
||||
- [Native binary investigation](guides/native/native-investigation.md)
|
||||
- [Go binary build metadata](guides/native/go-binaries.md)
|
||||
- [Offline binary diagnostics](guides/native/binary-diagnostics.md)
|
||||
- [ELF and Mach-O initializer functions](guides/native/native-initializers.md)
|
||||
- [ELF unwind function ranges](guides/native/elf-unwind-ranges.md)
|
||||
- [Linux kernel build config](guides/native/kernel-build-config.md)
|
||||
- [Recorded crash evidence](guides/native/recorded-crashes.md)
|
||||
|
||||
@@ -462,6 +462,18 @@ The workflow reports note owners, types, descriptor hashes, and GNU build-id
|
||||
or ABI-tag fields when those descriptors match. A missing note table is
|
||||
observed absence of those declarations. See [ELF note tables](../elf-notes.md).
|
||||
|
||||
List ELF and Mach-O initializer and finalizer functions without executing the
|
||||
image:
|
||||
|
||||
```bash
|
||||
rea inspect-native-initializers /absolute/path/to/program --json
|
||||
```
|
||||
|
||||
The workflow reports `DT_INIT`, init/fini arrays, legacy `.ctors`/`.dtors`, and
|
||||
Mach-O `__mod_init_func`, `__init_offsets` and `__mod_term_func` entries with
|
||||
their resolved targets. Universal Mach-O files need `--architecture`. See
|
||||
[ELF and Mach-O initializer functions](../guides/native/native-initializers.md).
|
||||
|
||||
Inspect ELF unwind function ranges without executing the image:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# ELF and Mach-O initializer functions
|
||||
|
||||
`inspect_native_initializers` / `inspect-native-initializers` lists the
|
||||
functions that one local ELF or Mach-O image declares to run before `main` and
|
||||
at exit. It reads the file without executing it and does not need a
|
||||
disassembler provider.
|
||||
|
||||
```bash
|
||||
rea inspect-native-initializers /absolute/path/to/program --json
|
||||
rea inspect-native-initializers ./Universal.app/Contents/MacOS/App --architecture arm64 --json
|
||||
```
|
||||
|
||||
The CLI resolves relative paths against the caller's working directory. MCP
|
||||
requires an absolute filesystem path:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_native_initializers",
|
||||
"arguments": { "path": "/artifacts/program", "offset": 0, "limit": 64 }
|
||||
}
|
||||
```
|
||||
|
||||
A universal Mach-O requires `architecture` to select one slice. ELF files and
|
||||
thin Mach-O files do not take it.
|
||||
|
||||
## Sources
|
||||
|
||||
ELF entries are listed in this order:
|
||||
|
||||
| Kind | Source |
|
||||
| --------------- | ------------------------------------------------------------------------------------- |
|
||||
| `preinit_array` | `DT_PREINIT_ARRAY` / `DT_PREINIT_ARRAYSZ`, else `.preinit_array` |
|
||||
| `init` | `DT_INIT` |
|
||||
| `init_array` | `DT_INIT_ARRAY` / `DT_INIT_ARRAYSZ`, else `SHT_INIT_ARRAY` or `.init_array*` sections |
|
||||
| `ctors` | legacy `.ctors` and `.ctors.*` sections |
|
||||
| `fini` | `DT_FINI` |
|
||||
| `fini_array` | `DT_FINI_ARRAY` / `DT_FINI_ARRAYSZ`, else `SHT_FINI_ARRAY` or `.fini_array*` sections |
|
||||
| `dtors` | legacy `.dtors` and `.dtors.*` sections |
|
||||
|
||||
Section fallback applies when the dynamic tag is absent, as in static
|
||||
executables and relocatable objects. Mach-O entries come from sections typed
|
||||
`S_MOD_INIT_FUNC_POINTERS` (`mod_init`), `S_INIT_FUNC_OFFSETS`
|
||||
(`init_offsets`) and `S_MOD_TERM_FUNC_POINTERS` (`mod_term`).
|
||||
|
||||
## Entries
|
||||
|
||||
Each `entries[]` item reports:
|
||||
|
||||
- `slot_va`, `slot_file_offset`, `slot_bytes` and `slot_section`: where the
|
||||
pointer or offset is stored. For a universal Mach-O, file offsets are
|
||||
relative to the whole file, not the slice. For `init` and `fini` the slot is
|
||||
the `DT_INIT` / `DT_FINI` value field in the dynamic section. When that field
|
||||
is outside every load segment, `slot_va` and `slot_section` are null and a
|
||||
limitation says so.
|
||||
- `value_va`, `value_file_offset` and `section`: the resolved target address,
|
||||
its file offset and its containing section. These are null when the value
|
||||
was not established. A file offset is also null when load segments map the
|
||||
address ambiguously (overlapping segments with different file offsets, or
|
||||
file and zero-fill memory), and the report is then `incomplete`.
|
||||
- `symbol`: a defined symbol whose value equals the target exactly, or the
|
||||
import name of an unresolved symbolic relocation or chained bind.
|
||||
- `resolution`: how the value was obtained.
|
||||
|
||||
| Resolution | Meaning |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| `raw` | The stored word, used when no relocation or fixup covers it |
|
||||
| `relative_addend` | An `R_*_RELATIVE` relocation addend (RELA, or the REL in-place word) |
|
||||
| `relr` | A `DT_RELR` packed relative relocation |
|
||||
| `symbol_addend` | A section relocation against a defined symbol plus its addend |
|
||||
| `chained_fixup` | A Mach-O chained-fixup rebase target |
|
||||
| `dyld_info_rebase` | A Mach-O `LC_DYLD_INFO` rebase opcode target |
|
||||
| `offset_table` | A 32-bit `__init_offsets` value added to the image base |
|
||||
| `unresolved_import` | A relocation or bind against an undefined symbol |
|
||||
| `unresolved` | A slot whose value this inspection cannot establish |
|
||||
|
||||
A bound import name over the 1024-byte name budget is reported as no name and
|
||||
makes the report `incomplete` with a counted limitation.
|
||||
|
||||
Symbol and section names are untrusted file text and carry
|
||||
`trust: "observed-untrusted"`.
|
||||
|
||||
For PIE executables and shared objects the array slots are usually zero on
|
||||
disk. A slot is reported `unresolved`, never as its stored zero, when the
|
||||
dynamic relocation tables use an encoding this inspection does not decode
|
||||
(for example Android packed relocations or an unsupported machine). Relative
|
||||
relocations are decoded for x86-64, i386, AArch64 and 32-bit ARM. A slot is
|
||||
also `unresolved` when a dynamic relocation writes only part of it, starts
|
||||
inside it, or writes it more than once with different values.
|
||||
|
||||
Section `REL` / `RELA` tables are applied to slots that no dynamic relocation
|
||||
covers. When such a table is skipped for an unexpected entry size, cannot be
|
||||
read, or lies past the record budget, every slot it could reach that has no
|
||||
relocation found is `unresolved`, and a `.ctors` slot is not dropped as a
|
||||
sentinel. A section record that writes only part of a slot, starts inside it,
|
||||
or repeats it also leaves the slot `unresolved`. In a relocatable object a
|
||||
table reaches only the section named by its `sh_info`. In a linked image, a relocation section without `SHF_ALLOC`
|
||||
(such as one kept by `--emit-relocs`) was already applied by the static
|
||||
linker, so the slot's stored word is used rather than applying the record
|
||||
again.
|
||||
|
||||
An absolute relocation against an `STT_GNU_IFUNC` symbol is `unresolved`: the
|
||||
symbol value is the resolver, and the loader stores whatever the resolver
|
||||
returns.
|
||||
|
||||
On 32-bit ARM (ELF `EM_ARM` and Mach-O `CPU_TYPE_ARM`), bit 0 of a function
|
||||
pointer selects Thumb state. `value_va` keeps the pointer as stored, while
|
||||
`value_file_offset` and `section` describe the code address with that bit
|
||||
cleared. A Mach-O symbol is matched at either address.
|
||||
|
||||
In a relocatable object (`ET_REL`) every section starts at address 0. Slot
|
||||
and value addresses are offsets inside the named section, and a limitation
|
||||
says so.
|
||||
|
||||
Mach-O pointer slots are decoded through chained fixups or `LC_DYLD_INFO`
|
||||
opcodes for linked little-endian arm64 and x86-64 images. Other linked images,
|
||||
including 32-bit ones, have no fixup decoder here: their stored word is used
|
||||
only when the image declares no `LC_DYLD_INFO` bind stream, and a pointer slot
|
||||
is `unresolved` otherwise. Legacy external relocations (`LC_DYSYMTAB`
|
||||
`nextrel`) are not decoded, so they make an otherwise raw pointer slot
|
||||
`unresolved` in any image. In an object file (`MH_OBJECT`), each section's own
|
||||
relocation entries set the slot: a pointer-width `UNSIGNED` entry against a
|
||||
defined symbol gives `symbol_addend`, one against an undefined symbol gives
|
||||
`unresolved_import`, a section-ordinal entry keeps the stored address, and any
|
||||
other entry leaves the slot `unresolved`. An `arm64_32` image that declares a
|
||||
64-bit header is rejected as malformed, since its pointers are four bytes.
|
||||
|
||||
`.ctors` and `.dtors` values `0` and `-1` are list sentinels and are omitted.
|
||||
`-1` means all ones at the image's pointer width: `0xffffffff` in ELF32 and
|
||||
`0xffffffffffffffff` in ELF64. A 64-bit slot holding `0xffffffff` is listed.
|
||||
A stored zero in an initializer array is listed, so the array length stays
|
||||
visible.
|
||||
|
||||
## Paging and limits
|
||||
|
||||
`offset` and `limit` page the ordered list (default limit 64, maximum 4096).
|
||||
`coverage.examined_entries` counts every slot read, including omitted
|
||||
sentinels, and `total_entries` (the listed entries) is set when the inventory
|
||||
is complete. At most 65536 slots are examined; beyond that, or when relocation
|
||||
coverage is unknown, `status` and `coverage.status` are `incomplete`.
|
||||
|
||||
ELF symbol names come from at most 65536 symbols across all `SHT_SYMTAB` and
|
||||
`SHT_DYNSYM` tables together, and section `REL` / `RELA` relocations from at
|
||||
most 1048576 records across all relocation sections together. Mach-O symbol
|
||||
names come from at most 65536 symbols across all `LC_SYMTAB` commands
|
||||
together; a repeated `LC_SYMTAB` over the same ranges is ignored with a
|
||||
limitation. Reaching any of these budgets makes the report `incomplete` with a
|
||||
limitation.
|
||||
|
||||
A symbol name that cannot be read, because its string table is missing or its
|
||||
offset lies outside the table, is reported as no name and makes the report
|
||||
`incomplete` with a counted limitation. Name offset 0 is the empty name.
|
||||
|
||||
The dynamic relocation tables are read in steps that yield to other work and
|
||||
observe cancellation, so a large `DT_RELR` table does not block the server.
|
||||
|
||||
`limitations` lists the standing limitations, then at most 24 distinct
|
||||
observations from the image. Reasons a crafted table can repeat per row, such
|
||||
as symbol names over the 1024-byte budget or relocation sections with an
|
||||
unexpected entry size, are reported once with a count, and observations past
|
||||
the 24-note budget are counted in one closing line. The list never exceeds 40
|
||||
lines.
|
||||
|
||||
The selected file is limited to 64 MiB by default (`max_file_bytes`, 512 MiB
|
||||
maximum). Malformed headers, multiple `PT_DYNAMIC` segments and truncated
|
||||
dynamic sections fail without partial success.
|
||||
|
||||
This inspection does not prove that a listed address runs, and file order is
|
||||
not the runtime order: `DT_FINI` is listed before `fini_array`, while the
|
||||
dynamic linker runs `fini_array` first, and `.ctors` runs in reverse order. A
|
||||
missing tag or section is observed absence of that declaration, not proof that
|
||||
no code runs before `main`.
|
||||
|
||||
Legacy Mach-O bind lookup examines at most 4096 candidate runs per query and
|
||||
at most twice the file byte count across the decoded image's queries. These
|
||||
resource allowances bound sparse, overlapping run descriptions without expanding
|
||||
them into per-address objects. Exhaustion leaves pointer applicability unresolved
|
||||
and the report incomplete. Bind-stream parsing and initializer batches yield so
|
||||
other requests and cancellation can run.
|
||||
+1
-1
@@ -52,7 +52,7 @@ The [documentation directory](README.md) lists every guide by domain.
|
||||
| Task | Guides |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](guides/native/native-investigation.md), [Go metadata](guides/native/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), [ELF unwind ranges](guides/native/elf-unwind-ranges.md), [Linux kernel modules](kernel-modules.md), [Linux kernel build config](guides/native/kernel-build-config.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](guides/native/binary-diagnostics.md), [recorded crashes](guides/native/recorded-crashes.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](guides/native/native-investigation.md), [Go metadata](guides/native/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), [initializer functions](guides/native/native-initializers.md), [ELF unwind ranges](guides/native/elf-unwind-ranges.md), [Linux kernel modules](kernel-modules.md), [Linux kernel build config](guides/native/kernel-build-config.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](guides/native/binary-diagnostics.md), [recorded crashes](guides/native/recorded-crashes.md) |
|
||||
| Select a native provider or import format | [Ghidra semantics](providers/ghidra-provider.md), [Windows](providers/windows-ghidra-p0.md), [DOS](providers/ghidra-dos.md), [raw bytes](providers/raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](providers/ghidra-mips.md), [RISC-V](providers/ghidra-riscv.md), [PowerPC](ghidra-ppc.md), [PSP](providers/ghidra-psp.md), [NativeAOT](providers/ghidra-nativeaot.md), [IDA](providers/ida-provider.md), [Kuna](kuna-provider.md), [JEB](providers/jeb-analysis.md) |
|
||||
| Analyze JavaScript or Electron artifacts | [Static analysis](guides/javascript/javascript-artifact-reconstruction.md), [tracing and comparisons](guides/javascript/javascript-application-workflows.md), [application graph](guides/javascript/javascript-application-graph.md), [source recovery](guides/javascript/javascript-recovery.md) |
|
||||
| Compare static JavaScript with runtime observations | [Runtime attribution](guides/javascript/javascript-runtime-observation.md), [reconciliation](guides/javascript/javascript-runtime-reconciliation.md), [Electron observation](guides/javascript/electron-observation.md), [Electron fuses](electron-fuses.md) |
|
||||
|
||||
@@ -30,3 +30,4 @@ for new work, roadmap granularity and producer-owned contracts.
|
||||
- [Deno compile executable inventory and extraction](artifacts/deno-standalone/spec.md) ([plan](artifacts/deno-standalone/plan.md), [tasks](artifacts/deno-standalone/tasks.md))
|
||||
- [ELF unwind function ranges](native/elf-unwind-ranges/spec.md) ([plan](native/elf-unwind-ranges/plan.md), [tasks](native/elf-unwind-ranges/tasks.md))
|
||||
- [Linux kernel build config](native/kernel-build-config/spec.md) ([plan](native/kernel-build-config/plan.md), [tasks](native/kernel-build-config/tasks.md))
|
||||
- [Native initializers](native/native-initializers/spec.md) ([plan](native/native-initializers/plan.md), [tasks](native/native-initializers/tasks.md))
|
||||
@@ -0,0 +1,40 @@
|
||||
# Implementation plan
|
||||
|
||||
## Boundary and contract
|
||||
|
||||
The domain contract lives in
|
||||
[`src/domain/native/nativeInitializers.ts`](https://github.com/morluto/rea/blob/main/src/domain/native/nativeInitializers.ts):
|
||||
input schema, entry and coverage schemas, kinds, resolutions, standing
|
||||
limitations and budgets. `NativeInitializersService` validates caller input,
|
||||
re-parses provider output, checks paging and artifact identity, and builds
|
||||
Evidence with a file-offset location per returned slot. CLI and MCP adapters
|
||||
call the same service; the tool is registered with the `evidence` effect.
|
||||
|
||||
## Parsers
|
||||
|
||||
`NativeInitializersProvider` selects one image (thin or universal slice) and
|
||||
dispatches to the ELF or Mach-O parser. Both produce `CollectedInitializer`
|
||||
rows, and `finishInitializerReport` pages them, counts examined slots and
|
||||
appends limitations after the standing ones. Both parsers record observations
|
||||
through `InitializerNotes`, which keeps distinct messages to the note budget
|
||||
and tallies per-row reasons, so the limitations list stays bounded.
|
||||
|
||||
- `ElfInitializers.ts` reads the consumed `DT_*` tags once, builds sources in
|
||||
declaration order with a shared slot budget, and resolves each slot. Dynamic
|
||||
relocation writes come from the owner,
|
||||
`readElfRelocationWritesSteps` in `ElfPointerRelocations.ts`; the RELATIVE
|
||||
type per machine comes from the same owner's `elfRelativeRelocationType`.
|
||||
Section names and fallback file offsets come from an `addressRangeIndex`
|
||||
built once over the section headers and searched by address.
|
||||
- `ElfInitializerSymbols.ts` loads symbol tables under one total symbol
|
||||
budget and decodes section REL / RELA records under one total record budget,
|
||||
checking the caller signal during that walk.
|
||||
- `MachoInitializers.ts` reuses the shared Mach-O container and chained-fixup
|
||||
decoders, reads symbols under one budget across every `LC_SYMTAB`, and
|
||||
names target sections through the same address index.
|
||||
|
||||
## Effects and compatibility
|
||||
|
||||
The tool reads one stable local file and never executes it. It adds a new
|
||||
contract and changes no existing tool. `slot_va` is nullable only for
|
||||
`DT_INIT` / `DT_FINI` whose value field is unmapped.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Native initializer and finalizer functions
|
||||
|
||||
## Problem and outcome
|
||||
|
||||
Analysts reviewing a native image need the functions it declares to run before
|
||||
`main` and at exit, without executing it or opening a disassembler provider.
|
||||
`inspect_native_initializers` / `inspect-native-initializers` lists the
|
||||
initializer and finalizer slots of one local ELF or Mach-O image, with each
|
||||
slot's location, resolved target and how that target was obtained. The
|
||||
[user guide](../../../guides/native/native-initializers.md) documents the result.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Accept one local ELF32, ELF64, thin Mach-O or universal Mach-O image. A
|
||||
universal Mach-O requires `architecture` to select one slice; other inputs
|
||||
reject it. The CLI resolves relative paths; MCP requires an absolute path.
|
||||
- ELF sources, in order: `DT_PREINIT_ARRAY`, `DT_INIT`, `DT_INIT_ARRAY`, legacy
|
||||
`.ctors`, `DT_FINI`, `DT_FINI_ARRAY`, legacy `.dtors`. Without the dynamic tag,
|
||||
fall back to `SHT_*_ARRAY` sections or `.init_array*` / `.fini_array*` /
|
||||
`.preinit_array` names. Mach-O sources are sections typed
|
||||
`S_MOD_INIT_FUNC_POINTERS`, `S_INIT_FUNC_OFFSETS` and
|
||||
`S_MOD_TERM_FUNC_POINTERS`.
|
||||
- Resolve values through RELATIVE and RELR dynamic relocations, then section
|
||||
REL / RELA records for slots no dynamic relocation covers, then the stored
|
||||
word when relocation coverage is known. Mach-O uses chained-fixup rebases,
|
||||
`LC_DYLD_INFO` rebases (reported separately), `MH_OBJECT` section
|
||||
relocations and `__init_offsets` image-base offsets. Anything else is
|
||||
`unresolved`, never a stored zero.
|
||||
- Relocation coverage is per slot. A slot that a dynamic or section relocation
|
||||
only partly overwrites or writes more than once is `unresolved`, and so is an
|
||||
absolute relocation against an `STT_GNU_IFUNC` resolver. On 32-bit ARM the
|
||||
Thumb bit is cleared for target section and file-offset lookup only. A slot with no
|
||||
relocation found is `unresolved` when a dynamic table, or a section table
|
||||
that could reach it, was not fully read; such a `.ctors` slot is not dropped
|
||||
as a sentinel. A linked image's non-`SHF_ALLOC` relocation sections were
|
||||
applied at link time and are not reapplied. A Mach-O stored pointer word is
|
||||
used only when no undecoded bind stream or external relocation could
|
||||
replace it.
|
||||
- Relocatable objects (`ET_REL`) report section-relative offsets and say so.
|
||||
- `.ctors` / `.dtors` sentinels are 0 and all ones at the image's pointer width;
|
||||
they are omitted and counted in a limitation.
|
||||
- `DT_INIT` / `DT_FINI` have no slot pointer: their slot is the dynamic value
|
||||
field, and `slot_va` / `slot_section` are null when that field is outside
|
||||
every load segment.
|
||||
- Names are exact untrusted file text marked `observed-untrusted`. A target is
|
||||
named only by a defined symbol whose value equals it, or by the import name
|
||||
of an unresolved symbolic relocation or chained bind. An unreadable name
|
||||
(missing string table or offset outside it) is reported as no name and makes
|
||||
the report `incomplete`.
|
||||
- Bounds: 64 MiB default file budget (512 MiB maximum); 65536 slots in total;
|
||||
65536 symbols across all ELF symbol tables; 65536 symbols across all
|
||||
Mach-O `LC_SYMTAB` commands; 1048576 section relocation records across all
|
||||
relocation sections; 1024-byte names. Reaching a bound makes the report
|
||||
`incomplete` with a limitation.
|
||||
- Legacy Mach-O bind lookup permits at most 4096 candidate runs per query
|
||||
and twice the file byte count across image queries. Exhaustion leaves
|
||||
affected pointers unresolved; stream parsing and slot batches yield.
|
||||
- Limitations are bounded: at most 24 distinct image observations, repeated
|
||||
per-row reasons reported once with a count, and at most 40 lines in total.
|
||||
- Section lookups for slot and target addresses use an index built once per
|
||||
image, so their cost does not grow with headers times slots.
|
||||
- Paging uses `offset` / `limit` (default 64, maximum 4096) over a stable
|
||||
ordered index. `coverage.examined_entries` counts every slot read, including
|
||||
omitted sentinels; `total_entries` is set only when the inventory is complete.
|
||||
- Malformed headers, multiple `PT_DYNAMIC` segments and truncated dynamic
|
||||
sections fail without partial success. Cancellation is honored during slot
|
||||
collection, the section relocation walk and the dynamic relocation walk,
|
||||
which yields to other work at the shared reader's step boundaries.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Parser tests cover ELF64 PIE (RELA, RELR, imports, `DT_INIT` / `DT_FINI`),
|
||||
ELF32 REL in-place addends, `ET_REL` section relocations and sentinels,
|
||||
unsupported relocation machines, slot, symbol and relocation budgets,
|
||||
over-long names, tallied repeated observations, a near-maximal section table
|
||||
with a full slot budget, cancellation in the relocation walks, skipped section
|
||||
relocation tables, partly overwritten slots, retained link-time relocations,
|
||||
unreadable names, Mach-O chained fixups, `LC_DYLD_INFO` rebases, 32-bit images
|
||||
with undecoded binds, `MH_OBJECT` section relocations, `__init_offsets`, the
|
||||
shared and repeated `LC_SYMTAB` budget, and universal slices. CLI acceptance, filesystem boundary
|
||||
and MCP boundary tests exercise the public workflows and Evidence output.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Executing the image or proving that a listed address runs; runtime ordering
|
||||
(file order is reported); disassembling initializer bodies; PE/COFF TLS
|
||||
callbacks; Android packed relocations and machines other than x86-64, i386,
|
||||
AArch64 and 32-bit ARM for relocation decoding; Mach-O fixup decoding for
|
||||
images other than little-endian 64-bit arm64 and x86-64, and legacy
|
||||
`LC_DYSYMTAB` external relocations.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Define the domain contract, budgets and standing limitations.
|
||||
- [x] Decode ELF dynamic tags, arrays, legacy `.ctors` / `.dtors` and
|
||||
`DT_INIT` / `DT_FINI` with a shared slot budget.
|
||||
- [x] Resolve ELF slots through the shared dynamic relocation owner, then
|
||||
section REL / RELA records, under total symbol and relocation budgets.
|
||||
- [x] Decode Mach-O initializer pointers, `__init_offsets` and terminators.
|
||||
- [x] Page results and count every examined slot, including sentinels.
|
||||
- [x] Wire the shared service into CLI and MCP with Evidence provenance.
|
||||
- [x] Cover parsers, service, CLI acceptance, filesystem and MCP boundaries.
|
||||
- [x] Document the tool in the native initializers guide and CLI reference.
|
||||
@@ -630,6 +630,7 @@ const workflowAvailabilityFor = (
|
||||
return { reason: "available", remediation: null };
|
||||
if (
|
||||
name === "inspect_elf_notes" ||
|
||||
name === "inspect_native_initializers" ||
|
||||
name === "inspect_elf_unwind_ranges" ||
|
||||
name === "inspect_devicetree" ||
|
||||
name === "inspect_kernel_module" ||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
InspectNativeInitializersInput,
|
||||
NativeInitializers,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Stable read and initializer decode, independent of a disassembler session. */
|
||||
export interface NativeInitializersPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
close?(): Promise<void>;
|
||||
inspect(
|
||||
input: InspectNativeInitializersInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<NativeInitializers, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { NativeInitializersService } from "./NativeInitializersService.js";
|
||||
import { NATIVE_INITIALIZERS_PROVIDER } from "../../domain/investigationProviders.js";
|
||||
import type { NativeInitializers } from "../../domain/native/nativeInitializers.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const identity = NATIVE_INITIALIZERS_PROVIDER;
|
||||
const report: NativeInitializers = {
|
||||
artifact: { path: "/artifacts/app", sha256: "a".repeat(64), bytes: 4096 },
|
||||
format: "elf",
|
||||
status: "parsed",
|
||||
applicability: "applicable",
|
||||
architecture: "x86_64",
|
||||
bits: 64,
|
||||
byte_order: "little",
|
||||
machine: 62,
|
||||
elf_type: 3,
|
||||
slice: null,
|
||||
coverage: {
|
||||
status: "complete",
|
||||
examined_entries: 1,
|
||||
total_entries: 1,
|
||||
returned_entries: 1,
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "init_array",
|
||||
slot_va: "0x200",
|
||||
slot_file_offset: 512,
|
||||
slot_bytes: 8,
|
||||
value_va: "0x100",
|
||||
value_file_offset: 256,
|
||||
slot_section: { name: ".init_array", trust: "observed-untrusted" },
|
||||
section: { name: ".text", trust: "observed-untrusted" },
|
||||
symbol: { name: "ctor", trust: "observed-untrusted" },
|
||||
resolution: "relative_addend",
|
||||
detail: "DT_INIT_ARRAY",
|
||||
},
|
||||
],
|
||||
limitations: ["fixture limitation"],
|
||||
};
|
||||
|
||||
const serviceFor = (value: NativeInitializers) =>
|
||||
new NativeInitializersService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(value)),
|
||||
});
|
||||
|
||||
it("rejects a relative path and extra arguments before reading", async () => {
|
||||
let calls = 0;
|
||||
const service = new NativeInitializersService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
for (const input of [
|
||||
{ path: "app" },
|
||||
{ path: report.artifact.path, extra: true },
|
||||
]) {
|
||||
const result = await service.inspect(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
}
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("records the selected image and each slot range as Evidence", async () => {
|
||||
const result = await serviceFor(report).inspect({
|
||||
path: report.artifact.path,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
confidence: "observed",
|
||||
subject: { format: "elf", architecture: "x86_64" },
|
||||
normalized_result: report,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: report.artifact.path },
|
||||
{ kind: "file-offset-range", start: 512, end: 520 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a page that does not match the request", { offset: 1 }],
|
||||
["a returned count that differs from the entries", { returned_entries: 0 }],
|
||||
["complete coverage on an incomplete report", { status: "incomplete" }],
|
||||
] as const)("rejects provider output with %s", async (_label, coverage) => {
|
||||
const result = await serviceFor({
|
||||
...report,
|
||||
coverage: { ...report.coverage, ...coverage },
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("rejects a slot offset outside the artifact", async () => {
|
||||
const [entry] = report.entries;
|
||||
const result = await serviceFor({
|
||||
...report,
|
||||
entries: [{ ...entry!, slot_file_offset: 4092 }],
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("discards an observation if the caller cancels while the provider completes", async () => {
|
||||
const controller = new AbortController();
|
||||
const service = new NativeInitializersService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { NativeInitializersPort } from "./NativeInitializersPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectNativeInitializersInputSchema,
|
||||
nativeInitializersSchema,
|
||||
type InspectNativeInitializersInput,
|
||||
type NativeInitializers,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
import { parseAnalysisInput } from "../AnalysisInput.js";
|
||||
|
||||
const OPERATION = "inspect_native_initializers";
|
||||
|
||||
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
|
||||
export class NativeInitializersService {
|
||||
constructor(readonly provider: NativeInitializersPort) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const input = parseAnalysisInput(
|
||||
OPERATION,
|
||||
rawInput,
|
||||
inspectNativeInitializersInputSchema,
|
||||
options?.signal,
|
||||
);
|
||||
if (!input.ok) return input;
|
||||
const result = await this.provider.inspect(input.value, options);
|
||||
if (!result.ok) return result;
|
||||
const report = nativeInitializersSchema.safeParse(result.value);
|
||||
if (
|
||||
!report.success ||
|
||||
report.data.artifact.path !== input.value.path ||
|
||||
!validReport(report.data, input.value)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Native initializer provider returned malformed output or changed artifact identity.",
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
const locations = [
|
||||
{ kind: "artifact-path" as const, path: value.artifact.path },
|
||||
...value.entries.flatMap((entry) =>
|
||||
entry.slot_file_offset === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "file-offset-range" as const,
|
||||
start: entry.slot_file_offset,
|
||||
end: entry.slot_file_offset + entry.slot_bytes,
|
||||
},
|
||||
],
|
||||
),
|
||||
];
|
||||
return completeProviderEvidence(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: value.format,
|
||||
sha256: value.artifact.sha256,
|
||||
...(value.architecture === null
|
||||
? {}
|
||||
: { architecture: value.architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.value,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations,
|
||||
},
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validReport = (
|
||||
report: NativeInitializers,
|
||||
input: InspectNativeInitializersInput,
|
||||
): boolean => {
|
||||
const { coverage } = report;
|
||||
const inside = (offset: number | null, bytes: number): boolean =>
|
||||
offset === null || offset <= report.artifact.bytes - bytes;
|
||||
if (
|
||||
report.artifact.bytes > input.max_file_bytes ||
|
||||
coverage.offset !== input.offset ||
|
||||
coverage.limit !== input.limit ||
|
||||
report.entries.length !== coverage.returned_entries ||
|
||||
report.entries.length > input.limit ||
|
||||
coverage.returned_entries + coverage.offset >
|
||||
Math.max(coverage.examined_entries, coverage.offset) ||
|
||||
(coverage.status === "complete") !== (report.status === "parsed")
|
||||
)
|
||||
return false;
|
||||
return report.entries.every(
|
||||
(entry, position) =>
|
||||
entry.index === coverage.offset + position &&
|
||||
inside(entry.slot_file_offset, entry.slot_bytes) &&
|
||||
inside(entry.value_file_offset, 1),
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createRecordedCrashService,
|
||||
createBinaryLayoutService,
|
||||
createElfNotesService,
|
||||
createNativeInitializersService,
|
||||
createElfUnwindRangesService,
|
||||
createAppleIpsService,
|
||||
createDevicetreeService,
|
||||
@@ -36,6 +37,8 @@ import { inspectPeImportsInputSchema } from "../domain/native/peImports.js";
|
||||
import { inspectWindowsShortcutInputSchema } from "../domain/native/windowsShortcut.js";
|
||||
import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js";
|
||||
import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js";
|
||||
import { inspectNativeInitializersInputSchema } from "../domain/native/nativeInitializers.js";
|
||||
import { machoSliceArchitectureSchema } from "../domain/apple/machoTargetSelection.js";
|
||||
import { inspectElfUnwindRangesInputSchema } from "../domain/native/elfUnwindRanges.js";
|
||||
import { inspectAppleIpsInputSchema } from "../domain/native/appleIps.js";
|
||||
import { inspectKernelModuleInputSchema } from "../domain/native/kernelModule.js";
|
||||
@@ -94,6 +97,59 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
);
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectNativeInitializers, {
|
||||
description:
|
||||
"List ELF and Mach-O initializer and finalizer functions without executing the image",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
"Local ELF32/ELF64, thin Mach-O or universal Mach-O image",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
maxFileBytes: cliDecimalField(
|
||||
inspectNativeInitializersInputSchema.shape.max_file_bytes,
|
||||
),
|
||||
offset: cliDecimalField(
|
||||
inspectNativeInitializersInputSchema.shape.offset,
|
||||
),
|
||||
limit: cliDecimalField(inspectNativeInitializersInputSchema.shape.limit),
|
||||
architecture: machoSliceArchitectureSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"CPU family of the slice to read from a universal Mach-O; required for universal files",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation(async (signal) => {
|
||||
const initializers = createNativeInitializersService();
|
||||
return await logCliCommand(
|
||||
logger,
|
||||
CLI_COMMANDS.inspectNativeInitializers,
|
||||
async () =>
|
||||
finishCliEvidenceCommand(
|
||||
args.path,
|
||||
() =>
|
||||
initializers.inspect(
|
||||
{
|
||||
path: absoluteCallerPath(args.path),
|
||||
max_file_bytes: options.maxFileBytes,
|
||||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
...(options.architecture === undefined
|
||||
? {}
|
||||
: { architecture: options.architecture }),
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
initializers,
|
||||
"rea-native-initializers",
|
||||
"inspect_native_initializers",
|
||||
signal,
|
||||
).then(projectCliAnalysisResult),
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
cli.command(CLI_COMMANDS.inspectElfUnwindRanges, {
|
||||
description:
|
||||
"Inspect ELF unwind function ranges without executing the image",
|
||||
|
||||
@@ -83,6 +83,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
inspectNativeExports: "inspect-native-exports",
|
||||
inspectDevicetree: "inspect-devicetree",
|
||||
inspectElfNotes: "inspect-elf-notes",
|
||||
inspectNativeInitializers: "inspect-native-initializers",
|
||||
inspectElfUnwindRanges: "inspect-elf-unwind-ranges",
|
||||
inspectAppleIps: "inspect-apple-ips",
|
||||
inspectKernelModule: "inspect-kernel-module",
|
||||
|
||||
@@ -18,6 +18,8 @@ import { WindowsShortcutService } from "../application/binaryDiagnostics/Windows
|
||||
import { WindowsShortcutProvider } from "../native/windows/WindowsShortcutProvider.js";
|
||||
import { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js";
|
||||
import { ElfNotesProvider } from "../native/elf/ElfNotesProvider.js";
|
||||
import { NativeInitializersService } from "../application/binaryDiagnostics/NativeInitializersService.js";
|
||||
import { NativeInitializersProvider } from "../native/initializers/NativeInitializersProvider.js";
|
||||
import { ElfUnwindRangesService } from "../application/binaryDiagnostics/ElfUnwindRangesService.js";
|
||||
import { ElfUnwindRangesProvider } from "../native/elf/ElfUnwindRangesProvider.js";
|
||||
import { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsService.js";
|
||||
@@ -60,6 +62,9 @@ export const createNativeExportsService = (): NativeExportsService =>
|
||||
export const createElfNotesService = (): ElfNotesService =>
|
||||
new ElfNotesService(new ElfNotesProvider());
|
||||
|
||||
/** Portable ELF and Mach-O initializer and finalizer listing with no engine. */
|
||||
export const createNativeInitializersService = (): NativeInitializersService =>
|
||||
new NativeInitializersService(new NativeInitializersProvider());
|
||||
/** Portable ELF unwind-range inspection with no engine or disassembler. */
|
||||
export const createElfUnwindRangesService = (): ElfUnwindRangesService =>
|
||||
new ElfUnwindRangesService(new ElfUnwindRangesProvider());
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
NATIVE_EXPORTS_PROVIDER,
|
||||
NATIVE_INITIALIZERS_PROVIDER,
|
||||
PE_CERTIFICATES_PROVIDER,
|
||||
PE_IMPORTS_PROVIDER,
|
||||
PE_LOAD_CONFIG_PROVIDER,
|
||||
@@ -260,6 +261,13 @@ export const CAPABILITY_DECLARATIONS = {
|
||||
elfNotes: {
|
||||
providers: [providerBinding(ELF_NOTES_PROVIDER, ["inspect_elf_notes"])],
|
||||
},
|
||||
nativeInitializers: {
|
||||
providers: [
|
||||
providerBinding(NATIVE_INITIALIZERS_PROVIDER, [
|
||||
"inspect_native_initializers",
|
||||
]),
|
||||
],
|
||||
},
|
||||
elfUnwindRanges: {
|
||||
providers: [
|
||||
providerBinding(ELF_UNWIND_RANGES_PROVIDER, [
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
pwntoolsHostSupported,
|
||||
createDevicetreeService,
|
||||
createElfNotesService,
|
||||
createNativeInitializersService,
|
||||
createElfUnwindRangesService,
|
||||
createAppleIpsService,
|
||||
createKernelModuleService,
|
||||
@@ -362,6 +363,7 @@ const modules = {
|
||||
nativeExports: service(() => createNativeExportsService()),
|
||||
devicetree: service(() => createDevicetreeService()),
|
||||
elfNotes: service(() => createElfNotesService()),
|
||||
nativeInitializers: service(() => createNativeInitializersService()),
|
||||
elfUnwindRanges: service(() => createElfUnwindRangesService()),
|
||||
appleIps: service(() => createAppleIpsService()),
|
||||
kernelModule: service(() => createKernelModuleService()),
|
||||
|
||||
@@ -52,6 +52,10 @@ import {
|
||||
inspectElfNotesInputSchema,
|
||||
elfNotesSchema,
|
||||
} from "../../domain/native/elfNotes.js";
|
||||
import {
|
||||
inspectNativeInitializersInputSchema,
|
||||
nativeInitializersSchema,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
import {
|
||||
inspectElfUnwindRangesInputSchema,
|
||||
elfUnwindRangesSchema,
|
||||
@@ -137,6 +141,22 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "inspect_native_initializers",
|
||||
...toolContractMetadata("inspect_native_initializers"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"List the initializer and finalizer functions declared by one explicit local ELF32/ELF64 or Mach-O image (thin, or one selected universal slice) without executing it or opening a disassembler. ELF: DT_PREINIT_ARRAY, DT_INIT, DT_INIT_ARRAY, DT_FINI, DT_FINI_ARRAY and legacy .ctors/.dtors, with section fallback for relocatable and static images. Mach-O: S_MOD_INIT_FUNC_POINTERS, S_INIT_FUNC_OFFSETS and S_MOD_TERM_FUNC_POINTERS. Each paged entry gives the slot address and file offset, the resolved target address, file offset, containing section, an exact-address symbol name when one exists, and how the value was resolved (raw, RELATIVE addend, RELR, symbol plus addend, chained fixup, offset table, or unresolved import). Slots that relocations or fixups do not establish are reported unresolved, never as their stored zero. A missing tag or section is observed absence of that declaration, not proof that no code runs before main.",
|
||||
inputSchema: inspectNativeInitializersInputSchema,
|
||||
outputSchema: evidenceResultOf(nativeInitializersSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "List ELF or Mach-O static constructors and finalizers",
|
||||
input: { path: "/artifacts/program" },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "inspect_elf_notes",
|
||||
...toolContractMetadata("inspect_elf_notes"),
|
||||
|
||||
@@ -293,6 +293,7 @@ const TOOL_EFFECT_AUDIT: Readonly<Record<string, EffectAudit>> = {
|
||||
inspect_windows_shortcut: evidence,
|
||||
inspect_native_exports: evidence,
|
||||
inspect_elf_notes: evidence,
|
||||
inspect_native_initializers: evidence,
|
||||
inspect_elf_unwind_ranges: evidence,
|
||||
inspect_apple_ips: evidence,
|
||||
inspect_devicetree: evidence,
|
||||
|
||||
@@ -33,6 +33,8 @@ it.each(formats)(
|
||||
view.setUint32(36, Number(size), littleEndian);
|
||||
}
|
||||
view.setUint32(wide ? 48 : 40, 0xffffffff, littleEndian);
|
||||
view.setUint32(wide ? 56 : 48, 0x12345678, littleEndian);
|
||||
view.setUint32(wide ? 60 : 52, 0x9abcdef0, littleEndian);
|
||||
view.setUint32(wide ? 64 : 56, 0x89abcdef, littleEndian);
|
||||
const expectedSegmentName = Buffer.from(bytes.subarray(16, 32));
|
||||
const before = Buffer.from(backing);
|
||||
@@ -51,6 +53,8 @@ it.each(formats)(
|
||||
address,
|
||||
size,
|
||||
offset: 0xffffffff,
|
||||
relocationOffset: 0x12345678,
|
||||
relocationCount: 0x9abcdef0,
|
||||
flags: 0x89abcdef,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface MachoSectionHeader {
|
||||
readonly address: bigint;
|
||||
readonly size: bigint;
|
||||
readonly offset: number;
|
||||
/** `reloff` and `nreloc`: the section's own relocation entries, used by object files. */
|
||||
readonly relocationOffset: number;
|
||||
readonly relocationCount: number;
|
||||
readonly flags: number;
|
||||
}
|
||||
|
||||
@@ -34,6 +37,8 @@ export const readMachoSectionHeader = (
|
||||
? view.getBigUint64(40, littleEndian)
|
||||
: BigInt(view.getUint32(36, littleEndian)),
|
||||
offset: view.getUint32(wide ? 48 : 40, littleEndian),
|
||||
relocationOffset: view.getUint32(wide ? 56 : 48, littleEndian),
|
||||
relocationCount: view.getUint32(wide ? 60 : 52, littleEndian),
|
||||
flags: view.getUint32(wide ? 64 : 56, littleEndian),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -80,6 +80,13 @@ export const KERNEL_MODULE_PROVIDER = Object.freeze({
|
||||
version: "1",
|
||||
} as const);
|
||||
|
||||
/** Provider identity for portable, execution-free ELF and Mach-O initializer and finalizer tables. */
|
||||
export const NATIVE_INITIALIZERS_PROVIDER = Object.freeze({
|
||||
id: "rea-native-initializers",
|
||||
name: "REA ELF and Mach-O initializer parser",
|
||||
version: "1",
|
||||
} as const);
|
||||
|
||||
/** Provider identity for portable, execution-free Linux kernel banner and IKCONFIG decoding. */
|
||||
export const KERNEL_BUILD_CONFIG_PROVIDER = Object.freeze({
|
||||
id: "rea-kernel-build-config",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_NATIVE_INITIALIZER_PAGE,
|
||||
inspectNativeInitializersInputSchema,
|
||||
nativeInitializersSchema,
|
||||
NATIVE_INITIALIZER_LIMITATIONS,
|
||||
} from "./nativeInitializers.js";
|
||||
|
||||
it("applies paging defaults and rejects relative paths and extra arguments", () => {
|
||||
expect(
|
||||
inspectNativeInitializersInputSchema.parse({ path: "/artifacts/app" }),
|
||||
).toMatchObject({
|
||||
offset: 0,
|
||||
limit: DEFAULT_NATIVE_INITIALIZER_PAGE,
|
||||
max_file_bytes: 67108864,
|
||||
});
|
||||
for (const input of [
|
||||
{ path: "app" },
|
||||
{ path: "/artifacts/app", extra: true },
|
||||
{ path: "/artifacts/app", limit: 0 },
|
||||
{ path: "/artifacts/app", limit: 4097 },
|
||||
])
|
||||
expect(inspectNativeInitializersInputSchema.safeParse(input).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("admits an empty inventory and rejects an unknown resolution", () => {
|
||||
const report = {
|
||||
artifact: { path: "/artifacts/app", sha256: "a".repeat(64), bytes: 512 },
|
||||
format: "elf" as const,
|
||||
status: "parsed" as const,
|
||||
applicability: "applicable" as const,
|
||||
architecture: "x86_64" as const,
|
||||
bits: 64 as const,
|
||||
byte_order: "little" as const,
|
||||
machine: 62,
|
||||
elf_type: 3,
|
||||
slice: null,
|
||||
coverage: {
|
||||
status: "complete" as const,
|
||||
examined_entries: 0,
|
||||
total_entries: 0,
|
||||
returned_entries: 0,
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
},
|
||||
entries: [],
|
||||
limitations: [...NATIVE_INITIALIZER_LIMITATIONS],
|
||||
};
|
||||
expect(nativeInitializersSchema.parse(report)).toEqual(report);
|
||||
const entry = {
|
||||
index: 0,
|
||||
kind: "init_array",
|
||||
slot_va: "0x200",
|
||||
slot_file_offset: 512,
|
||||
slot_bytes: 8,
|
||||
value_va: null,
|
||||
value_file_offset: null,
|
||||
slot_section: null,
|
||||
section: null,
|
||||
symbol: null,
|
||||
resolution: "guessed",
|
||||
detail: null,
|
||||
};
|
||||
expect(
|
||||
nativeInitializersSchema.safeParse({ ...report, entries: [entry] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { machoSliceArchitectureSchema } from "../apple/machoTargetSelection.js";
|
||||
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_NATIVE_INITIALIZERS_FILE_BYTES = 536870912;
|
||||
/** Slots collected before the inventory is reported incomplete. */
|
||||
export const MAX_NATIVE_INITIALIZER_ENTRIES = 65536;
|
||||
/** Default page size for returned entries. */
|
||||
export const DEFAULT_NATIVE_INITIALIZER_PAGE = 64;
|
||||
/** Upper bound on one returned page. */
|
||||
export const MAX_NATIVE_INITIALIZER_PAGE = 4096;
|
||||
/** Bytes admitted for one symbol or section name. */
|
||||
export const MAX_NATIVE_INITIALIZER_NAME_BYTES = 1024;
|
||||
/** Distinct image observations listed before later ones are only counted. */
|
||||
export const MAX_NATIVE_INITIALIZER_NOTES = 24;
|
||||
/**
|
||||
* Upper bound on reported limitations: the standing ones, the note budget, a
|
||||
* line per tallied reason, an omission count and a withheld-name count.
|
||||
*/
|
||||
export const MAX_NATIVE_INITIALIZER_LIMITATIONS = 40;
|
||||
|
||||
const uint = z.number().int().min(0).max(0xffffffff);
|
||||
const safeNonNegative = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
const hexAddress = z.string().regex(/^0x[0-9a-f]+$/u);
|
||||
|
||||
/** Declaration order used for ELF entries. Mach-O kinds follow. */
|
||||
export const NATIVE_INITIALIZER_KINDS = [
|
||||
"preinit_array",
|
||||
"init",
|
||||
"init_array",
|
||||
"ctors",
|
||||
"fini",
|
||||
"fini_array",
|
||||
"dtors",
|
||||
"mod_init",
|
||||
"init_offsets",
|
||||
"mod_term",
|
||||
] as const;
|
||||
export type NativeInitializerKind = (typeof NATIVE_INITIALIZER_KINDS)[number];
|
||||
|
||||
/** How a slot value was obtained. `unresolved` is not a stored zero. */
|
||||
export const NATIVE_INITIALIZER_RESOLUTIONS = [
|
||||
"raw",
|
||||
"relative_addend",
|
||||
"relr",
|
||||
"chained_fixup",
|
||||
"dyld_info_rebase",
|
||||
"offset_table",
|
||||
"symbol_addend",
|
||||
"unresolved_import",
|
||||
"unresolved",
|
||||
] as const;
|
||||
export type NativeInitializerResolution =
|
||||
(typeof NATIVE_INITIALIZER_RESOLUTIONS)[number];
|
||||
|
||||
/** One local ELF or Mach-O image, optionally one selected universal slice. */
|
||||
export const inspectNativeInitializersInputSchema = z.strictObject({
|
||||
path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute filesystem path to an ELF or Mach-O image",
|
||||
),
|
||||
max_file_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(64)
|
||||
.max(MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES)
|
||||
.default(67108864)
|
||||
.describe("Maximum stable artifact bytes to read (default 64 MiB)"),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_NATIVE_INITIALIZER_ENTRIES)
|
||||
.default(0)
|
||||
.describe("Zero-based index of the first returned entry"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_NATIVE_INITIALIZER_PAGE)
|
||||
.default(DEFAULT_NATIVE_INITIALIZER_PAGE)
|
||||
.describe("Maximum entries to return (default 64, max 4096)"),
|
||||
architecture: machoSliceArchitectureSchema.optional(),
|
||||
});
|
||||
export type InspectNativeInitializersInput = z.infer<
|
||||
typeof inspectNativeInitializersInputSchema
|
||||
>;
|
||||
|
||||
const untrustedNameSchema = z.strictObject({
|
||||
name: z.string().min(1).max(MAX_NATIVE_INITIALIZER_NAME_BYTES),
|
||||
trust: z.literal("observed-untrusted"),
|
||||
});
|
||||
export type NativeInitializerName = z.infer<typeof untrustedNameSchema>;
|
||||
|
||||
const entrySchema = z.strictObject({
|
||||
index: uint,
|
||||
kind: z.enum(NATIVE_INITIALIZER_KINDS),
|
||||
slot_va: hexAddress.nullable(),
|
||||
slot_file_offset: safeNonNegative.nullable(),
|
||||
slot_bytes: z.union([z.literal(4), z.literal(8)]),
|
||||
value_va: hexAddress.nullable(),
|
||||
value_file_offset: safeNonNegative.nullable(),
|
||||
slot_section: untrustedNameSchema.nullable(),
|
||||
section: untrustedNameSchema.nullable(),
|
||||
symbol: untrustedNameSchema.nullable(),
|
||||
resolution: z.enum(NATIVE_INITIALIZER_RESOLUTIONS),
|
||||
detail: z.string().min(1).max(MAX_NATIVE_INITIALIZER_NAME_BYTES).nullable(),
|
||||
});
|
||||
export type NativeInitializerEntry = z.infer<typeof entrySchema>;
|
||||
|
||||
const coverageSchema = z.strictObject({
|
||||
status: z.enum(["complete", "incomplete"]),
|
||||
examined_entries: z.number().int().min(0).max(MAX_NATIVE_INITIALIZER_ENTRIES),
|
||||
total_entries: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_NATIVE_INITIALIZER_ENTRIES)
|
||||
.nullable(),
|
||||
returned_entries: z.number().int().min(0).max(MAX_NATIVE_INITIALIZER_PAGE),
|
||||
offset: z.number().int().min(0).max(MAX_NATIVE_INITIALIZER_ENTRIES),
|
||||
limit: z.number().int().min(1).max(MAX_NATIVE_INITIALIZER_PAGE),
|
||||
});
|
||||
export type NativeInitializerCoverage = z.infer<typeof coverageSchema>;
|
||||
|
||||
const sliceSchema = z.strictObject({
|
||||
file_offset: safeNonNegative,
|
||||
size: safeNonNegative.min(1),
|
||||
cpu_type: z.number().int(),
|
||||
cpu_subtype: z.number().int(),
|
||||
architecture: z.string().min(1),
|
||||
});
|
||||
export type NativeInitializerSlice = z.infer<typeof sliceSchema>;
|
||||
|
||||
/**
|
||||
* Facts this inspection does not establish. A missing tag is not proof that
|
||||
* no constructor runs, and file order is not the dynamic linker's order.
|
||||
*/
|
||||
export const NATIVE_INITIALIZER_LIMITATIONS = [
|
||||
"This inspection does not execute the image and does not prove that a listed address runs before main or at exit.",
|
||||
"A missing dynamic tag or section is observed absence of that declaration, not proof that no constructor exists in code.",
|
||||
"Symbol and section names are exact file text and are untrusted. A resolved address is named only when a defined symbol value equals that address. An unresolved import keeps the relocation symbol name.",
|
||||
"PIE and shared-object slots are resolved through RELATIVE and RELR dynamic relocations, and through section REL or RELA records when no dynamic relocation covers the slot. Other relocation kinds stay unresolved.",
|
||||
".ctors and .dtors values 0 and -1 are sentinels and are omitted. Those sections are listed in file order, not reverse runtime order.",
|
||||
"DT_FINI is listed before fini_array. The dynamic linker runs fini_array first at exit.",
|
||||
"Mach-O __init_offsets entries are 32-bit offsets from the image base, not pointers. Chained binds are unresolved imports.",
|
||||
] as const;
|
||||
|
||||
const artifactSchema = z.strictObject({
|
||||
path: absoluteLocalPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES),
|
||||
});
|
||||
|
||||
/** Ordered initializer and finalizer slots for one ELF or Mach-O image. */
|
||||
export const nativeInitializersSchema = z.strictObject({
|
||||
artifact: artifactSchema,
|
||||
format: z.enum(["elf", "mach-o"]),
|
||||
status: z.enum(["parsed", "incomplete"]),
|
||||
applicability: z.literal("applicable"),
|
||||
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(),
|
||||
elf_type: uint.nullable(),
|
||||
slice: sliceSchema.nullable(),
|
||||
coverage: coverageSchema,
|
||||
entries: z.array(entrySchema),
|
||||
limitations: z
|
||||
.array(z.string().min(1))
|
||||
.max(MAX_NATIVE_INITIALIZER_LIMITATIONS),
|
||||
});
|
||||
export type NativeInitializers = z.infer<typeof nativeInitializersSchema>;
|
||||
|
||||
/** Parser result before the provider attaches the stable artifact identity. */
|
||||
export type NativeInitializerInspection = Omit<NativeInitializers, "artifact">;
|
||||
@@ -515,6 +515,7 @@ it("indexes 25,000 Mach-O load regions and sections without quadratic stalls and
|
||||
declared_range: { offset: BigInt(offset), size: BigInt(slotBytes) },
|
||||
segment_mapping: mapping,
|
||||
segment_unmapped: false,
|
||||
relocations: { offset: 0, count: 0 },
|
||||
offset,
|
||||
size: slotBytes,
|
||||
file_backed: true,
|
||||
@@ -710,6 +711,7 @@ it.each([
|
||||
declared_range: { offset: 0n, size: 16n },
|
||||
segment_mapping: roMapping,
|
||||
segment_unmapped: false,
|
||||
relocations: { offset: 0, count: 0 },
|
||||
offset: 0,
|
||||
size: 16,
|
||||
file_backed: true,
|
||||
@@ -726,6 +728,7 @@ it.each([
|
||||
declared_range: { offset: 16n, size: 16n },
|
||||
segment_mapping: dataMapping,
|
||||
segment_unmapped: false,
|
||||
relocations: { offset: 0, count: 0 },
|
||||
offset: 16,
|
||||
size: 16,
|
||||
file_backed: true,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../native/metadata/BinaryContainer.js";
|
||||
import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js";
|
||||
import {
|
||||
parsePointerFixups,
|
||||
parsePointerFixupsSteps,
|
||||
type PointerFixups,
|
||||
} from "../native/AppleMachoFixups.js";
|
||||
import {
|
||||
@@ -151,7 +151,7 @@ export function* openImageSteps(
|
||||
binaryMetadataArchitecture(container.architecture) ??
|
||||
container.architecture,
|
||||
);
|
||||
machoPointerFixups = parsePointerFixups(
|
||||
machoPointerFixups = yield* parsePointerFixupsSteps(
|
||||
image,
|
||||
layout.fixups,
|
||||
layout.segments,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { decodeAppleDispatchMetadataSteps } from "./AppleDispatchMetadata.js";
|
||||
|
||||
export const decodeAppleDispatchMetadata = (
|
||||
...args: Parameters<typeof decodeAppleDispatchMetadataSteps>
|
||||
) => {
|
||||
const steps = decodeAppleDispatchMetadataSteps(...args);
|
||||
for (;;) {
|
||||
const next = steps.next();
|
||||
if (next.done) return next.value;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js";
|
||||
import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.fixture.js";
|
||||
|
||||
const base = 0x100000000n;
|
||||
const fixture = (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setImmediate as immediate } from "node:timers/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { isSelectedRegularFileError } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacosProvide
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { pushDispatchCoverage } from "./AppleDispatchCoverage.js";
|
||||
import { parsePointerFixups } from "./AppleMachoFixups.js";
|
||||
import { parsePointerFixupsSteps } from "./AppleMachoFixups.js";
|
||||
import { BinaryFormatFailure } from "./metadata/BinaryContainer.js";
|
||||
import {
|
||||
parseMachoLayout,
|
||||
@@ -60,17 +61,17 @@ import {
|
||||
import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js";
|
||||
|
||||
/** Decode validated little-endian 64-bit Apple Objective-C metadata without loading target code. */
|
||||
export const decodeAppleDispatchMetadata = (
|
||||
export function* decodeAppleDispatchMetadataSteps(
|
||||
bytes: Buffer,
|
||||
maxRecords: number,
|
||||
provenance: { path: string; sha256: string },
|
||||
architecture = "arm64",
|
||||
objcClassImportAddresses?: readonly string[],
|
||||
objcClassImportAddressesComplete = true,
|
||||
): ObjcSwiftMetadata => {
|
||||
): Generator<void, ObjcSwiftMetadata, void> {
|
||||
const layout = parseMachoLayout(bytes, architecture);
|
||||
const { segments, sections, offset } = layout;
|
||||
const fixups = parsePointerFixups(
|
||||
const fixups = yield* parsePointerFixupsSteps(
|
||||
bytes,
|
||||
layout.fixups,
|
||||
segments,
|
||||
@@ -287,7 +288,7 @@ export const decodeAppleDispatchMetadata = (
|
||||
swift,
|
||||
});
|
||||
return objcSwiftMetadataSchema.parse(result);
|
||||
};
|
||||
}
|
||||
|
||||
const OPERATION = "inspect_native_dispatch_metadata";
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
@@ -385,20 +386,27 @@ export const inspectAppleDispatchMetadata = async (
|
||||
},
|
||||
"reject",
|
||||
);
|
||||
const steps = decodeAppleDispatchMetadataSteps(
|
||||
bytes,
|
||||
maxRecords,
|
||||
{ path: target.path, sha256: digest },
|
||||
target.architecture,
|
||||
options.objcClassImportAddresses,
|
||||
options.objcClassImportAddressesComplete ?? true,
|
||||
);
|
||||
let next = steps.next();
|
||||
while (!next.done) {
|
||||
await immediate();
|
||||
if (signal?.aborted) throw cancelled();
|
||||
next = steps.next();
|
||||
}
|
||||
return nativeDispatchMetadataResultSchema.parse({
|
||||
target_sha256: target.sha256,
|
||||
target_architecture: target.architecture,
|
||||
provider: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider,
|
||||
analysis_profile_digest: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
|
||||
input_admission: { ...admission, retained_bytes: bytes.length },
|
||||
result: decodeAppleDispatchMetadata(
|
||||
bytes,
|
||||
maxRecords,
|
||||
{ path: target.path, sha256: digest },
|
||||
target.architecture,
|
||||
options.objcClassImportAddresses,
|
||||
options.objcClassImportAddressesComplete ?? true,
|
||||
),
|
||||
result: next.value,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { parsePointerFixupsSteps } from "./AppleMachoFixups.js";
|
||||
|
||||
export const parsePointerFixups = (
|
||||
...args: Parameters<typeof parsePointerFixupsSteps>
|
||||
) => {
|
||||
const steps = parsePointerFixupsSteps(...args);
|
||||
for (;;) {
|
||||
const next = steps.next();
|
||||
if (next.done) return next.value;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
decodeChainedPointer,
|
||||
parsePointerFixups,
|
||||
} from "./AppleMachoFixups.js";
|
||||
import { decodeChainedPointer } from "./AppleMachoFixups.js";
|
||||
import { parsePointerFixups } from "./AppleMachoFixups.fixture.js";
|
||||
import type { Segment } from "./AppleMachoSelection.js";
|
||||
|
||||
const BASE = 0x100000000n;
|
||||
@@ -562,9 +560,9 @@ it("retains unaligned pointer locations accepted by dyld's rebase state checks",
|
||||
kind: "rebase",
|
||||
target: 0x1234n,
|
||||
});
|
||||
expect(fixups.decode(BASE + 0x100n, 0n)).toEqual({
|
||||
kind: "raw",
|
||||
target: 0n,
|
||||
expect(fixups.decode(BASE + 0x100n, 0n)).toMatchObject({
|
||||
kind: "unsupported",
|
||||
reason: "A legacy fixup partially overlaps the pointer slot",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1589,6 +1587,32 @@ describe("chained import library identity", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
it("keeps both pointer slots unresolved when a legacy bind overlaps them", () => {
|
||||
const opcodes = Buffer.from([
|
||||
0x11,
|
||||
0x40,
|
||||
...Buffer.from("_foo\0"),
|
||||
0x51,
|
||||
0x71,
|
||||
0x04,
|
||||
0x90,
|
||||
0x00,
|
||||
]);
|
||||
const fixups = parsePointerFixups(
|
||||
opcodes,
|
||||
{
|
||||
chained: null,
|
||||
binds: [{ offset: 0, size: opcodes.length, stream: "bind" }],
|
||||
rebases: [],
|
||||
dylibs: ["/lib"],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.decode(BASE + 0x4000n, 123n).kind).toBe("unsupported");
|
||||
expect(fixups.decode(BASE + 0x4008n, 456n).kind).toBe("unsupported");
|
||||
});
|
||||
|
||||
describe("legacy pointer fixup streams", () => {
|
||||
it("interprets legacy bind, weak-bind and lazy-bind opcodes", () => {
|
||||
const opcodes = Buffer.from([
|
||||
|
||||
+167
-53
@@ -124,8 +124,20 @@ interface BindRun {
|
||||
| Extract<DecodedPointer, { readonly kind: "unsupported" }>;
|
||||
}
|
||||
|
||||
interface BindFacts {
|
||||
const BIND_QUERY_CANDIDATES = 4096;
|
||||
const BIND_QUERY_LIMITATION =
|
||||
"Legacy bind lookup candidate allowance exhausted; pointer applicability is unknown";
|
||||
|
||||
interface BindRunIndex {
|
||||
remaining: number;
|
||||
exhausted: boolean;
|
||||
readonly runs: readonly BindRun[];
|
||||
/** Largest final bind address among runs up to the same index. */
|
||||
readonly prefixEnd: readonly bigint[];
|
||||
}
|
||||
|
||||
interface BindFacts {
|
||||
readonly index: BindRunIndex;
|
||||
readonly failures: readonly string[];
|
||||
readonly issues: readonly DecodeIssue[];
|
||||
readonly examined: number;
|
||||
@@ -605,7 +617,7 @@ const chainedFixups = (
|
||||
raw,
|
||||
reason: `LC_DYLD_INFO pointer-fixup applicability was not completely decoded: ${applicabilityFailures.join("; ")}`,
|
||||
};
|
||||
const legacyBind = resolveLegacyBind(binds.runs, address);
|
||||
const legacyBind = resolveLegacyBind(binds.index, address);
|
||||
const legacyRebase = resolveLegacyRebase(
|
||||
segments,
|
||||
rebases.membership,
|
||||
@@ -613,6 +625,18 @@ const chainedFixups = (
|
||||
raw,
|
||||
);
|
||||
if (legacyBind?.kind === "unsupported") return legacyBind;
|
||||
if (legacyRebase?.kind === "unsupported") return legacyRebase;
|
||||
if (
|
||||
rebases.complete &&
|
||||
binds.complete &&
|
||||
hasPartialLegacyPointerWrite(segments, rebases, binds, address)
|
||||
)
|
||||
return {
|
||||
kind: "unsupported",
|
||||
reason: binds.index.exhausted
|
||||
? BIND_QUERY_LIMITATION
|
||||
: "A legacy fixup partially overlaps the pointer slot",
|
||||
};
|
||||
if (legacyBind !== undefined && legacyRebase !== undefined)
|
||||
return {
|
||||
kind: "unsupported",
|
||||
@@ -758,11 +782,11 @@ const markRebaseLocation = (
|
||||
};
|
||||
|
||||
/** Interpret one bounded LC_DYLD_INFO rebase stream into file-backed membership. */
|
||||
const readRebaseStream = (
|
||||
function* readRebaseStreamSteps(
|
||||
image: FixupImage,
|
||||
range: FixupCommands["rebases"][number],
|
||||
index: { bitmap: Uint8Array | null; workRemaining: number },
|
||||
): void => {
|
||||
): Generator<void, void, void> {
|
||||
const { bytes, segments } = image;
|
||||
const end = range.offset + range.size;
|
||||
if (
|
||||
@@ -778,12 +802,13 @@ const readRebaseStream = (
|
||||
let segmentIndex = -1;
|
||||
let segmentOffset = 0n;
|
||||
let type = 0;
|
||||
let visits = 0;
|
||||
// dyld keeps the cursor in a uint64_t, so ULEB deltas wrap to encode
|
||||
// backward steps; addRun still bounds every emitted slot to the segment.
|
||||
const advance = (delta: bigint): void => {
|
||||
segmentOffset = (segmentOffset + delta) & 0xffffffffffffffffn;
|
||||
};
|
||||
const addRun = (count: bigint, skip: bigint): void => {
|
||||
function* addRun(count: bigint, skip: bigint): Generator<void, void, void> {
|
||||
if (count === 0n) return;
|
||||
if (type === 0 || type > 3)
|
||||
throw new RangeError(`Unsupported legacy rebase type ${type}`);
|
||||
@@ -809,6 +834,7 @@ const readRebaseStream = (
|
||||
for (let indexOffset = 0n; indexOffset < admitted; indexOffset++) {
|
||||
const fileOffset = Number(firstFileOffset + indexOffset * stride);
|
||||
markRebaseLocation(index, fileOffset, fact, bytes.length);
|
||||
if (++visits % 4096 === 0) yield;
|
||||
}
|
||||
index.workRemaining -= Number(admitted);
|
||||
if (admitted < count)
|
||||
@@ -818,8 +844,10 @@ const readRebaseStream = (
|
||||
// dyld advances after the final record too; that cursor may legally be
|
||||
// one past the segment because it is only bounds-checked on the next emit.
|
||||
advance(count * stride);
|
||||
};
|
||||
}
|
||||
let opcodes = 0;
|
||||
while (cursor.at < end) {
|
||||
if (++opcodes % 4096 === 0) yield;
|
||||
if (index.workRemaining === 0)
|
||||
throw new RangeError(
|
||||
"Legacy rebase parsing exceeded the source-derived work budget",
|
||||
@@ -853,17 +881,17 @@ const readRebaseStream = (
|
||||
advance(BigInt(immediate) * REBASE_POINTER_SIZE);
|
||||
break;
|
||||
case 0x50: // REBASE_OPCODE_DO_REBASE_IMM_TIMES
|
||||
addRun(BigInt(immediate), 0n);
|
||||
yield* addRun(BigInt(immediate), 0n);
|
||||
break;
|
||||
case 0x60: {
|
||||
// REBASE_OPCODE_DO_REBASE_ULEB_TIMES
|
||||
const count = uleb(bytes, cursor, end, "rebase", index);
|
||||
addRun(count, 0n);
|
||||
yield* addRun(count, 0n);
|
||||
break;
|
||||
}
|
||||
case 0x70: {
|
||||
// REBASE_OPCODE_DO_REBASE_ADD_ADDR_ULEB
|
||||
addRun(1n, 0n);
|
||||
yield* addRun(1n, 0n);
|
||||
advance(uleb(bytes, cursor, end, "rebase", index));
|
||||
break;
|
||||
}
|
||||
@@ -871,7 +899,7 @@ const readRebaseStream = (
|
||||
// REBASE_OPCODE_DO_REBASE_ULEB_TIMES_SKIPPING_ULEB
|
||||
const count = uleb(bytes, cursor, end, "rebase", index);
|
||||
const skip = uleb(bytes, cursor, end, "rebase", index);
|
||||
addRun(count, skip);
|
||||
yield* addRun(count, skip);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -880,7 +908,7 @@ const readRebaseStream = (
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Read loader-owned membership without interpreting a stored pointer word. */
|
||||
const legacyRebaseState = (
|
||||
@@ -932,10 +960,10 @@ const resolveLegacyRebase = (
|
||||
};
|
||||
};
|
||||
|
||||
const parseLegacyRebases = (
|
||||
function* parseLegacyRebasesSteps(
|
||||
image: FixupImage,
|
||||
streams: FixupCommands["rebases"],
|
||||
): RebaseFacts => {
|
||||
): Generator<void, RebaseFacts, void> {
|
||||
// Dyld does not require pointer alignment here, so up to one starting byte
|
||||
// per source byte is possible. Bound opcode scanning plus slot visits by 2N.
|
||||
const workBudget = Math.min(Number.MAX_SAFE_INTEGER, image.bytes.length * 2);
|
||||
@@ -948,7 +976,7 @@ const parseLegacyRebases = (
|
||||
let unreadBytes = 0;
|
||||
for (const stream of streams) {
|
||||
try {
|
||||
readRebaseStream(image, stream, index);
|
||||
yield* readRebaseStreamSteps(image, stream, index);
|
||||
decoded++;
|
||||
} catch (cause: unknown) {
|
||||
failures.push(
|
||||
@@ -968,7 +996,7 @@ const parseLegacyRebases = (
|
||||
unreadBytes,
|
||||
complete: failures.length === 0,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const sleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
|
||||
let value = 0n;
|
||||
@@ -1067,11 +1095,11 @@ const setBindRegister = (
|
||||
};
|
||||
|
||||
/** Interpret one `LC_DYLD_INFO` bind opcode stream into address -> import. */
|
||||
const readBindStream = (
|
||||
function* readBindStreamSteps(
|
||||
image: FixupImage,
|
||||
range: FixupCommands["binds"][number],
|
||||
binds: BindRun[],
|
||||
): void => {
|
||||
): Generator<void, void, void> {
|
||||
const { bytes } = image;
|
||||
const end = range.offset + range.size;
|
||||
if (end > bytes.length)
|
||||
@@ -1151,7 +1179,9 @@ const readBindStream = (
|
||||
// bind the current pointer slot again with DO_BIND_ADD_ADDR_ULEB.
|
||||
state.address = (state.address + count * stride) & 0xffffffffffffffffn;
|
||||
};
|
||||
let opcodes = 0;
|
||||
while (cursor.at < end) {
|
||||
if (++opcodes % 4096 === 0) yield;
|
||||
const byte = bytes[cursor.at++] ?? 0;
|
||||
if (setBindRegister(image, { cursor, end }, state, byte)) continue;
|
||||
switch (byte & 0xf0) {
|
||||
@@ -1182,28 +1212,78 @@ const readBindStream = (
|
||||
throw new RangeError(`Unsupported bind opcode 0x${byte.toString(16)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Index immutable bind runs once; it lives with this image's decoded fixups. */
|
||||
const indexBindRuns = (
|
||||
runs: readonly BindRun[],
|
||||
fileBytes: number,
|
||||
): BindRunIndex => {
|
||||
const sorted = [...runs].sort((a, b) =>
|
||||
a.start < b.start ? -1 : a.start > b.start ? 1 : 0,
|
||||
);
|
||||
const prefixEnd: bigint[] = [];
|
||||
let last = -1n;
|
||||
for (const run of sorted) {
|
||||
const end = run.start + (run.count - 1n) * run.stride;
|
||||
if (end > last) last = end;
|
||||
prefixEnd.push(last);
|
||||
}
|
||||
return {
|
||||
runs: sorted,
|
||||
prefixEnd,
|
||||
remaining: fileBytes * 2,
|
||||
exhausted: false,
|
||||
};
|
||||
};
|
||||
|
||||
/** Visit runs with a bind start in the inclusive interval, without expanding runs. */
|
||||
function* bindRunsIn(
|
||||
index: BindRunIndex,
|
||||
low: bigint,
|
||||
high: bigint,
|
||||
): Generator<BindRun> {
|
||||
let left = 0;
|
||||
let right = index.runs.length;
|
||||
while (left < right) {
|
||||
const middle = (left + right) >>> 1;
|
||||
if (index.runs[middle]!.start <= high) left = middle + 1;
|
||||
else right = middle;
|
||||
}
|
||||
let candidates = 0;
|
||||
for (let at = left - 1; at >= 0 && index.prefixEnd[at]! >= low; at -= 1) {
|
||||
if (
|
||||
index.exhausted ||
|
||||
index.remaining === 0 ||
|
||||
candidates === BIND_QUERY_CANDIDATES
|
||||
) {
|
||||
index.exhausted = true;
|
||||
return;
|
||||
}
|
||||
index.remaining--;
|
||||
candidates++;
|
||||
const run = index.runs[at]!;
|
||||
const first =
|
||||
low <= run.start ? 0n : (low - run.start + run.stride - 1n) / run.stride;
|
||||
if (first < run.count && run.start + first * run.stride <= high) yield run;
|
||||
}
|
||||
}
|
||||
|
||||
const resolveLegacyBind = (
|
||||
runs: readonly BindRun[],
|
||||
index: BindRunIndex,
|
||||
address: bigint,
|
||||
): PointerResolution | undefined => {
|
||||
let match: BindRun | undefined;
|
||||
for (const run of runs) {
|
||||
const distance = address - run.start;
|
||||
if (
|
||||
distance >= 0n &&
|
||||
distance % run.stride === 0n &&
|
||||
distance / run.stride < run.count
|
||||
) {
|
||||
if (match !== undefined)
|
||||
return {
|
||||
kind: "unsupported",
|
||||
reason: `Ambiguous bind records target pointer location 0x${address.toString(16)}`,
|
||||
};
|
||||
match = run;
|
||||
}
|
||||
for (const run of bindRunsIn(index, address, address)) {
|
||||
if (match !== undefined)
|
||||
return {
|
||||
kind: "unsupported",
|
||||
reason: `Ambiguous bind records target pointer location 0x${address.toString(16)}`,
|
||||
};
|
||||
match = run;
|
||||
}
|
||||
if (index.exhausted)
|
||||
return { kind: "unsupported", reason: BIND_QUERY_LIMITATION };
|
||||
return match === undefined
|
||||
? undefined
|
||||
: match.binding.kind === "unsupported"
|
||||
@@ -1211,16 +1291,16 @@ const resolveLegacyBind = (
|
||||
: { kind: "bind", ...match.binding.value };
|
||||
};
|
||||
|
||||
const parseLegacyBinds = (
|
||||
function* parseLegacyBindsSteps(
|
||||
image: FixupImage,
|
||||
streams: FixupCommands["binds"],
|
||||
): BindFacts => {
|
||||
): Generator<void, BindFacts, void> {
|
||||
const runs: BindRun[] = [];
|
||||
const failures: string[] = [];
|
||||
let decoded = 0;
|
||||
for (const stream of streams) {
|
||||
try {
|
||||
readBindStream(image, stream, runs);
|
||||
yield* readBindStreamSteps(image, stream, runs);
|
||||
decoded++;
|
||||
} catch (cause: unknown) {
|
||||
failures.push(
|
||||
@@ -1248,14 +1328,14 @@ const parseLegacyBinds = (
|
||||
failures.push(run.binding.reason);
|
||||
}
|
||||
return {
|
||||
runs,
|
||||
index: indexBindRuns(runs, image.bytes.length),
|
||||
failures,
|
||||
issues,
|
||||
examined: streams.length,
|
||||
decoded,
|
||||
complete,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Query retained legacy facts; never expand bind runs or read scalar padding. */
|
||||
const hasUnresolvedLegacyBytes = (
|
||||
@@ -1266,18 +1346,12 @@ const hasUnresolvedLegacyBytes = (
|
||||
size: number,
|
||||
): boolean => {
|
||||
if (size <= 0) return false;
|
||||
if (!rebases.complete || !binds.complete) return true;
|
||||
if (!rebases.complete || !binds.complete || binds.index.exhausted)
|
||||
return true;
|
||||
const first = address - 7n;
|
||||
const end = address + BigInt(size);
|
||||
for (const run of binds.runs) {
|
||||
// An eight-byte bind overlaps [address,end) exactly when its start is
|
||||
// in [address-7,end). Find the first such member without expanding it.
|
||||
const index =
|
||||
first <= run.start
|
||||
? 0n
|
||||
: (first - run.start + run.stride - 1n) / run.stride;
|
||||
if (index < run.count && run.start + index * run.stride < end) return true;
|
||||
}
|
||||
for (const _run of bindRunsIn(binds.index, first, end - 1n)) return true;
|
||||
if (binds.index.exhausted) return true;
|
||||
if (rebases.membership !== null)
|
||||
for (let slot = first; slot < end; slot++) {
|
||||
const state = legacyRebaseState(segments, rebases.membership, slot);
|
||||
@@ -1292,16 +1366,44 @@ const hasUnresolvedLegacyBytes = (
|
||||
return false;
|
||||
};
|
||||
|
||||
/** A write starting elsewhere can cover part of an eight-byte pointer slot. */
|
||||
const hasPartialLegacyPointerWrite = (
|
||||
segments: readonly Segment[],
|
||||
rebases: RebaseFacts,
|
||||
binds: BindFacts,
|
||||
address: bigint,
|
||||
): boolean => {
|
||||
const low = address - 7n;
|
||||
const high = address + 7n;
|
||||
for (const run of bindRunsIn(binds.index, low, high)) {
|
||||
const first =
|
||||
low <= run.start ? 0n : (low - run.start + run.stride - 1n) / run.stride;
|
||||
const at = run.start + first * run.stride;
|
||||
if (at !== address || (first + 1n < run.count && at + run.stride <= high))
|
||||
return true;
|
||||
}
|
||||
if (binds.index.exhausted) return true;
|
||||
if (rebases.membership !== null)
|
||||
for (let at = low; at <= high; at += 1n) {
|
||||
const state = legacyRebaseState(segments, rebases.membership, at);
|
||||
if (typeof state !== "number" || state === REBASE_CONFLICT) return true;
|
||||
if (state === REBASE_NON_POINTER && at + 4n > address) return true;
|
||||
if (state === REBASE_POINTER && at !== address && at + 8n > address)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Build pointer decoding for one slice from its fixup load commands. */
|
||||
export const parsePointerFixups = (
|
||||
export function* parsePointerFixupsSteps(
|
||||
bytes: Buffer,
|
||||
commands: FixupCommands,
|
||||
segments: readonly Segment[],
|
||||
baseAddress: bigint,
|
||||
): PointerFixups => {
|
||||
): Generator<void, PointerFixups, void> {
|
||||
const image = { bytes, segments, dylibs: commands.dylibs, baseAddress };
|
||||
const rebases = parseLegacyRebases(image, commands.rebases);
|
||||
const binds = parseLegacyBinds(image, commands.binds);
|
||||
const rebases = yield* parseLegacyRebasesSteps(image, commands.rebases);
|
||||
const binds = yield* parseLegacyBindsSteps(image, commands.binds);
|
||||
if (commands.chained !== null) {
|
||||
return chainedFixups(image, commands.chained, rebases, binds);
|
||||
}
|
||||
@@ -1344,7 +1446,7 @@ export const parsePointerFixups = (
|
||||
hasUnresolvedLegacyBytes: (address, size) =>
|
||||
hasUnresolvedLegacyBytes(segments, rebases, binds, address, size),
|
||||
decode(address, raw) {
|
||||
const match = resolveLegacyBind(binds.runs, address);
|
||||
const match = resolveLegacyBind(binds.index, address);
|
||||
const rebase = resolveLegacyRebase(
|
||||
segments,
|
||||
rebases.membership,
|
||||
@@ -1352,6 +1454,18 @@ export const parsePointerFixups = (
|
||||
raw,
|
||||
);
|
||||
if (match?.kind === "unsupported") return match;
|
||||
if (rebase?.kind === "unsupported") return rebase;
|
||||
if (
|
||||
rebases.complete &&
|
||||
binds.complete &&
|
||||
hasPartialLegacyPointerWrite(segments, rebases, binds, address)
|
||||
)
|
||||
return {
|
||||
kind: "unsupported",
|
||||
reason: binds.index.exhausted
|
||||
? BIND_QUERY_LIMITATION
|
||||
: "A legacy fixup partially overlaps the pointer slot",
|
||||
};
|
||||
if (match !== undefined && rebase !== undefined)
|
||||
return {
|
||||
kind: "unsupported",
|
||||
@@ -1373,4 +1487,4 @@ export const parsePointerFixups = (
|
||||
return rebase ?? rawPointer(raw);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
selectMachoSlice,
|
||||
UnsupportedMachoMetadataFormatError,
|
||||
} from "./AppleMachoSelection.js";
|
||||
import { parsePointerFixups } from "./AppleMachoFixups.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
|
||||
import { parsePointerFixups } from "./AppleMachoFixups.fixture.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.fixture.js";
|
||||
|
||||
/** macOS 27 system binaries ship CPU_SUBTYPE_ARM64E_X1 (12) with the PAC ABI bit. */
|
||||
const ARM64E_X1 = { type: 0x0100000c, subtype: 0x8000000c } as const;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.fixture.js";
|
||||
|
||||
const base = 0x100000000n;
|
||||
const provenance = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PointerFixups } from "./AppleMachoFixups.js";
|
||||
import { parsePointerFixups } from "./AppleMachoFixups.js";
|
||||
import { parsePointerFixups } from "./AppleMachoFixups.fixture.js";
|
||||
import type { MachoLayout } from "./AppleMachoSelection.js";
|
||||
import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.fixture.js";
|
||||
|
||||
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
|
||||
const BASE = 0x100000000n;
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { setImmediate as immediate } from "node:timers/promises";
|
||||
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
|
||||
import { readElfRelocationEntry } from "../../domain/elfRelocation.js";
|
||||
import { readElfSymbol } from "../../domain/elfSymbol.js";
|
||||
import {
|
||||
elfStringTableText,
|
||||
observeElfStringTableEntry,
|
||||
} from "../../domain/elfStringTable.js";
|
||||
import { MAX_NATIVE_INITIALIZER_NAME_BYTES } from "../../domain/native/nativeInitializers.js";
|
||||
import type { NativeInitializerResolution } from "../../domain/native/nativeInitializers.js";
|
||||
import {
|
||||
elfAbsoluteRelocationType,
|
||||
elfRelativeRelocationType,
|
||||
elfRelocationWidth,
|
||||
} from "../metadata/ElfPointerRelocations.js";
|
||||
import type { NativeSection } from "../metadata/NativeSectionDirectory.js";
|
||||
import type { InitializerNotes } from "./initializerNotes.js";
|
||||
|
||||
/** One symbol-table row. `section` 0 is an undefined import. */
|
||||
export interface ElfInitializerSymbol {
|
||||
readonly name: string | null;
|
||||
readonly value: bigint;
|
||||
readonly section: number;
|
||||
readonly type: number;
|
||||
}
|
||||
|
||||
/** Defined exact-address names plus per-section symbol arrays. */
|
||||
export interface ElfSymbolIndex {
|
||||
readonly defined: ReadonlyMap<bigint, string>;
|
||||
/** Relocatable objects: names keyed by `section:value`, since every section starts at 0. */
|
||||
readonly definedInSection: ReadonlyMap<string, string>;
|
||||
readonly tables: ReadonlyMap<number, readonly ElfInitializerSymbol[]>;
|
||||
/** A table was truncated by a budget or could not be read. */
|
||||
readonly incomplete: boolean;
|
||||
}
|
||||
|
||||
/** A relocation applied to one slot. `definedName` is the reloc symbol. */
|
||||
export interface SlotRelocation {
|
||||
readonly method: NativeInitializerResolution;
|
||||
readonly value: bigint | null;
|
||||
readonly importName: string | null;
|
||||
readonly definedName: string | null;
|
||||
/** Section index of the relocation symbol, used for relocatable objects. */
|
||||
readonly sectionIndex?: number;
|
||||
}
|
||||
|
||||
const STT_FUNC = 2;
|
||||
const STT_SECTION = 3;
|
||||
const STT_FILE = 4;
|
||||
/** `st_value` is a resolver; the loader writes whatever the resolver returns. */
|
||||
const STT_GNU_IFUNC = 10;
|
||||
|
||||
/** Key for a symbol defined at `value` inside section `section`. */
|
||||
export const sectionSymbolKey = (section: number, value: bigint): string =>
|
||||
`${String(section)}:${value.toString(16)}`;
|
||||
|
||||
/** Symbols decoded across every SHT_SYMTAB and SHT_DYNSYM table together. */
|
||||
const SYMBOL_SCAN = 65536;
|
||||
/** REL/RELA records decoded across every relocation section together. */
|
||||
const SECTION_RELOCATION_RECORD_SCAN = 1_048_576;
|
||||
|
||||
interface ImageFacts {
|
||||
readonly bits: 32 | 64;
|
||||
readonly little: boolean;
|
||||
readonly machine: number;
|
||||
readonly bytes: Buffer;
|
||||
}
|
||||
|
||||
const wordOf = (bits: 32 | 64): number => bits / 8;
|
||||
|
||||
/** In-place word used as a REL addend. Null when the slot is not file-backed. */
|
||||
export const readPointerWord = (
|
||||
bytes: Buffer,
|
||||
offset: number | null,
|
||||
bits: 32 | 64,
|
||||
little: boolean,
|
||||
): bigint | null => {
|
||||
const width = wordOf(bits);
|
||||
if (offset === null || offset < 0 || offset > bytes.length - width)
|
||||
return null;
|
||||
if (bits === 64)
|
||||
return little
|
||||
? bytes.readBigUInt64LE(offset)
|
||||
: bytes.readBigUInt64BE(offset);
|
||||
return BigInt(
|
||||
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Name at `offset`. Offset 0 is the empty name. A missing string table or an
|
||||
* offset outside it is an unreadable name, which `missing` keeps visible.
|
||||
*/
|
||||
const stringAt = (
|
||||
table: Buffer | null,
|
||||
offset: number,
|
||||
notes: InitializerNotes,
|
||||
): { readonly name: string | null; readonly missing: boolean } => {
|
||||
if (offset === 0) return { name: null, missing: false };
|
||||
if (table === null) {
|
||||
notes.tally("unreadable-symbol-name");
|
||||
return { name: null, missing: true };
|
||||
}
|
||||
const observed = observeElfStringTableEntry(
|
||||
table,
|
||||
offset,
|
||||
MAX_NATIVE_INITIALIZER_NAME_BYTES + 1,
|
||||
);
|
||||
if (observed.state === "truncated") {
|
||||
notes.tally("long-symbol-name");
|
||||
return { name: null, missing: true };
|
||||
}
|
||||
if (observed.state === "outside") {
|
||||
notes.tally("unreadable-symbol-name");
|
||||
return { name: null, missing: true };
|
||||
}
|
||||
const text = elfStringTableText(observed);
|
||||
if (
|
||||
text === null ||
|
||||
text.length === 0 ||
|
||||
text.length > MAX_NATIVE_INITIALIZER_NAME_BYTES
|
||||
)
|
||||
return { name: null, missing: false };
|
||||
return { name: text, missing: false };
|
||||
};
|
||||
|
||||
const tableBytes = (
|
||||
bytes: Buffer,
|
||||
section: NativeSection | undefined,
|
||||
): Buffer | null => {
|
||||
if (section === undefined || !section.file_backed) return null;
|
||||
const offset = Number(section.offset);
|
||||
const size = Number(section.size);
|
||||
if (offset < 0 || size < 0 || offset > bytes.length - size) return null;
|
||||
return bytes.subarray(offset, offset + size);
|
||||
};
|
||||
|
||||
const readTable = (
|
||||
facts: ImageFacts,
|
||||
sections: readonly NativeSection[],
|
||||
section: NativeSection,
|
||||
budget: number,
|
||||
notes: InitializerNotes,
|
||||
): {
|
||||
readonly symbols: ElfInitializerSymbol[];
|
||||
readonly truncated: boolean;
|
||||
} => {
|
||||
const width = facts.bits === 64 ? 24 : 16;
|
||||
const strings = tableBytes(facts.bytes, sections[section.link]);
|
||||
const start = Number(section.offset);
|
||||
const declared = section.size / BigInt(width);
|
||||
const count = Number(declared < BigInt(budget) ? declared : BigInt(budget));
|
||||
const truncated = declared > BigInt(budget);
|
||||
if (truncated)
|
||||
notes.add(
|
||||
`Symbol table scanning stopped at ${String(SYMBOL_SCAN)} symbols across all symbol tables. Later names may be missing.`,
|
||||
);
|
||||
const symbols: ElfInitializerSymbol[] = [];
|
||||
let nameMissing = false;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const at = start + index * width;
|
||||
const parsed = readElfSymbol(
|
||||
facts.bytes.subarray(at, at + width),
|
||||
facts.bits,
|
||||
facts.little,
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
notes.add(parsed.error.message);
|
||||
break;
|
||||
}
|
||||
const named = stringAt(strings, parsed.value.name_offset, notes);
|
||||
nameMissing ||= named.missing;
|
||||
symbols.push({
|
||||
name: named.name,
|
||||
value: parsed.value.value,
|
||||
section: parsed.value.section_index,
|
||||
type: parsed.value.type,
|
||||
});
|
||||
}
|
||||
return { symbols, truncated: truncated || nameMissing };
|
||||
};
|
||||
|
||||
/** Load SHT_SYMTAB and SHT_DYNSYM once. Defined STT_FUNC names win ties. */
|
||||
export const loadElfSymbolIndex = (
|
||||
facts: ImageFacts,
|
||||
sections: readonly NativeSection[],
|
||||
notes: InitializerNotes,
|
||||
): ElfSymbolIndex => {
|
||||
const tables = new Map<number, readonly ElfInitializerSymbol[]>();
|
||||
const ranked = new Map<
|
||||
bigint,
|
||||
{ readonly name: string; readonly func: boolean }
|
||||
>();
|
||||
const inSection = new Map<
|
||||
string,
|
||||
{ readonly name: string; readonly func: boolean }
|
||||
>();
|
||||
let truncated = false;
|
||||
const rank = <K>(
|
||||
map: Map<K, { readonly name: string; readonly func: boolean }>,
|
||||
key: K,
|
||||
name: string,
|
||||
func: boolean,
|
||||
): void => {
|
||||
const previous = map.get(key);
|
||||
if (previous === undefined || (!previous.func && func))
|
||||
map.set(key, { name, func });
|
||||
};
|
||||
const consider = (symbol: ElfInitializerSymbol): void => {
|
||||
if (symbol.section === 0 || symbol.name === null) return;
|
||||
if (symbol.type === STT_SECTION || symbol.type === STT_FILE) return;
|
||||
const func = symbol.type === STT_FUNC;
|
||||
rank(ranked, symbol.value, symbol.name, func);
|
||||
rank(
|
||||
inSection,
|
||||
sectionSymbolKey(symbol.section, symbol.value),
|
||||
symbol.name,
|
||||
func,
|
||||
);
|
||||
};
|
||||
let budget = SYMBOL_SCAN;
|
||||
let unreadable = false;
|
||||
sections.forEach((section, index) => {
|
||||
if (truncated) return;
|
||||
if (section.type !== 2 && section.type !== 11) return;
|
||||
if (
|
||||
!section.file_backed ||
|
||||
section.size % BigInt(facts.bits === 64 ? 24 : 16) !== 0n
|
||||
) {
|
||||
notes.add(
|
||||
"A symbol table is not stored in the file or its size is not a whole number of entries, so its names were not read.",
|
||||
);
|
||||
unreadable = true;
|
||||
return;
|
||||
}
|
||||
const table = readTable(facts, sections, section, budget, notes);
|
||||
budget -= table.symbols.length;
|
||||
tables.set(index, table.symbols);
|
||||
truncated ||= table.truncated;
|
||||
for (const symbol of table.symbols) consider(symbol);
|
||||
});
|
||||
return {
|
||||
defined: new Map([...ranked].map(([address, row]) => [address, row.name])),
|
||||
definedInSection: new Map(
|
||||
[...inSection].map(([key, row]) => [key, row.name]),
|
||||
),
|
||||
tables,
|
||||
incomplete: truncated || unreadable,
|
||||
};
|
||||
};
|
||||
|
||||
const unresolved = (): SlotRelocation => ({
|
||||
method: "unresolved",
|
||||
value: null,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
});
|
||||
|
||||
const addendOf = (
|
||||
entry: { readonly addend?: bigint },
|
||||
inplace: bigint | null,
|
||||
): bigint | null => (typeof entry.addend === "bigint" ? entry.addend : inplace);
|
||||
|
||||
/** Apply one REL or RELA record using its own symbol table. */
|
||||
export const relocationFromEntry = (
|
||||
facts: ImageFacts,
|
||||
entry: {
|
||||
readonly type: number;
|
||||
readonly symbolIndex: number;
|
||||
readonly addend?: bigint;
|
||||
},
|
||||
symbols: readonly ElfInitializerSymbol[] | undefined,
|
||||
inplace: bigint | null,
|
||||
): SlotRelocation => {
|
||||
const relative = elfRelativeRelocationType(facts.machine);
|
||||
const absolute = elfAbsoluteRelocationType(facts.machine);
|
||||
if (entry.type === relative && entry.symbolIndex === 0) {
|
||||
const addend = addendOf(entry, inplace);
|
||||
if (addend === null) return unresolved();
|
||||
return {
|
||||
method: "relative_addend",
|
||||
value: BigInt.asUintN(facts.bits, addend),
|
||||
importName: null,
|
||||
definedName: null,
|
||||
};
|
||||
}
|
||||
if (entry.symbolIndex === 0 || symbols === undefined) return unresolved();
|
||||
const symbol = symbols[entry.symbolIndex];
|
||||
if (symbol === undefined) return unresolved();
|
||||
if (symbol.section === 0)
|
||||
return {
|
||||
method: "unresolved_import",
|
||||
value: null,
|
||||
importName: symbol.name,
|
||||
definedName: null,
|
||||
};
|
||||
if (entry.type !== absolute || symbol.type === STT_GNU_IFUNC)
|
||||
return unresolved();
|
||||
const addend = addendOf(entry, inplace);
|
||||
if (addend === null) return unresolved();
|
||||
return {
|
||||
method: "symbol_addend",
|
||||
value: BigInt.asUintN(facts.bits, symbol.value + addend),
|
||||
importName: null,
|
||||
definedName:
|
||||
symbol.type === STT_SECTION || symbol.type === STT_FILE || addend !== 0n
|
||||
? null
|
||||
: symbol.name,
|
||||
sectionIndex: symbol.section,
|
||||
};
|
||||
};
|
||||
|
||||
interface SectionRelocInput extends ImageFacts {
|
||||
readonly sections: readonly NativeSection[];
|
||||
/** ET_REL: `r_offset` is relative to the section named by `sh_info`. */
|
||||
readonly relocatable: boolean;
|
||||
/** File offsets of slots not covered by dynamic relocations. */
|
||||
readonly wanted: ReadonlySet<number>;
|
||||
readonly tables: ReadonlyMap<number, readonly ElfInitializerSymbol[]>;
|
||||
readonly notes: InitializerNotes;
|
||||
/** File offset of `width` bytes at a linked-image address. */
|
||||
readonly fileOffset: (address: bigint, width: number) => number | null;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
/** Decode one REL or RELA record through the encoding-specific overload. */
|
||||
const readRelocationRecord = (
|
||||
bytes: Buffer,
|
||||
bits: 32 | 64,
|
||||
little: boolean,
|
||||
encoding: "rel" | "rela",
|
||||
) =>
|
||||
encoding === "rela"
|
||||
? readElfRelocationEntry(bytes, bits, little, "rela")
|
||||
: readElfRelocationEntry(bytes, bits, little, "rel");
|
||||
|
||||
const encodingOf = (type: number): "rel" | "rela" | null =>
|
||||
type === 4 ? "rela" : type === 9 ? "rel" : null;
|
||||
|
||||
/** File offset of the `width` bytes one record writes, or null when they are not file-backed. */
|
||||
const recordTarget = (
|
||||
input: SectionRelocInput,
|
||||
target: NativeSection | undefined,
|
||||
offset: bigint,
|
||||
width: number,
|
||||
): number | null => {
|
||||
if (!input.relocatable) return input.fileOffset(offset, width);
|
||||
if (target === undefined || !target.file_backed) return null;
|
||||
if (offset < 0n || offset + BigInt(width) > target.size) return null;
|
||||
return Number(target.offset + offset);
|
||||
};
|
||||
|
||||
/** Index of the first wanted slot whose word could overlap a write starting at `at`. */
|
||||
const firstOverlap = (
|
||||
wanted: readonly number[],
|
||||
at: number,
|
||||
word: number,
|
||||
): number => {
|
||||
let low = 0;
|
||||
let high = wanted.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if ((wanted[mid] ?? at) <= at - word) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
};
|
||||
|
||||
/** Section relocations found for wanted slots, and whether every applicable record was read. */
|
||||
export interface SectionRelocationScan {
|
||||
readonly found: ReadonlyMap<number, SlotRelocation>;
|
||||
/** Slots a narrower, offset or repeated record overlaps; their value is unknown. */
|
||||
readonly clobbered: ReadonlySet<number>;
|
||||
/**
|
||||
* False when a relocation section was skipped, ended early or lies past the
|
||||
* record budget, so an unmatched slot may still have an unread relocation.
|
||||
*/
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
const SHF_ALLOC = 2n;
|
||||
|
||||
/**
|
||||
* Whether records in `section` could reach a wanted slot. ET_REL records only
|
||||
* reach the section named by `sh_info`; a linked image's records may reach any
|
||||
* address.
|
||||
*/
|
||||
const reachesWanted = (
|
||||
input: SectionRelocInput,
|
||||
wanted: readonly number[],
|
||||
section: NativeSection,
|
||||
): boolean => {
|
||||
if (!input.relocatable) return true;
|
||||
const target = input.sections[section.info];
|
||||
if (target === undefined) return true;
|
||||
if (!target.file_backed) return false;
|
||||
const from = Number(target.offset);
|
||||
const to = from + Number(target.size);
|
||||
let low = 0;
|
||||
let high = wanted.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if ((wanted[mid] ?? to) < from) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return (wanted[low] ?? to) < to;
|
||||
};
|
||||
|
||||
/** Relocation sections this reader applies: every one in ET_REL, SHF_ALLOC ones otherwise. */
|
||||
const applicable = (
|
||||
input: SectionRelocInput,
|
||||
section: NativeSection,
|
||||
): "rel" | "rela" | null => {
|
||||
const encoding = encodingOf(section.type);
|
||||
if (encoding === null) return null;
|
||||
if (!input.relocatable && (section.flags & SHF_ALLOC) === 0n) return null;
|
||||
return encoding;
|
||||
};
|
||||
|
||||
/**
|
||||
* Section REL/RELA records for slots that dynamic tables do not cover, keyed by
|
||||
* slot file offset. In a linked image, a relocation section without SHF_ALLOC
|
||||
* is a link-time record the static linker already applied (for example from
|
||||
* `--emit-relocs`), so the slot already holds its result and is not reapplied.
|
||||
*/
|
||||
export const sectionRelocations = async (
|
||||
input: SectionRelocInput,
|
||||
): Promise<SectionRelocationScan> => {
|
||||
const found = new Map<number, SlotRelocation>();
|
||||
const clobbered = new Set<number>();
|
||||
if (input.wanted.size === 0) return { found, clobbered, complete: true };
|
||||
const wanted = [...input.wanted].sort((left, right) => left - right);
|
||||
const word = wordOf(input.bits);
|
||||
let complete = true;
|
||||
const skip = (section: NativeSection): void => {
|
||||
if (reachesWanted(input, wanted, section)) complete = false;
|
||||
};
|
||||
// One budget across every header, so many headers over one range cannot multiply the work.
|
||||
let budget = SECTION_RELOCATION_RECORD_SCAN;
|
||||
for (const [position, section] of input.sections.entries()) {
|
||||
const encoding = applicable(input, section);
|
||||
if (encoding === null) continue;
|
||||
if (!section.file_backed) {
|
||||
skip(section);
|
||||
continue;
|
||||
}
|
||||
const width = encoding === "rela" ? word * 3 : word * 2;
|
||||
const entsize =
|
||||
section.entry_size === 0n ? BigInt(width) : section.entry_size;
|
||||
if (entsize !== BigInt(width) || section.size % entsize !== 0n) {
|
||||
input.notes.tally("relocation-entry-size");
|
||||
skip(section);
|
||||
continue;
|
||||
}
|
||||
const symbols = input.tables.get(section.link);
|
||||
const target = input.relocatable ? input.sections[section.info] : undefined;
|
||||
if (input.relocatable && target === undefined) {
|
||||
input.notes.add(
|
||||
"A relocation section names a target section that does not exist, so its records were not applied.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const start = Number(section.offset);
|
||||
const declared = section.size / entsize;
|
||||
const count = Number(declared < BigInt(budget) ? declared : BigInt(budget));
|
||||
budget -= count;
|
||||
const stopped = declared > BigInt(count);
|
||||
if (stopped)
|
||||
input.notes.add(
|
||||
`Section relocation scanning stopped at ${String(SECTION_RELOCATION_RECORD_SCAN)} records across all relocation sections. Later records were not applied.`,
|
||||
);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (index % 4096 === 0) {
|
||||
await immediate();
|
||||
assertArtifactReadActive(input.signal);
|
||||
}
|
||||
const at = start + index * width;
|
||||
const parsed = readRelocationRecord(
|
||||
input.bytes.subarray(at, at + width),
|
||||
input.bits,
|
||||
input.little,
|
||||
encoding,
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
input.notes.add(parsed.error.message);
|
||||
skip(section);
|
||||
break;
|
||||
}
|
||||
// An unknown type is assumed to write a whole word, so it still marks what it may touch.
|
||||
const span = elfRelocationWidth(input.machine, parsed.value.type) ?? word;
|
||||
if (span === 0) continue;
|
||||
const written = recordTarget(input, target, parsed.value.offset, 1);
|
||||
if (written === null) {
|
||||
skip(section);
|
||||
continue;
|
||||
}
|
||||
const fullWrite =
|
||||
recordTarget(input, target, parsed.value.offset, span) !== null;
|
||||
if (!fullWrite) {
|
||||
complete = false;
|
||||
input.notes.add(
|
||||
"A section relocation extends beyond its target mapping; affected slots are unresolved.",
|
||||
);
|
||||
}
|
||||
for (
|
||||
let next = firstOverlap(wanted, written, word);
|
||||
next < wanted.length;
|
||||
next += 1
|
||||
) {
|
||||
const slot = wanted[next];
|
||||
if (slot === undefined || slot >= written + span) break;
|
||||
if (clobbered.has(slot)) continue;
|
||||
if (
|
||||
fullWrite &&
|
||||
slot === written &&
|
||||
span === word &&
|
||||
!found.has(slot)
|
||||
) {
|
||||
const inplace =
|
||||
encoding === "rel"
|
||||
? readPointerWord(input.bytes, slot, input.bits, input.little)
|
||||
: null;
|
||||
found.set(
|
||||
slot,
|
||||
relocationFromEntry(input, parsed.value, symbols, inplace),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
found.delete(slot);
|
||||
clobbered.add(slot);
|
||||
}
|
||||
}
|
||||
if (stopped) {
|
||||
// The rest of this section and every later one were not read.
|
||||
for (const later of input.sections.slice(position))
|
||||
if (applicable(input, later) !== null) skip(later);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (clobbered.size > 0)
|
||||
input.notes.add(
|
||||
`${String(clobbered.size)} initializer slot(s) are partly overwritten or relocated more than once by section relocations and are listed as unresolved.`,
|
||||
);
|
||||
return { found, clobbered, complete };
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,903 @@
|
||||
import { setImmediate as immediate } from "node:timers/promises";
|
||||
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
|
||||
import {
|
||||
MachoLoadCommandCursor,
|
||||
readMachoLoadCommandTableHeader,
|
||||
readMachoSymtabCommand,
|
||||
} from "../../domain/apple/machoLoadCommands.js";
|
||||
import { observeElfStringTableEntry } from "../../domain/elfStringTable.js";
|
||||
import {
|
||||
MAX_NATIVE_INITIALIZER_ENTRIES,
|
||||
MAX_NATIVE_INITIALIZER_NAME_BYTES,
|
||||
type NativeInitializerKind,
|
||||
type NativeInitializerSlice,
|
||||
type NativeInitializerInspection,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
import {
|
||||
parsePointerFixupsSteps,
|
||||
type PointerFixups,
|
||||
} from "../AppleMachoFixups.js";
|
||||
import { parseMachoLayout } from "../AppleMachoSelection.js";
|
||||
import { decodeMachoName } from "../AppleMachoNames.js";
|
||||
import {
|
||||
BinaryFormatFailure,
|
||||
BinaryMappingFailure,
|
||||
createFileMappingIndex,
|
||||
type ImageSection,
|
||||
} from "../metadata/BinaryContainer.js";
|
||||
import { readMachoImage } from "../metadata/MachoImage.js";
|
||||
import {
|
||||
finishInitializerReport,
|
||||
type CollectedInitializer,
|
||||
type InitializerPage,
|
||||
} from "./initializerReport.js";
|
||||
import { InitializerNotes } from "./initializerNotes.js";
|
||||
import {
|
||||
buildAddressRangeIndex,
|
||||
type AddressRangeIndex,
|
||||
} from "./addressRangeIndex.js";
|
||||
import type { SlotRelocation } from "./ElfInitializerSymbols.js";
|
||||
|
||||
const S_MOD_INIT_FUNC_POINTERS = 0x9;
|
||||
const S_MOD_TERM_FUNC_POINTERS = 0xa;
|
||||
const S_INIT_FUNC_OFFSETS = 0x16;
|
||||
const LC_SYMTAB = 0x2;
|
||||
const LC_DYSYMTAB = 0xb;
|
||||
const LC_DYLD_INFO = 0x22;
|
||||
const LC_DYLD_INFO_ONLY = 0x80000022;
|
||||
const MH_OBJECT = 0x1;
|
||||
const CPU_TYPE_ARM = 12;
|
||||
const CPU_TYPE_ARM64_32 = 0x0200000c;
|
||||
/** nlist records decoded across every LC_SYMTAB together. */
|
||||
const SYMBOL_SCAN = 65536;
|
||||
/** Object-file section relocation entries read across every initializer section together. */
|
||||
const SECTION_RELOCATION_SCAN = 1_048_576;
|
||||
|
||||
const CPU_ARCHITECTURE = new Map<
|
||||
number,
|
||||
NonNullable<NativeInitializerInspection["architecture"]>
|
||||
>([
|
||||
[7, "x86"],
|
||||
[0x01000007, "x86_64"],
|
||||
[12, "arm"],
|
||||
[0x0100000c, "arm64"],
|
||||
[0x0200000c, "arm64_32"],
|
||||
]);
|
||||
|
||||
/** Thin slice plus its location in the container file. */
|
||||
export interface MachoInitializerImage {
|
||||
readonly bytes: Buffer;
|
||||
readonly fileBase: number;
|
||||
readonly slice: NativeInitializerSlice | null;
|
||||
}
|
||||
|
||||
interface MachoScan {
|
||||
readonly bytes: Buffer;
|
||||
readonly notes: InitializerNotes;
|
||||
incomplete: boolean;
|
||||
/** Pointer slots whose fixup decode returned no rebase, bind or raw value. */
|
||||
undecoded: number;
|
||||
/** Object-file slots whose section relocation this reader does not apply. */
|
||||
unappliedObjectRelocations: number;
|
||||
}
|
||||
|
||||
const note = (scan: MachoScan, message: string): void => {
|
||||
scan.notes.add(message);
|
||||
scan.incomplete = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* dyld fixups for a linked little-endian arm64 or x86-64 image. Other images
|
||||
* get null, and {@link uncheckedFixupReason} decides whether their stored
|
||||
* words can still be trusted.
|
||||
*/
|
||||
const layoutFixups = async (
|
||||
scan: MachoScan,
|
||||
bits: 32 | 64,
|
||||
little: boolean,
|
||||
cpu: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
readonly fixups: PointerFixups | null;
|
||||
readonly base: bigint | null;
|
||||
}> => {
|
||||
const architecture =
|
||||
cpu === 0x0100000c ? "arm64" : cpu === 0x01000007 ? "x86_64" : null;
|
||||
if (bits !== 64 || !little || architecture === null)
|
||||
return { fixups: null, base: null };
|
||||
try {
|
||||
const layout = parseMachoLayout(scan.bytes, architecture);
|
||||
const steps = parsePointerFixupsSteps(
|
||||
scan.bytes,
|
||||
layout.fixups,
|
||||
layout.segments,
|
||||
layout.baseAddress,
|
||||
);
|
||||
let next = steps.next();
|
||||
while (!next.done) {
|
||||
await immediate();
|
||||
assertArtifactReadActive(signal);
|
||||
next = steps.next();
|
||||
}
|
||||
return { fixups: next.value, base: layout.baseAddress };
|
||||
} catch (cause) {
|
||||
assertArtifactReadActive(signal);
|
||||
note(
|
||||
scan,
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Mach-O fixups could not be decoded.",
|
||||
);
|
||||
return { fixups: null, base: null };
|
||||
}
|
||||
};
|
||||
|
||||
const imageBase = (
|
||||
mappings: ReturnType<typeof readMachoImage>["mappings"],
|
||||
layoutBase: bigint | null,
|
||||
): bigint => {
|
||||
const zero = mappings.find((mapping) => mapping.offset === 0);
|
||||
if (zero !== undefined) return zero.address;
|
||||
const first = mappings[0];
|
||||
if (first === undefined) return layoutBase ?? 0n;
|
||||
return mappings.reduce(
|
||||
(lowest, mapping) => (mapping.address < lowest ? mapping.address : lowest),
|
||||
first.address,
|
||||
);
|
||||
};
|
||||
|
||||
type MachoSection = Extract<ImageSection, { format: "macho" }>;
|
||||
|
||||
/** Smallest section containing an address; earlier sections win ties. Built once per image. */
|
||||
const indexSections = (
|
||||
sections: readonly ImageSection[],
|
||||
): AddressRangeIndex<{
|
||||
readonly section: MachoSection;
|
||||
readonly order: number;
|
||||
}> =>
|
||||
buildAddressRangeIndex(
|
||||
sections.flatMap((section, order) =>
|
||||
section.format === "macho" && section.size !== 0
|
||||
? [
|
||||
{
|
||||
start: section.address,
|
||||
end: section.address + BigInt(section.size),
|
||||
value: { section, order },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
(left, right) =>
|
||||
left.section.size - right.section.size || left.order - right.order,
|
||||
);
|
||||
|
||||
const containing = (
|
||||
index: ReturnType<typeof indexSections>,
|
||||
address: bigint,
|
||||
): string | null => {
|
||||
const name = index.at(address)?.value.section.name;
|
||||
return name === undefined || name.length === 0 ? null : name;
|
||||
};
|
||||
|
||||
/** The first admitted LC_SYMTAB, which object-file relocations index by symbol number. */
|
||||
interface SymbolTable {
|
||||
readonly symoff: number;
|
||||
readonly nsyms: number;
|
||||
readonly strings: Buffer;
|
||||
readonly wide: boolean;
|
||||
readonly little: boolean;
|
||||
}
|
||||
|
||||
interface MachoSymbols {
|
||||
readonly names: ReadonlyMap<bigint, string>;
|
||||
readonly table: SymbolTable | null;
|
||||
}
|
||||
|
||||
/** Name at `strx`. Index 0 is the empty name; an index outside the table is unreadable. */
|
||||
const nameAt = (
|
||||
scan: MachoScan,
|
||||
strings: Buffer,
|
||||
strx: number,
|
||||
): string | null => {
|
||||
if (strx === 0) return null;
|
||||
const observed = observeElfStringTableEntry(
|
||||
strings,
|
||||
strx,
|
||||
MAX_NATIVE_INITIALIZER_NAME_BYTES + 1,
|
||||
);
|
||||
if (observed.state === "truncated") {
|
||||
scan.notes.tally("long-symbol-name");
|
||||
scan.incomplete = true;
|
||||
return null;
|
||||
}
|
||||
if (observed.state === "outside") {
|
||||
scan.notes.tally("unreadable-symbol-name");
|
||||
scan.incomplete = true;
|
||||
return null;
|
||||
}
|
||||
const text = decodeMachoName(observed.bytes);
|
||||
if (typeof text !== "string") {
|
||||
scan.notes.tally("unreadable-symbol-name");
|
||||
scan.incomplete = true;
|
||||
return null;
|
||||
}
|
||||
return text.length === 0 ? null : text;
|
||||
};
|
||||
|
||||
const readSymbols = (
|
||||
scan: MachoScan,
|
||||
wide: boolean,
|
||||
little: boolean,
|
||||
): MachoSymbols => {
|
||||
const names = new Map<bigint, string>();
|
||||
let first: SymbolTable | null = null;
|
||||
const done = (): MachoSymbols => ({ names, table: first });
|
||||
const header = readMachoLoadCommandTableHeader(scan.bytes, scan.bytes.length);
|
||||
if (!header.ok || header.value === null) return done();
|
||||
const cursor = new MachoLoadCommandCursor(header.value);
|
||||
const width = wide ? 16 : 12;
|
||||
// One budget across every LC_SYMTAB, so repeated commands cannot multiply the work.
|
||||
let budget = SYMBOL_SCAN;
|
||||
const seen = new Set<string>();
|
||||
while (cursor.nextOffset !== null) {
|
||||
const commandAt = header.value.headerSize + cursor.nextOffset;
|
||||
const frame = cursor.read(scan.bytes.subarray(commandAt, commandAt + 8));
|
||||
if (!frame.ok) break;
|
||||
const body = scan.bytes.subarray(commandAt, commandAt + frame.value.size);
|
||||
if (frame.value.command !== LC_SYMTAB) continue;
|
||||
const table = readMachoSymtabCommand(body, little);
|
||||
if (table === null) {
|
||||
note(scan, "LC_SYMTAB is truncated.");
|
||||
return done();
|
||||
}
|
||||
const stringsEnd = table.stroff + table.strsize;
|
||||
if (
|
||||
table.symoff < 0 ||
|
||||
table.stroff < 0 ||
|
||||
table.strsize < 0 ||
|
||||
stringsEnd > scan.bytes.length ||
|
||||
table.nsyms > (scan.bytes.length - table.symoff) / width
|
||||
) {
|
||||
note(scan, "LC_SYMTAB points outside the Mach-O image.");
|
||||
return done();
|
||||
}
|
||||
const range = `${String(table.symoff)}:${String(table.nsyms)}:${String(table.stroff)}:${String(table.strsize)}`;
|
||||
if (seen.has(range)) {
|
||||
// Nothing is lost by skipping an identical copy, so coverage stays as it was.
|
||||
scan.notes.add(
|
||||
"A repeated LC_SYMTAB names the same symbol and string ranges and was ignored.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
seen.add(range);
|
||||
const strings = scan.bytes.subarray(table.stroff, stringsEnd);
|
||||
first ??= {
|
||||
symoff: table.symoff,
|
||||
nsyms: table.nsyms,
|
||||
strings,
|
||||
wide,
|
||||
little,
|
||||
};
|
||||
const count = Math.min(table.nsyms, budget);
|
||||
budget -= count;
|
||||
const stopped = table.nsyms > count;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const at = table.symoff + index * width;
|
||||
const type = scan.bytes[at + 4] ?? 0;
|
||||
// Skip debugger stabs and undefined (N_UNDF) entries.
|
||||
if ((type & 0xe0) !== 0 || (type & 0x0e) === 0) continue;
|
||||
const strx = little
|
||||
? scan.bytes.readUInt32LE(at)
|
||||
: scan.bytes.readUInt32BE(at);
|
||||
const value = wide
|
||||
? little
|
||||
? scan.bytes.readBigUInt64LE(at + 8)
|
||||
: scan.bytes.readBigUInt64BE(at + 8)
|
||||
: BigInt(
|
||||
little
|
||||
? scan.bytes.readUInt32LE(at + 8)
|
||||
: scan.bytes.readUInt32BE(at + 8),
|
||||
);
|
||||
const text = nameAt(scan, strings, strx);
|
||||
if (text === null || names.has(value)) continue;
|
||||
names.set(value, text);
|
||||
}
|
||||
if (stopped) {
|
||||
note(
|
||||
scan,
|
||||
`Symbol scanning stopped at ${String(SYMBOL_SCAN)} symbols across all LC_SYMTAB commands. Later names may be missing.`,
|
||||
);
|
||||
return done();
|
||||
}
|
||||
}
|
||||
return done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a stored pointer word in a linked image cannot be trusted when no fixup
|
||||
* decoder covers it, or null when it can. Rebases keep the link-time target in
|
||||
* the stored word, so only bind streams and external relocations replace it.
|
||||
*/
|
||||
const uncheckedFixupReason = (
|
||||
scan: MachoScan,
|
||||
little: boolean,
|
||||
decoded: boolean,
|
||||
): string | null => {
|
||||
const header = readMachoLoadCommandTableHeader(scan.bytes, scan.bytes.length);
|
||||
if (!header.ok || header.value === null) return null;
|
||||
const cursor = new MachoLoadCommandCursor(header.value);
|
||||
const word = (at: number): number =>
|
||||
at + 4 > scan.bytes.length
|
||||
? 0
|
||||
: little
|
||||
? scan.bytes.readUInt32LE(at)
|
||||
: scan.bytes.readUInt32BE(at);
|
||||
while (cursor.nextOffset !== null) {
|
||||
const commandAt = header.value.headerSize + cursor.nextOffset;
|
||||
const frame = cursor.read(scan.bytes.subarray(commandAt, commandAt + 8));
|
||||
if (!frame.ok) break;
|
||||
const { command, size } = frame.value;
|
||||
if (
|
||||
!decoded &&
|
||||
(command === LC_DYLD_INFO || command === LC_DYLD_INFO_ONLY) &&
|
||||
size >= 48 &&
|
||||
// bind_size, weak_bind_size and lazy_bind_size.
|
||||
(word(commandAt + 20) !== 0 ||
|
||||
word(commandAt + 28) !== 0 ||
|
||||
word(commandAt + 36) !== 0)
|
||||
)
|
||||
return "LC_DYLD_INFO bind opcodes are not decoded for this image, so a pointer slot's stored word may be replaced by a bound symbol at load time.";
|
||||
// nextrel: legacy external relocations, which no decoder here applies.
|
||||
if (command === LC_DYSYMTAB && size >= 80 && word(commandAt + 68) !== 0)
|
||||
return "LC_DYSYMTAB external relocations are not decoded, so a pointer slot's stored word may be replaced by a bound symbol at load time.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const pointerResolution = (
|
||||
scan: MachoScan,
|
||||
fixups: PointerFixups | null,
|
||||
chained: boolean,
|
||||
unchecked: boolean,
|
||||
address: bigint,
|
||||
raw: bigint,
|
||||
): SlotRelocation => {
|
||||
const resolved = decodePointer(scan, fixups, chained, address, raw);
|
||||
// A stored word is only the target when every fixup source over it was checked.
|
||||
return unchecked && resolved.method === "raw" ? UNRESOLVED : resolved;
|
||||
};
|
||||
|
||||
const UNRESOLVED: SlotRelocation = {
|
||||
method: "unresolved",
|
||||
value: null,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
};
|
||||
|
||||
const decodePointer = (
|
||||
scan: MachoScan,
|
||||
fixups: PointerFixups | null,
|
||||
chained: boolean,
|
||||
address: bigint,
|
||||
raw: bigint,
|
||||
): SlotRelocation => {
|
||||
// Without a chained-fixup decoder, a chained slot's stored word is an encoding, not an address.
|
||||
if (fixups === null && chained)
|
||||
return {
|
||||
method: "unresolved",
|
||||
value: null,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
};
|
||||
if (fixups === null)
|
||||
return { method: "raw", value: raw, importName: null, definedName: null };
|
||||
const decoded = fixups.decode(address, raw);
|
||||
if (decoded.kind === "rebase")
|
||||
return {
|
||||
// The decoder reports both encodings as rebases; its kind names the source.
|
||||
method: fixups.kind === "chained" ? "chained_fixup" : "dyld_info_rebase",
|
||||
value: decoded.target,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
};
|
||||
if (decoded.kind === "raw")
|
||||
return {
|
||||
method: "raw",
|
||||
value: decoded.target,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
};
|
||||
if (decoded.kind === "bind") {
|
||||
// The bound name is the only identity of this target, so dropping it is a visible gap.
|
||||
const overBudget =
|
||||
decoded.symbol.length > MAX_NATIVE_INITIALIZER_NAME_BYTES;
|
||||
if (overBudget) {
|
||||
scan.notes.tally("long-symbol-name");
|
||||
scan.incomplete = true;
|
||||
}
|
||||
return {
|
||||
method: "unresolved_import",
|
||||
value: null,
|
||||
importName:
|
||||
decoded.symbol.length > 0 && !overBudget ? decoded.symbol : null,
|
||||
definedName: null,
|
||||
};
|
||||
}
|
||||
note(scan, decoded.reason);
|
||||
return {
|
||||
method: "unresolved",
|
||||
value: null,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
};
|
||||
};
|
||||
|
||||
const readWord = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
width: number,
|
||||
little: boolean,
|
||||
): bigint | null => {
|
||||
if (offset < 0 || offset > bytes.length - width) return null;
|
||||
if (width === 8)
|
||||
return little
|
||||
? bytes.readBigUInt64LE(offset)
|
||||
: bytes.readBigUInt64BE(offset);
|
||||
return BigInt(
|
||||
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset),
|
||||
);
|
||||
};
|
||||
|
||||
interface SectionWalk {
|
||||
readonly scan: MachoScan;
|
||||
readonly image: ReturnType<typeof readMachoImage>;
|
||||
readonly fileBase: number;
|
||||
readonly fixups: PointerFixups | null;
|
||||
readonly chained: boolean;
|
||||
/** A fixup source over pointer slots was not decoded, so a stored word is not a target. */
|
||||
readonly unchecked: boolean;
|
||||
/** MH_OBJECT: section relocation entries, not dyld fixups, set slot values. */
|
||||
readonly object: boolean;
|
||||
/** CPU_TYPE_ARM: function pointers may carry the Thumb bit. */
|
||||
readonly thumb: boolean;
|
||||
readonly base: bigint;
|
||||
readonly symbols: MachoSymbols;
|
||||
readonly sections: ReturnType<typeof indexSections>;
|
||||
readonly locate: (address: bigint, size: number) => number | null;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
/** Object-file relocation entries still readable across every section. */
|
||||
relocationBudget: number;
|
||||
}
|
||||
|
||||
/** One object-file relocation entry as it bears on the slot it starts in. */
|
||||
type ObjectRelocation =
|
||||
| {
|
||||
readonly kind: "unsigned";
|
||||
readonly extern: boolean;
|
||||
readonly symbol: number;
|
||||
}
|
||||
| { readonly kind: "other" };
|
||||
|
||||
interface ObjectRelocations {
|
||||
/** Keyed by slot offset inside the section. */
|
||||
readonly bySlot: ReadonlyMap<number, ObjectRelocation>;
|
||||
/** False when some entries were not read, so an unmatched slot may still be relocated. */
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an object file section's own relocation entries. Only a pointer-width,
|
||||
* non-PC-relative UNSIGNED (or VANILLA) entry that starts a slot is applied;
|
||||
* every other entry over a slot leaves it unresolved.
|
||||
*/
|
||||
const objectRelocations = (
|
||||
walk: SectionWalk,
|
||||
section: MachoSection,
|
||||
stride: number,
|
||||
): ObjectRelocations => {
|
||||
const { offset, count } = section.relocations;
|
||||
const bySlot = new Map<number, ObjectRelocation>();
|
||||
if (count === 0) return { bySlot, complete: true };
|
||||
const bytes = walk.scan.bytes;
|
||||
if (offset < 0 || count > (bytes.length - offset) / 8) {
|
||||
note(
|
||||
walk.scan,
|
||||
`${section.name} relocation entries lie outside the file, so its slots are listed as unresolved.`,
|
||||
);
|
||||
return { bySlot, complete: false };
|
||||
}
|
||||
const read = Math.min(count, walk.relocationBudget);
|
||||
walk.relocationBudget -= read;
|
||||
if (read < count)
|
||||
note(
|
||||
walk.scan,
|
||||
`Object-file relocation scanning stopped at ${String(SECTION_RELOCATION_SCAN)} entries. Slots past that point are listed as unresolved.`,
|
||||
);
|
||||
const little = walk.image.byte_order === "little";
|
||||
const length = stride === 8 ? 3 : 2;
|
||||
const add = (
|
||||
address: number,
|
||||
width: number,
|
||||
relocation: ObjectRelocation,
|
||||
): void => {
|
||||
const firstSlot = address - (address % stride);
|
||||
const end = address + width;
|
||||
for (let slot = firstSlot; slot < end; slot += stride) {
|
||||
const exact =
|
||||
address === slot && width === stride && relocation.kind === "unsigned";
|
||||
bySlot.set(
|
||||
slot,
|
||||
exact && !bySlot.has(slot) ? relocation : { kind: "other" },
|
||||
);
|
||||
}
|
||||
};
|
||||
for (let index = 0; index < read; index += 1) {
|
||||
if (index % 4096 === 0) assertArtifactReadActive(walk.signal);
|
||||
const at = offset + index * 8;
|
||||
const first = little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at);
|
||||
const info = little
|
||||
? bytes.readUInt32LE(at + 4)
|
||||
: bytes.readUInt32BE(at + 4);
|
||||
// R_SCATTERED: the low 24 bits hold the address in either byte order.
|
||||
if ((first & 0x80000000) !== 0) {
|
||||
add(first & 0xffffff, 1 << ((first >>> 28) & 3), { kind: "other" });
|
||||
continue;
|
||||
}
|
||||
const fields = little
|
||||
? {
|
||||
symbol: info & 0xffffff,
|
||||
pcrel: (info >>> 24) & 1,
|
||||
length: (info >>> 25) & 3,
|
||||
extern: (info >>> 27) & 1,
|
||||
type: info >>> 28,
|
||||
}
|
||||
: {
|
||||
symbol: info >>> 8,
|
||||
pcrel: (info >>> 7) & 1,
|
||||
length: (info >>> 5) & 3,
|
||||
extern: (info >>> 4) & 1,
|
||||
type: info & 0xf,
|
||||
};
|
||||
add(
|
||||
first,
|
||||
1 << fields.length,
|
||||
fields.type === 0 && fields.pcrel === 0 && fields.length === length
|
||||
? {
|
||||
kind: "unsigned",
|
||||
extern: fields.extern === 1,
|
||||
symbol: fields.symbol,
|
||||
}
|
||||
: { kind: "other" },
|
||||
);
|
||||
}
|
||||
return { bySlot, complete: read === count };
|
||||
};
|
||||
|
||||
/** `S + A` for an external UNSIGNED entry against nlist `index`. */
|
||||
const objectSymbol = (
|
||||
walk: SectionWalk,
|
||||
index: number,
|
||||
stored: bigint,
|
||||
): SlotRelocation => {
|
||||
const table = walk.symbols.table;
|
||||
if (table === null || index >= table.nsyms) return UNRESOLVED;
|
||||
const width = table.wide ? 16 : 12;
|
||||
const at = table.symoff + index * width;
|
||||
const bytes = walk.scan.bytes;
|
||||
if (at + width > bytes.length) return UNRESOLVED;
|
||||
const strx = table.little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at);
|
||||
const type = bytes[at + 4] ?? 0;
|
||||
const value = table.wide
|
||||
? table.little
|
||||
? bytes.readBigUInt64LE(at + 8)
|
||||
: bytes.readBigUInt64BE(at + 8)
|
||||
: BigInt(
|
||||
table.little ? bytes.readUInt32LE(at + 8) : bytes.readUInt32BE(at + 8),
|
||||
);
|
||||
// Debugger stabs never name a relocation target.
|
||||
if ((type & 0xe0) !== 0) return UNRESOLVED;
|
||||
const name = nameAt(walk.scan, table.strings, strx);
|
||||
// N_UNDF: defined in another object or library.
|
||||
if ((type & 0x0e) === 0)
|
||||
return {
|
||||
method: "unresolved_import",
|
||||
value: null,
|
||||
importName: name,
|
||||
definedName: null,
|
||||
};
|
||||
// Only N_SECT has an address in this object.
|
||||
if ((type & 0x0e) !== 0x0e) return UNRESOLVED;
|
||||
return {
|
||||
method: "symbol_addend",
|
||||
value: BigInt.asUintN(walk.image.bits, value + stored),
|
||||
importName: null,
|
||||
definedName: stored === 0n ? name : null,
|
||||
};
|
||||
};
|
||||
|
||||
/** One object-file slot, which only its section relocation entries can change. */
|
||||
const objectSlot = (
|
||||
walk: SectionWalk,
|
||||
relocations: ObjectRelocations,
|
||||
slotOffset: number,
|
||||
stored: bigint,
|
||||
offsets: boolean,
|
||||
): SlotRelocation => {
|
||||
const relocation = relocations.bySlot.get(slotOffset);
|
||||
const unchanged: SlotRelocation = offsets
|
||||
? {
|
||||
method: "offset_table",
|
||||
value: walk.base + stored,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
}
|
||||
: { method: "raw", value: stored, importName: null, definedName: null };
|
||||
if (relocation === undefined)
|
||||
return relocations.complete ? unchanged : UNRESOLVED;
|
||||
if (relocation.kind === "other" || offsets) return UNRESOLVED;
|
||||
// A section-ordinal entry keeps the stored object-space address as the target.
|
||||
if (!relocation.extern) return unchanged;
|
||||
return objectSymbol(walk, relocation.symbol, stored);
|
||||
};
|
||||
|
||||
const slotsOf = async (
|
||||
walk: SectionWalk,
|
||||
section: Extract<ImageSection, { format: "macho" }>,
|
||||
kind: NativeInitializerKind,
|
||||
detail: string,
|
||||
offsets: boolean,
|
||||
entries: CollectedInitializer[],
|
||||
): Promise<boolean> => {
|
||||
const stride = offsets ? 4 : walk.image.bits / 8;
|
||||
if (!section.file_backed) {
|
||||
note(
|
||||
walk.scan,
|
||||
`${section.name} is not file-backed. Its slots were omitted.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (section.size % stride !== 0)
|
||||
note(
|
||||
walk.scan,
|
||||
`${section.name} size is not a multiple of its slot width. Trailing bytes were not read.`,
|
||||
);
|
||||
const count = Math.floor(section.size / stride);
|
||||
const little = walk.image.byte_order === "little";
|
||||
const relocations = walk.object
|
||||
? objectRelocations(walk, section, stride)
|
||||
: undefined;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (entries.length >= MAX_NATIVE_INITIALIZER_ENTRIES) return true;
|
||||
if (index % 128 === 0) {
|
||||
await immediate();
|
||||
assertArtifactReadActive(walk.signal);
|
||||
}
|
||||
const slotVa = section.address + BigInt(index * stride);
|
||||
const slotFile = section.offset + index * stride;
|
||||
const stored = readWord(walk.scan.bytes, slotFile, stride, little);
|
||||
const mappingMismatch =
|
||||
!walk.object && walk.locate(slotVa, stride) !== walk.fileBase + slotFile;
|
||||
if (mappingMismatch)
|
||||
note(
|
||||
walk.scan,
|
||||
`${section.name} slot mapping disagrees with its section offset; affected slots are unresolved.`,
|
||||
);
|
||||
const unresolved: SlotRelocation = {
|
||||
method: "unresolved",
|
||||
value: null,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
};
|
||||
const applied: SlotRelocation =
|
||||
stored === null || mappingMismatch
|
||||
? unresolved
|
||||
: relocations !== undefined
|
||||
? objectSlot(walk, relocations, index * stride, stored, offsets)
|
||||
: offsets
|
||||
? {
|
||||
method: "offset_table",
|
||||
value: walk.base + stored,
|
||||
importName: null,
|
||||
definedName: null,
|
||||
}
|
||||
: pointerResolution(
|
||||
walk.scan,
|
||||
walk.fixups,
|
||||
walk.chained,
|
||||
walk.unchecked,
|
||||
slotVa,
|
||||
stored,
|
||||
);
|
||||
if (stored !== null && applied.method === "unresolved") {
|
||||
if (relocations !== undefined) walk.scan.unappliedObjectRelocations += 1;
|
||||
else if (!offsets) walk.scan.undecoded += 1;
|
||||
}
|
||||
// 32-bit ARM: bit 0 selects Thumb state, so it is cleared to find the code.
|
||||
const code =
|
||||
applied.value === null
|
||||
? null
|
||||
: walk.thumb
|
||||
? applied.value & ~1n
|
||||
: applied.value;
|
||||
const symbol =
|
||||
applied.method === "unresolved_import"
|
||||
? applied.importName
|
||||
: (applied.definedName ??
|
||||
(applied.value === null
|
||||
? null
|
||||
: (walk.symbols.names.get(applied.value) ??
|
||||
(code === null ? null : walk.symbols.names.get(code)) ??
|
||||
null)));
|
||||
entries.push({
|
||||
index: entries.length,
|
||||
kind,
|
||||
slotVa,
|
||||
slotFileOffset: walk.fileBase + slotFile,
|
||||
slotBytes: stride === 4 ? 4 : 8,
|
||||
valueVa: applied.value,
|
||||
valueFileOffset: code === null ? null : walk.locate(code, 1),
|
||||
slotSection: section.name,
|
||||
section: code === null ? null : containing(walk.sections, code),
|
||||
symbol,
|
||||
resolution: applied.method,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
type: S_MOD_INIT_FUNC_POINTERS,
|
||||
kind: "mod_init",
|
||||
detail: "S_MOD_INIT_FUNC_POINTERS",
|
||||
offsets: false,
|
||||
},
|
||||
{
|
||||
type: S_INIT_FUNC_OFFSETS,
|
||||
kind: "init_offsets",
|
||||
detail: "S_INIT_FUNC_OFFSETS",
|
||||
offsets: true,
|
||||
},
|
||||
{
|
||||
type: S_MOD_TERM_FUNC_POINTERS,
|
||||
kind: "mod_term",
|
||||
detail: "S_MOD_TERM_FUNC_POINTERS",
|
||||
offsets: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Decode Mach-O initializer and finalizer slots for one selected thin slice. */
|
||||
export const readMachoInitializers = async (
|
||||
image: MachoInitializerImage,
|
||||
page: InitializerPage,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NativeInitializerInspection> => {
|
||||
assertArtifactReadActive(signal);
|
||||
const container = readMachoImage(image.bytes);
|
||||
const header = readMachoLoadCommandTableHeader(
|
||||
image.bytes,
|
||||
image.bytes.length,
|
||||
);
|
||||
if (!header.ok || header.value === null)
|
||||
throw new BinaryFormatFailure("malformed", "Unrecognized Mach-O header");
|
||||
const scan: MachoScan = {
|
||||
bytes: image.bytes,
|
||||
notes: new InitializerNotes(),
|
||||
incomplete: false,
|
||||
undecoded: 0,
|
||||
unappliedObjectRelocations: 0,
|
||||
};
|
||||
const cpu = header.value.identity.cpuType >>> 0;
|
||||
// arm64_32 pointers are four bytes, so slot width cannot follow a 64-bit header.
|
||||
if (cpu === CPU_TYPE_ARM64_32 && container.bits === 64)
|
||||
throw new BinaryFormatFailure(
|
||||
"malformed",
|
||||
"arm64_32 Mach-O uses 4-byte pointers but declares a 64-bit header",
|
||||
);
|
||||
const little = container.byte_order === "little";
|
||||
// Object files carry section relocations, not dyld fixups.
|
||||
const object = header.value.fileType === MH_OBJECT;
|
||||
const decoded = object
|
||||
? { fixups: null, base: null }
|
||||
: await layoutFixups(scan, container.bits, little, cpu, signal);
|
||||
const unchecked = object
|
||||
? null
|
||||
: uncheckedFixupReason(scan, little, decoded.fixups !== null);
|
||||
const base = imageBase(container.mappings, decoded.base);
|
||||
const symbols = readSymbols(
|
||||
scan,
|
||||
header.value.wide,
|
||||
header.value.littleEndian,
|
||||
);
|
||||
// Built once per image, so each slot and target lookup is logarithmic.
|
||||
const mappings = createFileMappingIndex(
|
||||
container.mappings,
|
||||
container.zero_fills,
|
||||
);
|
||||
const locate = (address: bigint, size: number): number | null => {
|
||||
try {
|
||||
return image.fileBase + mappings.offset(address, size);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof BinaryMappingFailure)) throw cause;
|
||||
if (cause.reason === "ambiguous")
|
||||
note(
|
||||
scan,
|
||||
"An address has ambiguous file mappings (overlapping load segments with different file offsets, or file and zero-fill memory), so its file offset is not reported.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const walk: SectionWalk = {
|
||||
scan,
|
||||
image: container,
|
||||
fileBase: image.fileBase,
|
||||
fixups: decoded.fixups,
|
||||
chained: container.macho_chained_fixups === true,
|
||||
unchecked: unchecked !== null,
|
||||
object,
|
||||
thumb: cpu === CPU_TYPE_ARM,
|
||||
base,
|
||||
symbols,
|
||||
sections: indexSections(container.sections),
|
||||
locate,
|
||||
signal,
|
||||
relocationBudget: SECTION_RELOCATION_SCAN,
|
||||
};
|
||||
const entries: CollectedInitializer[] = [];
|
||||
let stopped = false;
|
||||
for (const spec of SECTIONS) {
|
||||
for (const section of container.sections) {
|
||||
if (section.format !== "macho" || section.type !== spec.type) continue;
|
||||
stopped = await slotsOf(
|
||||
walk,
|
||||
section,
|
||||
spec.kind,
|
||||
spec.detail,
|
||||
spec.offsets,
|
||||
entries,
|
||||
);
|
||||
if (stopped) break;
|
||||
}
|
||||
if (stopped) break;
|
||||
}
|
||||
if (stopped)
|
||||
note(
|
||||
scan,
|
||||
"Initializer scanning stopped at 65536 slots. Later slots were not read.",
|
||||
);
|
||||
// decode() records failures lazily, so sample them only after every slot was decoded.
|
||||
if (
|
||||
container.macho_chained_fixups === true &&
|
||||
(decoded.fixups === null || decoded.fixups.failures.length > 0)
|
||||
)
|
||||
note(scan, "Mach-O chained fixup coverage is incomplete.");
|
||||
if (unchecked !== null && scan.undecoded > 0) note(scan, unchecked);
|
||||
if (scan.unappliedObjectRelocations > 0)
|
||||
note(
|
||||
scan,
|
||||
`${String(scan.unappliedObjectRelocations)} object-file slot(s) have section relocations this reader does not apply and are listed as unresolved.`,
|
||||
);
|
||||
if (scan.undecoded > 0)
|
||||
note(
|
||||
scan,
|
||||
`${String(scan.undecoded)} pointer slot(s) could not be decoded through Mach-O fixups and are listed as unresolved.`,
|
||||
);
|
||||
return finishInitializerReport(
|
||||
{
|
||||
format: "mach-o",
|
||||
architecture: CPU_ARCHITECTURE.get(cpu) ?? null,
|
||||
bits: container.bits,
|
||||
byte_order: container.byte_order,
|
||||
machine: cpu,
|
||||
elf_type: null,
|
||||
slice: image.slice,
|
||||
},
|
||||
entries,
|
||||
// Mach-O omits no slot it reads, so every read slot is an entry.
|
||||
entries.length,
|
||||
scan.notes,
|
||||
page,
|
||||
scan.incomplete,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,654 @@
|
||||
/** Synthetic ELF and Mach-O images with known initializer slots. */
|
||||
|
||||
const elfIdent = (
|
||||
file: Buffer,
|
||||
bits: 32 | 64,
|
||||
type: number,
|
||||
machine: number,
|
||||
): void => {
|
||||
file.set([0x7f, 0x45, 0x4c, 0x46, bits === 64 ? 2 : 1, 1, 1], 0);
|
||||
file.writeUInt16LE(type, 16);
|
||||
file.writeUInt16LE(machine, 18);
|
||||
file.writeUInt32LE(1, 20);
|
||||
};
|
||||
|
||||
const phdr64 = (
|
||||
file: Buffer,
|
||||
at: number,
|
||||
type: number,
|
||||
flags: number,
|
||||
offset: number,
|
||||
address: number,
|
||||
size: number,
|
||||
): void => {
|
||||
file.writeUInt32LE(type, at);
|
||||
file.writeUInt32LE(flags, at + 4);
|
||||
file.writeBigUInt64LE(BigInt(offset), at + 8);
|
||||
file.writeBigUInt64LE(BigInt(address), at + 16);
|
||||
file.writeBigUInt64LE(BigInt(address), at + 24);
|
||||
file.writeBigUInt64LE(BigInt(size), at + 32);
|
||||
file.writeBigUInt64LE(BigInt(size), at + 40);
|
||||
file.writeBigUInt64LE(1n, at + 48);
|
||||
};
|
||||
|
||||
const shdr64 = (
|
||||
file: Buffer,
|
||||
index: number,
|
||||
base: number,
|
||||
name: number,
|
||||
type: number,
|
||||
flags: number,
|
||||
address: number,
|
||||
offset: number,
|
||||
size: number,
|
||||
link = 0,
|
||||
entsize = 0,
|
||||
info = 0,
|
||||
): void => {
|
||||
const at = base + index * 64;
|
||||
file.writeUInt32LE(name, at);
|
||||
file.writeUInt32LE(type, at + 4);
|
||||
file.writeBigUInt64LE(BigInt(flags), at + 8);
|
||||
file.writeBigUInt64LE(BigInt(address), at + 16);
|
||||
file.writeBigUInt64LE(BigInt(offset), at + 24);
|
||||
file.writeBigUInt64LE(BigInt(size), at + 32);
|
||||
file.writeUInt32LE(link, at + 40);
|
||||
file.writeUInt32LE(info, at + 44);
|
||||
file.writeBigUInt64LE(1n, at + 48);
|
||||
file.writeBigUInt64LE(BigInt(entsize), at + 56);
|
||||
};
|
||||
|
||||
const sym64 = (
|
||||
file: Buffer,
|
||||
at: number,
|
||||
name: number,
|
||||
info: number,
|
||||
section: number,
|
||||
value: number,
|
||||
): void => {
|
||||
file.writeUInt32LE(name, at);
|
||||
file.writeUInt8(info, at + 4);
|
||||
file.writeUInt16LE(section, at + 6);
|
||||
file.writeBigUInt64LE(BigInt(value), at + 8);
|
||||
};
|
||||
|
||||
const dyn64 = (
|
||||
file: Buffer,
|
||||
index: number,
|
||||
tag: number,
|
||||
value: number,
|
||||
): void => {
|
||||
const at = 0x300 + index * 16;
|
||||
file.writeBigInt64LE(BigInt(tag), at);
|
||||
file.writeBigUInt64LE(BigInt(value), at + 8);
|
||||
};
|
||||
|
||||
const rela64 = (
|
||||
file: Buffer,
|
||||
at: number,
|
||||
offset: number,
|
||||
symbol: number,
|
||||
type: number,
|
||||
addend: number,
|
||||
): void => {
|
||||
file.writeBigUInt64LE(BigInt(offset), at);
|
||||
file.writeBigUInt64LE((BigInt(symbol) << 32n) | BigInt(type), at + 8);
|
||||
file.writeBigInt64LE(BigInt(addend), at + 16);
|
||||
};
|
||||
|
||||
const cStringTable = (
|
||||
names: readonly string[],
|
||||
): { readonly bytes: Buffer; readonly offsets: number[] } => {
|
||||
const offsets: number[] = [];
|
||||
const parts: Buffer[] = [Buffer.from([0])];
|
||||
let at = 1;
|
||||
for (const name of names) {
|
||||
offsets.push(at);
|
||||
const bytes = Buffer.from(`${name}\0`, "utf8");
|
||||
parts.push(bytes);
|
||||
at += bytes.length;
|
||||
}
|
||||
return { bytes: Buffer.concat(parts), offsets };
|
||||
};
|
||||
|
||||
/**
|
||||
* ELF64 PIE: DT_INIT, RELATIVE init_array, one import, one RELR fini slot.
|
||||
* `needed` prepends that many DT_NEEDED entries, which repeat by design.
|
||||
*/
|
||||
export const elf64PieInitializers = (needed = 0): Buffer => {
|
||||
const file = Buffer.alloc(0xc00);
|
||||
const dynstr = cStringTable(["imp_init"]);
|
||||
const strtab = cStringTable(["ctor_a", "ctor_b", "fini_fn"]);
|
||||
const shstr = cStringTable([
|
||||
".text",
|
||||
".preinit_array",
|
||||
".init_array",
|
||||
".fini_array",
|
||||
".dynamic",
|
||||
".rela.dyn",
|
||||
".dynsym",
|
||||
".dynstr",
|
||||
".symtab",
|
||||
".strtab",
|
||||
".shstrtab",
|
||||
]);
|
||||
elfIdent(file, 64, 3, 62);
|
||||
file.writeBigUInt64LE(64n, 32);
|
||||
file.writeBigUInt64LE(0x800n, 40);
|
||||
file.writeUInt16LE(64, 52);
|
||||
file.writeUInt16LE(56, 54);
|
||||
file.writeUInt16LE(2, 56);
|
||||
file.writeUInt16LE(64, 58);
|
||||
file.writeUInt16LE(12, 60);
|
||||
file.writeUInt16LE(11, 62);
|
||||
phdr64(file, 64, 1, 7, 0, 0, file.length);
|
||||
phdr64(file, 120, 2, 6, 0x300, 0x300, (19 + needed) * 16);
|
||||
file.writeBigUInt64LE(0x120n, 0x220);
|
||||
file.writeBigUInt64LE(0x100n, 0x240);
|
||||
[
|
||||
...Array.from({ length: needed }, () => [1, dynstr.offsets[0] ?? 1]),
|
||||
[12, 0x100],
|
||||
[13, 0x120],
|
||||
[25, 0x200],
|
||||
[27, 24],
|
||||
[26, 0x220],
|
||||
[28, 8],
|
||||
[32, 0x240],
|
||||
[33, 8],
|
||||
[7, 0x480],
|
||||
[8, 96],
|
||||
[9, 24],
|
||||
[36, 0x500],
|
||||
[35, 8],
|
||||
[37, 8],
|
||||
[6, 0x520],
|
||||
[5, 0x560],
|
||||
[10, dynstr.bytes.length],
|
||||
[11, 24],
|
||||
[0, 0],
|
||||
].forEach(([tag, value], index) => dyn64(file, index, tag ?? 0, value ?? 0));
|
||||
rela64(file, 0x480, 0, 0, 0, 0);
|
||||
rela64(file, 0x498, 0x200, 0, 8, 0x100);
|
||||
rela64(file, 0x4b0, 0x208, 0, 8, 0x110);
|
||||
rela64(file, 0x4c8, 0x210, 1, 1, 0);
|
||||
file.writeBigUInt64LE(0x220n, 0x500);
|
||||
sym64(file, 0x538, dynstr.offsets[0] ?? 1, 0x12, 0, 0);
|
||||
sym64(file, 0x598, strtab.offsets[0] ?? 1, 0x12, 1, 0x100);
|
||||
sym64(file, 0x5b0, strtab.offsets[1] ?? 8, 0x12, 1, 0x110);
|
||||
sym64(file, 0x5c8, strtab.offsets[2] ?? 15, 0x12, 1, 0x120);
|
||||
dynstr.bytes.copy(file, 0x560);
|
||||
strtab.bytes.copy(file, 0x600);
|
||||
shstr.bytes.copy(file, 0x680);
|
||||
const name = (index: number): number => shstr.offsets[index] ?? 0;
|
||||
shdr64(file, 1, 0x800, name(0), 1, 6, 0x100, 0x100, 0x40);
|
||||
shdr64(file, 2, 0x800, name(1), 16, 2, 0x240, 0x240, 8);
|
||||
shdr64(file, 3, 0x800, name(2), 14, 2, 0x200, 0x200, 24);
|
||||
shdr64(file, 4, 0x800, name(3), 15, 2, 0x220, 0x220, 8);
|
||||
shdr64(file, 5, 0x800, name(4), 6, 2, 0x300, 0x300, 19 * 16);
|
||||
shdr64(file, 6, 0x800, name(5), 4, 2, 0x480, 0x480, 96, 7, 24);
|
||||
shdr64(file, 7, 0x800, name(6), 11, 2, 0x520, 0x520, 48, 8, 24);
|
||||
shdr64(file, 8, 0x800, name(7), 3, 2, 0x560, 0x560, dynstr.bytes.length);
|
||||
shdr64(file, 9, 0x800, name(8), 2, 0, 0x580, 0x580, 96, 10, 24);
|
||||
shdr64(file, 10, 0x800, name(9), 3, 0, 0x600, 0x600, strtab.bytes.length);
|
||||
shdr64(file, 11, 0x800, name(10), 3, 0, 0, 0x680, shstr.bytes.length);
|
||||
return file;
|
||||
};
|
||||
|
||||
const phdr32 = (
|
||||
file: Buffer,
|
||||
at: number,
|
||||
type: number,
|
||||
offset: number,
|
||||
address: number,
|
||||
size: number,
|
||||
flags: number,
|
||||
): void => {
|
||||
file.writeUInt32LE(type, at);
|
||||
file.writeUInt32LE(offset, at + 4);
|
||||
file.writeUInt32LE(address, at + 8);
|
||||
file.writeUInt32LE(address, at + 12);
|
||||
file.writeUInt32LE(size, at + 16);
|
||||
file.writeUInt32LE(size, at + 20);
|
||||
file.writeUInt32LE(flags, at + 24);
|
||||
file.writeUInt32LE(1, at + 28);
|
||||
};
|
||||
|
||||
const shdr32 = (
|
||||
file: Buffer,
|
||||
index: number,
|
||||
name: number,
|
||||
type: number,
|
||||
flags: number,
|
||||
address: number,
|
||||
offset: number,
|
||||
size: number,
|
||||
link = 0,
|
||||
entsize = 0,
|
||||
): void => {
|
||||
const at = 0x280 + index * 40;
|
||||
file.writeUInt32LE(name, at);
|
||||
file.writeUInt32LE(type, at + 4);
|
||||
file.writeUInt32LE(flags, at + 8);
|
||||
file.writeUInt32LE(address, at + 12);
|
||||
file.writeUInt32LE(offset, at + 16);
|
||||
file.writeUInt32LE(size, at + 20);
|
||||
file.writeUInt32LE(link, at + 24);
|
||||
file.writeUInt32LE(1, at + 32);
|
||||
file.writeUInt32LE(entsize, at + 36);
|
||||
};
|
||||
|
||||
/** ELF32 image whose one init_array slot is an in-place R_386_RELATIVE addend. */
|
||||
export const elf32RelativeInitializers = (): Buffer => {
|
||||
const file = Buffer.alloc(0x480);
|
||||
const strtab = cStringTable(["ctor"]);
|
||||
const shstr = cStringTable([
|
||||
".text",
|
||||
".init_array",
|
||||
".dynamic",
|
||||
".rel.dyn",
|
||||
".symtab",
|
||||
".strtab",
|
||||
".shstrtab",
|
||||
]);
|
||||
elfIdent(file, 32, 3, 3);
|
||||
file.writeUInt32LE(52, 28);
|
||||
file.writeUInt32LE(0x280, 32);
|
||||
file.writeUInt16LE(52, 40);
|
||||
file.writeUInt16LE(32, 42);
|
||||
file.writeUInt16LE(2, 44);
|
||||
file.writeUInt16LE(40, 46);
|
||||
file.writeUInt16LE(8, 48);
|
||||
file.writeUInt16LE(7, 50);
|
||||
phdr32(file, 52, 1, 0, 0, file.length, 7);
|
||||
phdr32(file, 84, 2, 0x120, 0x120, 48, 6);
|
||||
file.writeUInt32LE(0x80, 0x100);
|
||||
[25, 0x100, 27, 4, 17, 0x180, 18, 8, 19, 8, 0, 0].forEach((value, index) => {
|
||||
if (index % 2 === 0) file.writeInt32LE(value, 0x120 + index * 4);
|
||||
else file.writeUInt32LE(value, 0x120 + index * 4);
|
||||
});
|
||||
file.writeUInt32LE(0x100, 0x180);
|
||||
file.writeUInt32LE(8, 0x184);
|
||||
file.writeUInt32LE(strtab.offsets[0] ?? 1, 0x1b0);
|
||||
file.writeUInt32LE(0x80, 0x1b4);
|
||||
file.writeUInt8(0x12, 0x1bc);
|
||||
file.writeUInt16LE(1, 0x1be);
|
||||
strtab.bytes.copy(file, 0x1d0);
|
||||
shstr.bytes.copy(file, 0x200);
|
||||
const name = (index: number): number => shstr.offsets[index] ?? 0;
|
||||
shdr32(file, 1, name(0), 1, 6, 0x80, 0x80, 4);
|
||||
shdr32(file, 2, name(1), 14, 2, 0x100, 0x100, 4);
|
||||
shdr32(file, 3, name(2), 6, 2, 0x120, 0x120, 48);
|
||||
shdr32(file, 4, name(3), 9, 2, 0x180, 0x180, 8, 0, 8);
|
||||
shdr32(file, 5, name(4), 2, 0, 0x1a0, 0x1a0, 32, 6, 16);
|
||||
shdr32(file, 6, name(5), 3, 0, 0x1d0, 0x1d0, strtab.bytes.length);
|
||||
shdr32(file, 7, name(6), 3, 0, 0, 0x200, shstr.bytes.length);
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* ET_REL object: every section starts at address 0. `.ctors` holds sentinel -1,
|
||||
* one R_X86_64_64 slot against `ctor`, and sentinel 0. `.init_array` holds one
|
||||
* slot against the `.text` section symbol plus addend 8, where `init_b` lives.
|
||||
*/
|
||||
export const elfRelocatableCtors = (): Buffer => {
|
||||
const file = Buffer.alloc(0x740);
|
||||
const strtab = cStringTable(["ctor", "init_b"]);
|
||||
const shstr = cStringTable([
|
||||
".text",
|
||||
".ctors",
|
||||
".rela.ctors",
|
||||
".symtab",
|
||||
".strtab",
|
||||
".shstrtab",
|
||||
".init_array",
|
||||
".rela.init_array",
|
||||
]);
|
||||
elfIdent(file, 64, 1, 62);
|
||||
file.writeBigUInt64LE(0x500n, 40);
|
||||
file.writeUInt16LE(64, 52);
|
||||
file.writeUInt16LE(64, 58);
|
||||
file.writeUInt16LE(9, 60);
|
||||
file.writeUInt16LE(6, 62);
|
||||
file.writeBigUInt64LE(0xffffffffffffffffn, 0x200);
|
||||
rela64(file, 0x300, 8, 1, 1, 0);
|
||||
rela64(file, 0x318, 0, 2, 1, 8);
|
||||
sym64(file, 0x338, strtab.offsets[0] ?? 1, 0x12, 1, 0);
|
||||
sym64(file, 0x350, 0, 0x03, 1, 0);
|
||||
sym64(file, 0x368, strtab.offsets[1] ?? 6, 0x12, 1, 8);
|
||||
strtab.bytes.copy(file, 0x380);
|
||||
shstr.bytes.copy(file, 0x400);
|
||||
const name = (index: number): number => shstr.offsets[index] ?? 0;
|
||||
shdr64(file, 1, 0x500, name(0), 1, 6, 0, 0x100, 16);
|
||||
shdr64(file, 2, 0x500, name(1), 1, 3, 0, 0x200, 24);
|
||||
shdr64(file, 3, 0x500, name(2), 4, 0x40, 0, 0x300, 24, 4, 24, 2);
|
||||
shdr64(file, 4, 0x500, name(3), 2, 0, 0, 0x320, 96, 5, 24, 2);
|
||||
shdr64(file, 5, 0x500, name(4), 3, 0, 0, 0x380, strtab.bytes.length);
|
||||
shdr64(file, 6, 0x500, name(5), 3, 0, 0, 0x400, shstr.bytes.length);
|
||||
shdr64(file, 7, 0x500, name(6), 14, 3, 0, 0x220, 8);
|
||||
shdr64(file, 8, 0x500, name(7), 4, 0x40, 0, 0x318, 24, 4, 24, 7);
|
||||
return file;
|
||||
};
|
||||
|
||||
const name16 = (text: string): Buffer => {
|
||||
const bytes = Buffer.alloc(16);
|
||||
bytes.write(text, 0, "utf8");
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const segment64 = (
|
||||
file: Buffer,
|
||||
at: number,
|
||||
name: string,
|
||||
address: bigint,
|
||||
size: number,
|
||||
fileOffset: number,
|
||||
protection: number,
|
||||
sections: number,
|
||||
): void => {
|
||||
file.writeUInt32LE(0x19, at);
|
||||
file.writeUInt32LE(72 + sections * 80, at + 4);
|
||||
name16(name).copy(file, at + 8);
|
||||
file.writeBigUInt64LE(address, at + 24);
|
||||
file.writeBigUInt64LE(BigInt(size), at + 32);
|
||||
file.writeBigUInt64LE(BigInt(fileOffset), at + 40);
|
||||
file.writeBigUInt64LE(BigInt(size), at + 48);
|
||||
file.writeUInt32LE(protection, at + 56);
|
||||
file.writeUInt32LE(protection, at + 60);
|
||||
file.writeUInt32LE(sections, at + 64);
|
||||
};
|
||||
|
||||
const section64 = (
|
||||
file: Buffer,
|
||||
at: number,
|
||||
section: string,
|
||||
segment: string,
|
||||
address: bigint,
|
||||
size: number,
|
||||
offset: number,
|
||||
flags: number,
|
||||
): void => {
|
||||
name16(section).copy(file, at);
|
||||
name16(segment).copy(file, at + 16);
|
||||
file.writeBigUInt64LE(address, at + 32);
|
||||
file.writeBigUInt64LE(BigInt(size), at + 40);
|
||||
file.writeUInt32LE(offset, at + 48);
|
||||
file.writeUInt32LE(3, at + 52);
|
||||
file.writeUInt32LE(flags, at + 64);
|
||||
};
|
||||
|
||||
/** Little-endian arm64 image with one chained initializer, one offset, one raw terminator. */
|
||||
export const machoArm64Initializers = (): Buffer => {
|
||||
const file = Buffer.alloc(0x8000);
|
||||
const base = 0x100000000n;
|
||||
file.writeUInt32LE(0xfeedfacf, 0);
|
||||
file.writeUInt32LE(0x0100000c, 4);
|
||||
file.writeUInt32LE(2, 12);
|
||||
file.writeUInt32LE(4, 16);
|
||||
file.writeUInt32LE(504, 20);
|
||||
segment64(file, 32, "__TEXT", base, 0x4000, 0, 5, 1);
|
||||
section64(file, 104, "__text", "__TEXT", base, 0x2000, 0, 0x80000400);
|
||||
segment64(file, 184, "__DATA", base + 0x4000n, 0x4000, 0x4000, 3, 3);
|
||||
section64(
|
||||
file,
|
||||
256,
|
||||
"__mod_init_func",
|
||||
"__DATA",
|
||||
base + 0x4000n,
|
||||
8,
|
||||
0x4000,
|
||||
0x9,
|
||||
);
|
||||
section64(
|
||||
file,
|
||||
336,
|
||||
"__init_offsets",
|
||||
"__DATA",
|
||||
base + 0x4010n,
|
||||
4,
|
||||
0x4010,
|
||||
0x16,
|
||||
);
|
||||
section64(
|
||||
file,
|
||||
416,
|
||||
"__mod_term_func",
|
||||
"__DATA",
|
||||
base + 0x4020n,
|
||||
8,
|
||||
0x4020,
|
||||
0xa,
|
||||
);
|
||||
file.writeUInt32LE(0x80000034, 496);
|
||||
file.writeUInt32LE(16, 500);
|
||||
file.writeUInt32LE(0x3200, 504);
|
||||
const starts = Buffer.alloc(36);
|
||||
starts.writeUInt32LE(2, 0);
|
||||
starts.writeUInt32LE(0, 4);
|
||||
starts.writeUInt32LE(12, 8);
|
||||
starts.writeUInt32LE(24, 12);
|
||||
starts.writeUInt16LE(0x4000, 16);
|
||||
starts.writeUInt16LE(6, 18);
|
||||
starts.writeBigUInt64LE(0x4000n, 20);
|
||||
starts.writeUInt16LE(1, 32);
|
||||
const header = Buffer.alloc(28);
|
||||
header.writeUInt32LE(28, 4);
|
||||
header.writeUInt32LE(28 + starts.length, 8);
|
||||
header.writeUInt32LE(28 + starts.length, 12);
|
||||
header.writeUInt32LE(1, 20);
|
||||
const chained = Buffer.concat([header, starts, Buffer.from([0])]);
|
||||
file.writeUInt32LE(chained.length, 508);
|
||||
chained.copy(file, 0x3200);
|
||||
file.writeUInt32LE(0x2, 512);
|
||||
file.writeUInt32LE(24, 516);
|
||||
file.writeUInt32LE(0x3000, 520);
|
||||
file.writeUInt32LE(2, 524);
|
||||
file.writeUInt32LE(0x3100, 528);
|
||||
const strings = Buffer.from("\0_init\0_term\0", "utf8");
|
||||
file.writeUInt32LE(strings.length, 532);
|
||||
strings.copy(file, 0x3100);
|
||||
const nlist = (at: number, strx: number, value: bigint): void => {
|
||||
file.writeUInt32LE(strx, at);
|
||||
file.writeUInt8(0x0f, at + 4);
|
||||
file.writeUInt8(1, at + 5);
|
||||
file.writeBigUInt64LE(value, at + 8);
|
||||
};
|
||||
nlist(0x3000, 1, base + 0x1000n);
|
||||
nlist(0x3010, 7, base + 0x1100n);
|
||||
file.writeBigUInt64LE(0x1000n, 0x4000);
|
||||
file.writeUInt32LE(0x1000, 0x4010);
|
||||
file.writeBigUInt64LE(base + 0x1100n, 0x4020);
|
||||
return file;
|
||||
};
|
||||
|
||||
/** Universal Mach-O whose arm64 slice is `machoArm64Initializers`. */
|
||||
export const machoArm64FatInitializers = (): Buffer => {
|
||||
const thin = machoArm64Initializers();
|
||||
const file = Buffer.alloc(0x1000 + thin.length);
|
||||
file.writeUInt32BE(0xcafebabe, 0);
|
||||
file.writeUInt32BE(1, 4);
|
||||
file.writeUInt32BE(0x0100000c, 8);
|
||||
file.writeUInt32BE(0, 12);
|
||||
file.writeUInt32BE(0x1000, 16);
|
||||
file.writeUInt32BE(thin.length, 20);
|
||||
file.writeUInt32BE(12, 24);
|
||||
thin.copy(file, 0x1000);
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* The arm64 image with LC_DYLD_INFO_ONLY in place of chained fixups: a legacy
|
||||
* rebase opcode covers the `__mod_init_func` slot, which stores its target.
|
||||
*/
|
||||
export const machoArm64LegacyRebaseInitializers = (): Buffer => {
|
||||
const file = machoArm64Initializers();
|
||||
// Turn LC_DYLD_CHAINED_FIXUPS into a 16-byte LC_SOURCE_VERSION.
|
||||
file.writeUInt32LE(0x2a, 496);
|
||||
file.writeBigUInt64LE(0n, 504);
|
||||
// Append LC_DYLD_INFO_ONLY after LC_SYMTAB.
|
||||
file.writeUInt32LE(5, 16);
|
||||
file.writeUInt32LE(504 + 48, 20);
|
||||
file.writeUInt32LE(0x80000022, 536);
|
||||
file.writeUInt32LE(48, 540);
|
||||
file.writeUInt32LE(0x3300, 544);
|
||||
// SET_TYPE_IMM(pointer), SET_SEGMENT_AND_OFFSET_ULEB(__DATA, 0), DO_REBASE_IMM_TIMES(1), DONE.
|
||||
const rebase = Buffer.from([0x11, 0x21, 0x00, 0x51, 0x00]);
|
||||
file.writeUInt32LE(rebase.length, 548);
|
||||
rebase.copy(file, 0x3300);
|
||||
file.writeBigUInt64LE(0x100001000n, 0x4000);
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* i386 MH_EXECUTE with one `__mod_init_func` slot storing `_init` and an
|
||||
* LC_DYLD_INFO_ONLY whose bind stream has `bindSize` bytes. 32-bit images have
|
||||
* no fixup decoder here.
|
||||
*/
|
||||
export const macho32Initializers = (bindSize: number): Buffer => {
|
||||
const file = Buffer.alloc(0x2000);
|
||||
file.writeUInt32LE(0xfeedface, 0);
|
||||
file.writeUInt32LE(7, 4);
|
||||
file.writeUInt32LE(3, 8);
|
||||
file.writeUInt32LE(2, 12);
|
||||
file.writeUInt32LE(4, 16);
|
||||
file.writeUInt32LE(320, 20);
|
||||
const segment = (
|
||||
at: number,
|
||||
name: string,
|
||||
address: number,
|
||||
fileOffset: number,
|
||||
protection: number,
|
||||
): void => {
|
||||
file.writeUInt32LE(0x1, at);
|
||||
file.writeUInt32LE(56 + 68, at + 4);
|
||||
name16(name).copy(file, at + 8);
|
||||
file.writeUInt32LE(address, at + 24);
|
||||
file.writeUInt32LE(0x1000, at + 28);
|
||||
file.writeUInt32LE(fileOffset, at + 32);
|
||||
file.writeUInt32LE(0x1000, at + 36);
|
||||
file.writeUInt32LE(protection, at + 40);
|
||||
file.writeUInt32LE(protection, at + 44);
|
||||
file.writeUInt32LE(1, at + 48);
|
||||
};
|
||||
const section = (
|
||||
at: number,
|
||||
name: string,
|
||||
segmentName: string,
|
||||
address: number,
|
||||
size: number,
|
||||
offset: number,
|
||||
flags: number,
|
||||
): void => {
|
||||
name16(name).copy(file, at);
|
||||
name16(segmentName).copy(file, at + 16);
|
||||
file.writeUInt32LE(address, at + 32);
|
||||
file.writeUInt32LE(size, at + 36);
|
||||
file.writeUInt32LE(offset, at + 40);
|
||||
file.writeUInt32LE(2, at + 44);
|
||||
file.writeUInt32LE(flags, at + 56);
|
||||
};
|
||||
segment(28, "__TEXT", 0x1000, 0, 5);
|
||||
section(84, "__text", "__TEXT", 0x1100, 0x100, 0x100, 0x80000400);
|
||||
segment(152, "__DATA", 0x2000, 0x1000, 3);
|
||||
section(208, "__mod_init_func", "__DATA", 0x2000, 4, 0x1000, 0x9);
|
||||
file.writeUInt32LE(0x80000022, 276);
|
||||
file.writeUInt32LE(48, 280);
|
||||
file.writeUInt32LE(0x1800, 292);
|
||||
file.writeUInt32LE(bindSize, 296);
|
||||
file.writeUInt32LE(0x2, 324);
|
||||
file.writeUInt32LE(24, 328);
|
||||
file.writeUInt32LE(0x1900, 332);
|
||||
file.writeUInt32LE(1, 336);
|
||||
file.writeUInt32LE(0x1910, 340);
|
||||
const strings = Buffer.from("\0_init\0", "utf8");
|
||||
file.writeUInt32LE(strings.length, 344);
|
||||
file.writeUInt32LE(1, 0x1900);
|
||||
file.writeUInt8(0x0f, 0x1904);
|
||||
file.writeUInt8(1, 0x1905);
|
||||
file.writeUInt32LE(0x1100, 0x1908);
|
||||
strings.copy(file, 0x1910);
|
||||
file.writeUInt32LE(0x1100, 0x1000);
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* x86-64 MH_OBJECT: `_before` at 0x0 and `_startup` at 0x10 in `__text`. The
|
||||
* first `__mod_init_func` slot stores 0 under an external
|
||||
* X86_64_RELOC_UNSIGNED against `_startup`; the second stores 0 under a
|
||||
* section-ordinal entry, so its stored address names `_before`.
|
||||
*/
|
||||
export const machoX8664ObjectInitializers = (): Buffer => {
|
||||
const file = Buffer.alloc(0x300);
|
||||
file.writeUInt32LE(0xfeedfacf, 0);
|
||||
file.writeUInt32LE(0x01000007, 4);
|
||||
file.writeUInt32LE(3, 8);
|
||||
file.writeUInt32LE(1, 12);
|
||||
file.writeUInt32LE(2, 16);
|
||||
file.writeUInt32LE(72 + 2 * 80 + 24, 20);
|
||||
segment64(file, 32, "", 0n, 0x30, 0x200, 7, 2);
|
||||
section64(file, 104, "__text", "__TEXT", 0n, 0x20, 0x200, 0x80000400);
|
||||
section64(file, 184, "__mod_init_func", "__DATA", 0x20n, 16, 0x220, 0x9);
|
||||
file.writeUInt32LE(0x240, 184 + 56);
|
||||
file.writeUInt32LE(2, 184 + 60);
|
||||
// r_address 0, r_symbolnum 1 (_startup), r_length 3, r_extern 1, UNSIGNED.
|
||||
file.writeUInt32LE(0, 0x240);
|
||||
file.writeUInt32LE(1 | (3 << 25) | (1 << 27), 0x244);
|
||||
// r_address 8, r_symbolnum 1 (section __text), r_length 3, r_extern 0.
|
||||
file.writeUInt32LE(8, 0x248);
|
||||
file.writeUInt32LE(1 | (3 << 25), 0x24c);
|
||||
file.writeUInt32LE(0x2, 264);
|
||||
file.writeUInt32LE(24, 268);
|
||||
file.writeUInt32LE(0x250, 272);
|
||||
file.writeUInt32LE(2, 276);
|
||||
file.writeUInt32LE(0x270, 280);
|
||||
const strings = Buffer.from("\0_before\0_startup\0", "utf8");
|
||||
file.writeUInt32LE(strings.length, 284);
|
||||
const nlist = (at: number, strx: number, value: bigint): void => {
|
||||
file.writeUInt32LE(strx, at);
|
||||
file.writeUInt8(0x0e, at + 4);
|
||||
file.writeUInt8(1, at + 5);
|
||||
file.writeBigUInt64LE(value, at + 8);
|
||||
};
|
||||
nlist(0x250, 1, 0n);
|
||||
nlist(0x260, 9, 0x10n);
|
||||
strings.copy(file, 0x270);
|
||||
return file;
|
||||
};
|
||||
|
||||
/** Sparse bind runs bracket the initializer slots without binding any of them. */
|
||||
export const machoSparseBindInitializers = (count: number): Buffer => {
|
||||
const uleb = (value: number): Buffer => {
|
||||
let n = BigInt(value);
|
||||
const out: number[] = [];
|
||||
do {
|
||||
const byte = Number(n & 127n);
|
||||
n >>= 7n;
|
||||
out.push(byte | (n > 0n ? 128 : 0));
|
||||
} while (n > 0n);
|
||||
return Buffer.from(out);
|
||||
};
|
||||
const parts: Buffer[] = [Buffer.from([0x51, 0x40, 0x5f, 0x78, 0])];
|
||||
for (let index = 0; index < count; index++)
|
||||
parts.push(
|
||||
Buffer.from([0x71]),
|
||||
uleb(index * 8),
|
||||
Buffer.from([0xc0]),
|
||||
uleb(2),
|
||||
uleb(count * 32 - 8),
|
||||
);
|
||||
parts.push(Buffer.from([0]));
|
||||
const stream = Buffer.concat(parts);
|
||||
const dataSize = count * 64;
|
||||
const bindOffset = 0x4000 + dataSize;
|
||||
const image = Buffer.alloc(bindOffset + stream.length);
|
||||
machoArm64LegacyRebaseInitializers().copy(image);
|
||||
image.writeBigUInt64LE(BigInt(dataSize), 184 + 32);
|
||||
image.writeBigUInt64LE(BigInt(dataSize), 184 + 48);
|
||||
image.writeBigUInt64LE(0x100004000n + BigInt(count * 8), 256 + 32);
|
||||
image.writeBigUInt64LE(BigInt(count * 8), 256 + 40);
|
||||
image.writeUInt32LE(0x4000 + count * 8, 256 + 48);
|
||||
image.writeUInt32LE(0, 548);
|
||||
image.writeUInt32LE(bindOffset, 552);
|
||||
image.writeUInt32LE(stream.length, 556);
|
||||
stream.copy(image, bindOffset);
|
||||
return image;
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
projectArtifactCloseFailure,
|
||||
projectStableArtifactFailure,
|
||||
} from "../../artifacts/StableArtifactFailure.js";
|
||||
import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
artifactFilePrimaryFacts,
|
||||
assertArtifactReadActive,
|
||||
type ArtifactFilePrimaryFacts,
|
||||
} from "../../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import type { NativeInitializersPort } from "../../application/binaryDiagnostics/NativeInitializersPort.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 {
|
||||
MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES,
|
||||
type InspectNativeInitializersInput,
|
||||
type NativeInitializerInspection,
|
||||
type NativeInitializers,
|
||||
type NativeInitializerSlice,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { NATIVE_INITIALIZERS_PROVIDER } from "../../domain/investigationProviders.js";
|
||||
import {
|
||||
BinaryFormatFailure,
|
||||
BinaryResourceFailure,
|
||||
} from "../metadata/BinaryContainer.js";
|
||||
import { ElfPointerRelocationFailure } from "../metadata/ElfPointerRelocations.js";
|
||||
import { requireSelectedNativeImage } from "../metadata/MachoSliceSelection.js";
|
||||
import { readElfInitializers } from "./ElfInitializers.js";
|
||||
import { readMachoInitializers } from "./MachoInitializers.js";
|
||||
|
||||
const OPERATION = "inspect_native_initializers";
|
||||
|
||||
const MACHO_MAGICS = new Set([
|
||||
0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, 0xcafebabe, 0xbebafeca,
|
||||
0xcafebabf, 0xbfbafeca,
|
||||
]);
|
||||
|
||||
const isElf = (bytes: Buffer): boolean =>
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0x7f &&
|
||||
bytes[1] === 0x45 &&
|
||||
bytes[2] === 0x4c &&
|
||||
bytes[3] === 0x46;
|
||||
|
||||
const isMacho = (bytes: Buffer): boolean =>
|
||||
bytes.length >= 4 && MACHO_MAGICS.has(bytes.readUInt32BE(0));
|
||||
|
||||
/** Portable ELF and Mach-O initializer reader over one stable regular file. */
|
||||
export class NativeInitializersProvider implements NativeInitializersPort {
|
||||
readonly identity = NATIVE_INITIALIZERS_PROVIDER;
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#resources.close();
|
||||
} catch (cause: unknown) {
|
||||
throw projectArtifactCloseFailure(
|
||||
cause,
|
||||
NATIVE_INITIALIZERS_PROVIDER.id,
|
||||
OPERATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
input: InspectNativeInitializersInput,
|
||||
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 readNativeInitializers(
|
||||
snapshot.bytes,
|
||||
{
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
input,
|
||||
options?.signal,
|
||||
);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
return ok(report);
|
||||
});
|
||||
} catch (cause) {
|
||||
return err(nativeInitializersFailure(cause, input));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode one stable buffer. ELF is read whole; Mach-O uses the selected slice. */
|
||||
export const readNativeInitializers = async (
|
||||
bytes: Buffer,
|
||||
artifact: NativeInitializers["artifact"],
|
||||
input: Pick<
|
||||
InspectNativeInitializersInput,
|
||||
"offset" | "limit" | "architecture" | "path"
|
||||
>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NativeInitializers> => {
|
||||
assertArtifactReadActive(signal);
|
||||
const selected = requireSelectedNativeImage(
|
||||
OPERATION,
|
||||
input.path,
|
||||
bytes,
|
||||
input.architecture,
|
||||
);
|
||||
const page = { offset: input.offset, limit: input.limit };
|
||||
const inspection = await inspectSelected(bytes, selected, page, signal);
|
||||
return { artifact, ...inspection };
|
||||
};
|
||||
|
||||
const inspectSelected = async (
|
||||
container: Buffer,
|
||||
selected: ReturnType<typeof requireSelectedNativeImage>,
|
||||
page: { readonly offset: number; readonly limit: number },
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<NativeInitializerInspection> => {
|
||||
if (isElf(selected.bytes)) {
|
||||
if (selected.selection !== null)
|
||||
throw new BinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF bytes are not a Mach-O slice",
|
||||
);
|
||||
return readElfInitializers(selected.bytes, page, signal);
|
||||
}
|
||||
if (!isMacho(container) && !isMacho(selected.bytes))
|
||||
throw new BinaryFormatFailure(
|
||||
"malformed",
|
||||
"Selected file is not an ELF or Mach-O image",
|
||||
);
|
||||
const selection = selected.selection;
|
||||
const slice: NativeInitializerSlice | null =
|
||||
selection === null
|
||||
? null
|
||||
: {
|
||||
file_offset: selection.offset,
|
||||
size: selection.byte_length,
|
||||
cpu_type: selection.cpu_type,
|
||||
cpu_subtype: selection.cpu_subtype,
|
||||
architecture: architectureName(selection.cpu_type),
|
||||
};
|
||||
return readMachoInitializers(
|
||||
{ bytes: selected.bytes, fileBase: selected.fileOffset, slice },
|
||||
page,
|
||||
signal,
|
||||
);
|
||||
};
|
||||
|
||||
const architectureName = (cpuType: number): string => {
|
||||
switch (cpuType >>> 0) {
|
||||
case 7:
|
||||
return "x86";
|
||||
case 0x01000007:
|
||||
return "x86_64";
|
||||
case 12:
|
||||
return "arm";
|
||||
case 0x0100000c:
|
||||
return "arm64";
|
||||
default:
|
||||
return `cpu-0x${(cpuType >>> 0).toString(16)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const fileSizeRemediation = (input: InspectNativeInitializersInput): string =>
|
||||
input.max_file_bytes >= MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES
|
||||
? `The image exceeds the ${String(MAX_INSPECT_NATIVE_INITIALIZERS_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 failurePrimary = (
|
||||
facts: ArtifactFilePrimaryFacts,
|
||||
input: InspectNativeInitializersInput,
|
||||
): 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 ElfPointerRelocationFailure &&
|
||||
cause.reason === "native-index-capacity"
|
||||
)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"capacity",
|
||||
`Selected artifact ${input.path}: ${cause.message}`,
|
||||
{ boundary: "structure" },
|
||||
{
|
||||
cause,
|
||||
...fixedAllowanceOptions(
|
||||
"The native initializer structural allowance",
|
||||
"parser",
|
||||
),
|
||||
},
|
||||
);
|
||||
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 native initializer 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(NATIVE_INITIALIZERS_PROVIDER.id, OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
path: input.path,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const nativeInitializersFailure = (
|
||||
cause: unknown,
|
||||
input: InspectNativeInitializersInput,
|
||||
): AnalysisError =>
|
||||
projectStableArtifactFailure(
|
||||
cause,
|
||||
failurePrimary(artifactFilePrimaryFacts(cause), input),
|
||||
input.path,
|
||||
NATIVE_INITIALIZERS_PROVIDER.id,
|
||||
OPERATION,
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
/** One half-open address range `[start, end)` and the value it names. */
|
||||
export interface AddressRange<T> {
|
||||
readonly start: bigint;
|
||||
readonly end: bigint;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
const compareBigInt = (left: bigint, right: bigint): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
|
||||
/** Minimal binary heap; `before(a, b)` is true when `a` belongs on top. */
|
||||
const heap = <T>(before: (left: T, right: T) => boolean) => {
|
||||
const items: T[] = [];
|
||||
const swap = (a: number, b: number): void => {
|
||||
const held = items[a] as T;
|
||||
items[a] = items[b] as T;
|
||||
items[b] = held;
|
||||
};
|
||||
return {
|
||||
top: (): T | undefined => items[0],
|
||||
push(item: T): void {
|
||||
items.push(item);
|
||||
let at = items.length - 1;
|
||||
while (at > 0) {
|
||||
const parent = (at - 1) >> 1;
|
||||
if (!before(items[at] as T, items[parent] as T)) break;
|
||||
swap(at, parent);
|
||||
at = parent;
|
||||
}
|
||||
},
|
||||
pop(): void {
|
||||
const last = items.pop();
|
||||
if (last === undefined || items.length === 0) return;
|
||||
items[0] = last;
|
||||
let at = 0;
|
||||
for (;;) {
|
||||
const left = at * 2 + 1;
|
||||
const right = left + 1;
|
||||
let best = at;
|
||||
if (left < items.length && before(items[left] as T, items[best] as T))
|
||||
best = left;
|
||||
if (right < items.length && before(items[right] as T, items[best] as T))
|
||||
best = right;
|
||||
if (best === at) return;
|
||||
swap(at, best);
|
||||
at = best;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Disjoint pieces of overlapping ranges, each naming the preferred range that
|
||||
* covers it. Built once in O(n log n) so per-address lookups are binary
|
||||
* searches instead of walks over every header.
|
||||
*/
|
||||
export interface AddressRangeIndex<T> {
|
||||
/** The preferred range covering `address`, if any. */
|
||||
readonly at: (address: bigint) => AddressRange<T> | undefined;
|
||||
}
|
||||
|
||||
/** `prefer(a, b) < 0` when `a` wins where both cover an address. It must be a total order. */
|
||||
export const buildAddressRangeIndex = <T>(
|
||||
ranges: readonly AddressRange<T>[],
|
||||
prefer: (left: T, right: T) => number,
|
||||
): AddressRangeIndex<T> => {
|
||||
const sorted = ranges
|
||||
.filter((range) => range.end > range.start)
|
||||
.sort((left, right) => compareBigInt(left.start, right.start));
|
||||
const bounds = [
|
||||
...new Set(sorted.flatMap((range) => [range.start, range.end])),
|
||||
].sort(compareBigInt);
|
||||
const active = heap<AddressRange<T>>(
|
||||
(left, right) => prefer(left.value, right.value) < 0,
|
||||
);
|
||||
const starts: bigint[] = [];
|
||||
const ends: bigint[] = [];
|
||||
const winners: AddressRange<T>[] = [];
|
||||
let next = 0;
|
||||
for (let index = 0; index + 1 < bounds.length; index += 1) {
|
||||
const bound = bounds[index] as bigint;
|
||||
while (
|
||||
next < sorted.length &&
|
||||
(sorted[next] as AddressRange<T>).start <= bound
|
||||
) {
|
||||
active.push(sorted[next] as AddressRange<T>);
|
||||
next += 1;
|
||||
}
|
||||
let top = active.top();
|
||||
while (top !== undefined && top.end <= bound) {
|
||||
active.pop();
|
||||
top = active.top();
|
||||
}
|
||||
if (top === undefined) continue;
|
||||
const pieceEnd = bounds[index + 1] as bigint;
|
||||
const last = winners.length - 1;
|
||||
if (last >= 0 && winners[last] === top && ends[last] === bound) {
|
||||
ends[last] = pieceEnd;
|
||||
continue;
|
||||
}
|
||||
starts.push(bound);
|
||||
ends.push(pieceEnd);
|
||||
winners.push(top);
|
||||
}
|
||||
return {
|
||||
at(address) {
|
||||
let low = 0;
|
||||
let high = starts.length - 1;
|
||||
while (low <= high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if ((starts[middle] as bigint) > address) high = middle - 1;
|
||||
else if ((ends[middle] as bigint) <= address) low = middle + 1;
|
||||
else return winners[middle];
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
MAX_NATIVE_INITIALIZER_NAME_BYTES,
|
||||
MAX_NATIVE_INITIALIZER_NOTES,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
|
||||
/** Observations a crafted table can repeat per row, so they are reported once with a count. */
|
||||
const TALLY_TEXT = {
|
||||
"long-symbol-name": (count: number) =>
|
||||
`${String(count)} symbol name(s) exceeded the ${String(MAX_NATIVE_INITIALIZER_NAME_BYTES)}-byte name budget and were not used.`,
|
||||
"unreadable-symbol-name": (count: number) =>
|
||||
`${String(count)} symbol name(s) could not be read because the string table is missing or the name offset is outside it.`,
|
||||
"relocation-entry-size": (count: number) =>
|
||||
`${String(count)} relocation section(s) had an unexpected entry size and were not applied.`,
|
||||
} as const;
|
||||
export type InitializerTally = keyof typeof TALLY_TEXT;
|
||||
const TALLIES = Object.keys(TALLY_TEXT) as InitializerTally[];
|
||||
|
||||
/**
|
||||
* Image observations for one inspection. Distinct messages are kept once in
|
||||
* arrival order up to the note budget; later ones are counted, and repeated
|
||||
* per-row reasons are tallied, so a crafted table cannot grow the report.
|
||||
*/
|
||||
export class InitializerNotes {
|
||||
private readonly messages: string[] = [];
|
||||
private readonly tallies = new Map<InitializerTally, number>();
|
||||
private omitted = 0;
|
||||
/** Every observation recorded, including duplicates, tallies and omitted ones. */
|
||||
recorded = 0;
|
||||
|
||||
add(message: string): void {
|
||||
this.recorded += 1;
|
||||
if (this.messages.includes(message)) return;
|
||||
if (this.messages.length >= MAX_NATIVE_INITIALIZER_NOTES) {
|
||||
this.omitted += 1;
|
||||
return;
|
||||
}
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
tally(reason: InitializerTally): void {
|
||||
this.recorded += 1;
|
||||
this.tallies.set(reason, (this.tallies.get(reason) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/** At most the note budget, one line per tally reason and one omission line. */
|
||||
list(): string[] {
|
||||
return [
|
||||
...this.messages,
|
||||
...TALLIES.flatMap((reason) => {
|
||||
const count = this.tallies.get(reason);
|
||||
return count === undefined ? [] : [TALLY_TEXT[reason](count)];
|
||||
}),
|
||||
...(this.omitted === 0
|
||||
? []
|
||||
: [
|
||||
`${String(this.omitted)} further observation(s), repeats included, were not listed after the ${String(MAX_NATIVE_INITIALIZER_NOTES)}-note budget.`,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
NativeInitializerCoverage,
|
||||
NativeInitializerEntry,
|
||||
NativeInitializerInspection,
|
||||
NativeInitializerKind,
|
||||
NativeInitializerName,
|
||||
NativeInitializerResolution,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
import {
|
||||
MAX_NATIVE_INITIALIZER_NAME_BYTES,
|
||||
NATIVE_INITIALIZER_LIMITATIONS,
|
||||
} from "../../domain/native/nativeInitializers.js";
|
||||
import type { InitializerNotes } from "./initializerNotes.js";
|
||||
|
||||
/** Page requested by the caller after schema defaults. */
|
||||
export interface InitializerPage {
|
||||
readonly offset: number;
|
||||
readonly limit: number;
|
||||
}
|
||||
|
||||
/** One collected slot before paging. `index` is assigned from file order. */
|
||||
export interface CollectedInitializer {
|
||||
readonly index: number;
|
||||
readonly kind: NativeInitializerKind;
|
||||
/** Null for DT_INIT / DT_FINI when the dynamic value field is outside every load segment. */
|
||||
readonly slotVa: bigint | null;
|
||||
readonly slotFileOffset: number | null;
|
||||
readonly slotBytes: 4 | 8;
|
||||
readonly valueVa: bigint | null;
|
||||
readonly valueFileOffset: number | null;
|
||||
readonly slotSection: string | null;
|
||||
readonly section: string | null;
|
||||
readonly symbol: string | null;
|
||||
readonly resolution: NativeInitializerResolution;
|
||||
readonly detail: string | null;
|
||||
}
|
||||
|
||||
/** Lowercase `0x` hex without leading zeros other than `0x0`. */
|
||||
export const hexAddress = (value: bigint): string => `0x${value.toString(16)}`;
|
||||
|
||||
const fitsName = (name: string | null): name is string =>
|
||||
name !== null &&
|
||||
name.length > 0 &&
|
||||
name.length <= MAX_NATIVE_INITIALIZER_NAME_BYTES;
|
||||
|
||||
/** Target-derived text. Empty and over-long names are not reported. */
|
||||
const untrustedName = (name: string | null): NativeInitializerName | null =>
|
||||
fitsName(name) ? { name, trust: "observed-untrusted" } : null;
|
||||
|
||||
const pageOf = (
|
||||
entries: readonly CollectedInitializer[],
|
||||
examined: number,
|
||||
page: InitializerPage,
|
||||
incomplete: boolean,
|
||||
): {
|
||||
readonly entries: CollectedInitializer[];
|
||||
readonly coverage: NativeInitializerCoverage;
|
||||
} => {
|
||||
const returned = entries.slice(page.offset, page.offset + page.limit);
|
||||
return {
|
||||
entries: returned,
|
||||
coverage: {
|
||||
status: incomplete ? "incomplete" : "complete",
|
||||
examined_entries: examined,
|
||||
total_entries: incomplete ? null : entries.length,
|
||||
returned_entries: returned.length,
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Project one entry after completeness has accounted for all collected names. */
|
||||
const toEntry = (entry: CollectedInitializer): NativeInitializerEntry => ({
|
||||
index: entry.index,
|
||||
kind: entry.kind,
|
||||
slot_va: entry.slotVa === null ? null : hexAddress(entry.slotVa),
|
||||
slot_file_offset: entry.slotFileOffset,
|
||||
slot_bytes: entry.slotBytes,
|
||||
value_va: entry.valueVa === null ? null : hexAddress(entry.valueVa),
|
||||
value_file_offset: entry.valueFileOffset,
|
||||
slot_section: untrustedName(entry.slotSection),
|
||||
section: untrustedName(entry.section),
|
||||
symbol: untrustedName(entry.symbol),
|
||||
resolution: entry.resolution,
|
||||
detail: untrustedName(entry.detail)?.name ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Standing limitations first, then observations from this image. `examined`
|
||||
* counts every slot read, including omitted sentinels.
|
||||
*/
|
||||
export const finishInitializerReport = (
|
||||
body: Omit<
|
||||
NativeInitializerInspection,
|
||||
"status" | "coverage" | "entries" | "limitations" | "applicability"
|
||||
>,
|
||||
entries: readonly CollectedInitializer[],
|
||||
examined: number,
|
||||
notes: InitializerNotes,
|
||||
page: InitializerPage,
|
||||
incomplete: boolean,
|
||||
): NativeInitializerInspection => {
|
||||
let withheld = 0;
|
||||
for (const entry of entries)
|
||||
for (const name of [
|
||||
entry.slotSection,
|
||||
entry.section,
|
||||
entry.symbol,
|
||||
entry.detail,
|
||||
])
|
||||
if (name !== null && name.length > MAX_NATIVE_INITIALIZER_NAME_BYTES)
|
||||
withheld += 1;
|
||||
const incompleteReport = incomplete || withheld > 0;
|
||||
const paged = pageOf(entries, examined, page, incompleteReport);
|
||||
const returned = paged.entries.map(toEntry);
|
||||
return {
|
||||
...body,
|
||||
applicability: "applicable",
|
||||
status: incompleteReport ? "incomplete" : "parsed",
|
||||
coverage: paged.coverage,
|
||||
entries: returned,
|
||||
limitations: [
|
||||
...NATIVE_INITIALIZER_LIMITATIONS,
|
||||
...notes.list(),
|
||||
...(withheld === 0
|
||||
? []
|
||||
: [
|
||||
`${String(withheld)} section, symbol or detail name(s) exceeded the ${String(MAX_NATIVE_INITIALIZER_NAME_BYTES)}-byte name budget and were reported as null.`,
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -114,6 +114,8 @@ export type ImageSection = {
|
||||
};
|
||||
readonly segment_mapping: FileMapping | null;
|
||||
readonly segment_unmapped: boolean;
|
||||
/** Declared `reloff` and `nreloc`, which only object files populate. */
|
||||
readonly relocations: { readonly offset: number; readonly count: number };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
elfAbsoluteRelocationType,
|
||||
readElfRelocationWritesSteps,
|
||||
type ElfPointerWrite,
|
||||
} from "./ElfPointerRelocations.js";
|
||||
|
||||
const EM_X86_64 = 62;
|
||||
|
||||
const image = (): Buffer => {
|
||||
const bytes = Buffer.alloc(0x400);
|
||||
const dynamic: [bigint, bigint][] = [
|
||||
[7n, 0x100n],
|
||||
[8n, 72n],
|
||||
[9n, 24n],
|
||||
[17n, 0x200n],
|
||||
[18n, 16n],
|
||||
[19n, 16n],
|
||||
[0n, 0n],
|
||||
];
|
||||
dynamic.forEach(([tag, value], index) => {
|
||||
bytes.writeBigUInt64LE(tag, index * 16);
|
||||
bytes.writeBigUInt64LE(value, index * 16 + 8);
|
||||
});
|
||||
const rela: [bigint, bigint, bigint][] = [
|
||||
[0x300n, (0n << 32n) | 8n, 0x1234n],
|
||||
[0x308n, (2n << 32n) | 1n, 0x10n],
|
||||
[0x310n, (3n << 32n) | 6n, -8n],
|
||||
];
|
||||
rela.forEach(([offset, info, addend], index) => {
|
||||
const at = 0x100 + index * 24;
|
||||
bytes.writeBigUInt64LE(offset, at);
|
||||
bytes.writeBigUInt64LE(info, at + 8);
|
||||
bytes.writeBigInt64LE(addend, at + 16);
|
||||
});
|
||||
bytes.writeBigUInt64LE(0x318n, 0x200);
|
||||
bytes.writeBigUInt64LE((5n << 32n) | 1n, 0x208);
|
||||
bytes.writeBigUInt64LE(0x40n, 0x318);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const drain = (bytes: Buffer): ElfPointerWrite[] => {
|
||||
const writes: ElfPointerWrite[] = [];
|
||||
const steps = readElfRelocationWritesSteps(bytes, {
|
||||
bits: 64,
|
||||
little: true,
|
||||
machine: EM_X86_64,
|
||||
dynamicSegments: [{ offset: 0n, size: 112n }],
|
||||
mappings: [{ address: 0n, offset: 0, size: bytes.length }],
|
||||
zeroFills: [],
|
||||
});
|
||||
for (let next = steps.next(); !next.done; next = steps.next())
|
||||
if (next.value !== undefined) writes.push(next.value);
|
||||
return writes;
|
||||
};
|
||||
|
||||
it("returns the decoded record type, symbol index and addend with each write", () => {
|
||||
const writes = drain(image());
|
||||
expect(
|
||||
writes.map((write) => ({
|
||||
address: write.address,
|
||||
value: write.value,
|
||||
encoding: write.source.encoding,
|
||||
kind: write.source.kind,
|
||||
record_file_offset: write.source.record_file_offset,
|
||||
symbol_index: write.symbol_index,
|
||||
addend: write.addend,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
address: 0x300n,
|
||||
value: 0x1234n,
|
||||
encoding: "rela",
|
||||
kind: 8,
|
||||
record_file_offset: 0x100,
|
||||
symbol_index: 0,
|
||||
addend: 0x1234n,
|
||||
},
|
||||
{
|
||||
address: 0x308n,
|
||||
value: null,
|
||||
encoding: "rela",
|
||||
kind: 1,
|
||||
record_file_offset: 0x118,
|
||||
symbol_index: 2,
|
||||
addend: 0x10n,
|
||||
},
|
||||
{
|
||||
address: 0x310n,
|
||||
value: null,
|
||||
encoding: "rela",
|
||||
kind: 6,
|
||||
record_file_offset: 0x130,
|
||||
symbol_index: 3,
|
||||
addend: -8n,
|
||||
},
|
||||
{
|
||||
address: 0x318n,
|
||||
value: null,
|
||||
encoding: "rel",
|
||||
kind: 1,
|
||||
record_file_offset: 0x200,
|
||||
symbol_index: 5,
|
||||
addend: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("owns the word-sized absolute relocation type per machine", () => {
|
||||
expect(elfAbsoluteRelocationType(62)).toBe(1);
|
||||
expect(elfAbsoluteRelocationType(3)).toBe(1);
|
||||
expect(elfAbsoluteRelocationType(183)).toBe(257);
|
||||
expect(elfAbsoluteRelocationType(40)).toBe(2);
|
||||
expect(elfAbsoluteRelocationType(8)).toBeUndefined();
|
||||
});
|
||||
@@ -34,6 +34,10 @@ export interface ElfPointerWrite {
|
||||
readonly value: bigint | null;
|
||||
readonly file_offset: number | null;
|
||||
readonly source: ElfPointerRelocationSource;
|
||||
/** Decoded REL/RELA `r_sym`; null for RELR. */
|
||||
readonly symbol_index: number | null;
|
||||
/** Decoded RELA `r_addend`; null for REL (implicit in place) and RELR. */
|
||||
readonly addend: bigint | null;
|
||||
}
|
||||
export interface ElfOverlayPointer {
|
||||
readonly value: bigint;
|
||||
@@ -98,6 +102,7 @@ const RELOCATION_KINDS = new Map([
|
||||
[
|
||||
183,
|
||||
{
|
||||
absolute: 257,
|
||||
bits: 64,
|
||||
relative: 1027,
|
||||
widths: new Map([
|
||||
@@ -119,6 +124,7 @@ const RELOCATION_KINDS = new Map([
|
||||
[
|
||||
62,
|
||||
{
|
||||
absolute: 1,
|
||||
bits: 64,
|
||||
relative: 8,
|
||||
widths: new Map([
|
||||
@@ -139,6 +145,7 @@ const RELOCATION_KINDS = new Map([
|
||||
[
|
||||
40,
|
||||
{
|
||||
absolute: 2,
|
||||
bits: 32,
|
||||
relative: 23,
|
||||
widths: new Map([
|
||||
@@ -156,6 +163,7 @@ const RELOCATION_KINDS = new Map([
|
||||
[
|
||||
3,
|
||||
{
|
||||
absolute: 1,
|
||||
bits: 32,
|
||||
relative: 8,
|
||||
widths: new Map([
|
||||
@@ -189,6 +197,23 @@ const RELOCATION_KINDS = new Map([
|
||||
],
|
||||
]);
|
||||
|
||||
/** R_*_RELATIVE type for a supported ELF machine, or undefined when unsupported. */
|
||||
export const elfRelativeRelocationType = (
|
||||
machine: number,
|
||||
): number | undefined => RELOCATION_KINDS.get(machine)?.relative;
|
||||
|
||||
/** Word-sized absolute (S + A) relocation type for a supported ELF machine. */
|
||||
export const elfAbsoluteRelocationType = (
|
||||
machine: number,
|
||||
): number | undefined => RELOCATION_KINDS.get(machine)?.absolute;
|
||||
|
||||
/** Bytes written by a relocation; type zero is a no-op and unknown types remain undefined. */
|
||||
export const elfRelocationWidth = (
|
||||
machine: number,
|
||||
type: number,
|
||||
): number | undefined =>
|
||||
type === 0 ? 0 : RELOCATION_KINDS.get(machine)?.widths.get(type);
|
||||
|
||||
function* dynamicTags(
|
||||
bytes: Buffer,
|
||||
input: ElfPointerRelocationInput,
|
||||
@@ -314,14 +339,14 @@ export function* readElfRelocationWritesSteps(
|
||||
address: bigint,
|
||||
width: number,
|
||||
value: bigint | null,
|
||||
source: ElfPointerRelocationSource,
|
||||
record: Pick<ElfPointerWrite, "source" | "symbol_index" | "addend">,
|
||||
): ElfPointerWrite => ({
|
||||
address,
|
||||
width,
|
||||
value,
|
||||
file_offset:
|
||||
value === null ? fileOffset(address, width) : target(address, width),
|
||||
source,
|
||||
...record,
|
||||
});
|
||||
if (PACKED_TAGS.some((tag) => tags.has(tag)))
|
||||
unsupported(
|
||||
@@ -369,30 +394,35 @@ export function* readElfRelocationWritesSteps(
|
||||
);
|
||||
if (!parsed.ok) return malformed(parsed.error.message);
|
||||
const { type: kind, offset: destination, symbolIndex } = parsed.value;
|
||||
if (kind === 0) continue;
|
||||
if (elfRelocationWidth(input.machine, kind) === 0) continue;
|
||||
const source = { encoding, record_file_offset: at, kind };
|
||||
if (kind !== relative || symbolIndex !== 0) {
|
||||
const width = unknownWidths.get(kind);
|
||||
if (width === undefined) {
|
||||
unsupported(`ELF relocation kind ${kind} is not decoded`);
|
||||
continue;
|
||||
}
|
||||
yield write(destination, width, null, source);
|
||||
continue;
|
||||
}
|
||||
const slot = target(destination, word);
|
||||
const explicitAddend =
|
||||
"addend" in parsed.value && typeof parsed.value.addend === "bigint"
|
||||
? parsed.value.addend
|
||||
: undefined;
|
||||
if (encoding === "rela" && explicitAddend === undefined)
|
||||
return malformed("RELA relocation has no decoded addend");
|
||||
const record = {
|
||||
source,
|
||||
symbol_index: symbolIndex,
|
||||
addend: explicitAddend ?? null,
|
||||
};
|
||||
if (kind !== relative || symbolIndex !== 0) {
|
||||
const width = unknownWidths.get(kind);
|
||||
if (width === undefined) {
|
||||
unsupported(`ELF relocation kind ${kind} is not decoded`);
|
||||
continue;
|
||||
}
|
||||
yield write(destination, width, null, record);
|
||||
continue;
|
||||
}
|
||||
const slot = target(destination, word);
|
||||
const addend = explicitAddend ?? (slot === null ? 0n : rawWord(slot));
|
||||
yield write(
|
||||
destination,
|
||||
word,
|
||||
BigInt.asUintN(input.bits, addend),
|
||||
source,
|
||||
record,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -455,9 +485,9 @@ export function* readElfRelocationWritesSteps(
|
||||
malformed("RELR destination overflows the ELF address space");
|
||||
const slot = target(address, word);
|
||||
return write(address, word, slot === null ? 0n : rawWord(slot), {
|
||||
encoding: "relr",
|
||||
record_file_offset: at,
|
||||
kind: null,
|
||||
source: { encoding: "relr", record_file_offset: at, kind: null },
|
||||
symbol_index: null,
|
||||
addend: null,
|
||||
});
|
||||
};
|
||||
if ((entry & 1n) === 0n) {
|
||||
|
||||
@@ -235,6 +235,10 @@ const machoImageSection = ({
|
||||
declared_range: { offset: sectionOffset, size: sectionSize },
|
||||
segment_mapping: segmentMapping,
|
||||
segment_unmapped: segmentUnmapped,
|
||||
relocations: {
|
||||
offset: section.relocationOffset,
|
||||
count: section.relocationCount,
|
||||
},
|
||||
...range,
|
||||
file_backed: fileBacked,
|
||||
read_only: readOnly,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { captureProviderEnvironment } from "./providerEnvironment.js";
|
||||
import { createByteEntropyService } from "../composition/binaryDiagnostics.js";
|
||||
import { registerElfNotesTool } from "./registerElfNotesTool.js";
|
||||
import { registerNativeInitializersTool } from "./registerNativeInitializersTool.js";
|
||||
import { registerElfUnwindRangesTool } from "./registerElfUnwindRangesTool.js";
|
||||
import { registerAppleIpsTool } from "./registerAppleIpsTool.js";
|
||||
import { registerKernelModuleTool } from "./registerKernelModuleTool.js";
|
||||
@@ -221,6 +222,7 @@ export const createServer = (
|
||||
nativeExports,
|
||||
devicetree,
|
||||
elfNotes,
|
||||
nativeInitializers,
|
||||
elfUnwindRanges,
|
||||
appleIps,
|
||||
kernelModule,
|
||||
@@ -503,6 +505,13 @@ export const createServer = (
|
||||
recordEvidence,
|
||||
analysisAdmission,
|
||||
);
|
||||
registerNativeInitializersTool(
|
||||
server,
|
||||
nativeInitializers,
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
analysisAdmission,
|
||||
);
|
||||
registerElfUnwindRangesTool(
|
||||
server,
|
||||
elfUnwindRanges,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { NativeInitializersService } from "../application/binaryDiagnostics/NativeInitializersService.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 and Mach-O initializer listing and retain its Evidence. */
|
||||
export const registerNativeInitializersTool = (
|
||||
server: EvidenceMcpServer,
|
||||
service: NativeInitializersService,
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_native_initializers");
|
||||
const handler = createEvidenceToolHandler(
|
||||
server,
|
||||
(input, options) => service.inspect(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ const fileCommands = [
|
||||
"decode-signal-recording",
|
||||
"inspect-model-artifact",
|
||||
"inspect-elf-notes",
|
||||
"inspect-native-initializers",
|
||||
"inspect-elf-unwind-ranges",
|
||||
"inspect-kernel-module",
|
||||
"inspect-kernel-build-config",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
elfRelocatableCtors,
|
||||
machoArm64Initializers,
|
||||
} from "../../src/native/initializers/NativeInitializers.fixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
const contract = toolContract("inspect_native_initializers");
|
||||
|
||||
cliTest(
|
||||
"lists Mach-O initializers and finalizers through the public CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-native-init-cli-");
|
||||
const path = join(root, "program");
|
||||
await writeFile(path, machoArm64Initializers());
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-native-initializers", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = contract.outputSchema.parse(result.json);
|
||||
expect(
|
||||
evidence.normalized_result.entries.map((entry) => [
|
||||
entry.kind,
|
||||
entry.value_va,
|
||||
entry.symbol?.name,
|
||||
]),
|
||||
).toEqual([
|
||||
["mod_init", "0x100001000", "_init"],
|
||||
["init_offsets", "0x100001000", "_init"],
|
||||
["mod_term", "0x100001100", "_term"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"pages relocatable-object constructors with decimal options",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-native-init-cli-page-");
|
||||
const path = join(root, "object.o");
|
||||
await writeFile(path, elfRelocatableCtors());
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect-native-initializers",
|
||||
path,
|
||||
"--offset",
|
||||
"1",
|
||||
"--limit",
|
||||
"1",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = contract.outputSchema.parse(result.json);
|
||||
expect(evidence.normalized_result.coverage).toMatchObject({
|
||||
// Every slot read counts, including the two omitted .ctors sentinels.
|
||||
examined_entries: 4,
|
||||
total_entries: 2,
|
||||
returned_entries: 1,
|
||||
offset: 1,
|
||||
});
|
||||
expect(evidence.normalized_result.entries[0]).toMatchObject({
|
||||
index: 1,
|
||||
kind: "ctors",
|
||||
symbol: { name: "ctor" },
|
||||
resolution: "symbol_addend",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest("reports a truncated image as invalid input", async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-native-init-cli-bad-");
|
||||
const path = join(root, "broken");
|
||||
await writeFile(path, Buffer.from("\x7fELF"));
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-native-initializers", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: "inspect_native_initializers" },
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,7 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
|
||||
["inspect_windows_shortcut", { path: nul }],
|
||||
["inspect_native_exports", { path: nul }],
|
||||
["inspect_elf_notes", { path: nul }],
|
||||
["inspect_native_initializers", { path: nul }],
|
||||
["inspect_elf_unwind_ranges", { path: nul }],
|
||||
["inspect_apple_ips", { path: nul }],
|
||||
["inspect_devicetree", { path: nul }],
|
||||
|
||||
@@ -64,6 +64,7 @@ const REQUIRED_NUMERIC_FIELDS = [
|
||||
"compare-web-screenshots options.channelThreshold",
|
||||
"inspect-byte-entropy options.blockSize",
|
||||
"inspect-elf-notes options.maxFileBytes",
|
||||
"inspect-native-initializers options.limit",
|
||||
"inspect-web-page options.observationMs",
|
||||
"observe-web-execution options.observationMs",
|
||||
"extract-firmware options.maxDepth",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNativeInitializersService } from "../../../src/composition/binaryDiagnostics.js";
|
||||
import { elf64PieInitializers } from "../../../src/native/initializers/NativeInitializers.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("native initializer stable filesystem boundary", () => {
|
||||
it("reports non-file and malformed selections as caller input errors", async () => {
|
||||
const root = await createTestTempDirectory("rea-native-init-files-");
|
||||
const path = join(root, "program");
|
||||
const alias = join(root, "alias");
|
||||
const malformed = join(root, "malformed");
|
||||
await writeFile(path, elf64PieInitializers());
|
||||
await writeFile(malformed, Buffer.from("not an executable image"));
|
||||
await symlink(path, alias);
|
||||
const service = createNativeInitializersService();
|
||||
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_native_initializers",
|
||||
issues: [{ path: ["path"], reason, message: expect.any(String) }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a stable file and returns typed budget and cancellation failures", async () => {
|
||||
const root = await createTestTempDirectory("rea-native-init-budget-");
|
||||
const path = join(root, "program");
|
||||
await writeFile(path, elf64PieInitializers());
|
||||
const service = createNativeInitializersService();
|
||||
const read = await service.inspect({ path });
|
||||
if (!read.ok) throw read.error;
|
||||
expect(read.value).toMatchObject({
|
||||
operation: "inspect_native_initializers",
|
||||
provider: { id: "rea-native-initializers" },
|
||||
normalized_result: { format: "elf", status: "parsed" },
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import {
|
||||
machoArm64FatInitializers,
|
||||
elf64PieInitializers,
|
||||
} from "../../../src/native/initializers/NativeInitializers.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js";
|
||||
|
||||
const connect = async (mcp: TestMcp) => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Initializer listing must not start a deep provider");
|
||||
});
|
||||
const client = await mcp.connect(createServer({ kind: "session", session }));
|
||||
return { client, session };
|
||||
};
|
||||
|
||||
mcpTest(
|
||||
"advertises inspect_native_initializers and pages ELF initializers",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_native_initializers",
|
||||
);
|
||||
expect(advertised?.annotations).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
idempotentHint: true,
|
||||
});
|
||||
const root = await createTestTempDirectory("rea-native-init-mcp-");
|
||||
const path = join(root, "program");
|
||||
await writeFile(path, elf64PieInitializers());
|
||||
const response = await client.callTool({
|
||||
name: "inspect_native_initializers",
|
||||
arguments: { path, offset: 2, limit: 3 },
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
||||
const evidence = toolContract(
|
||||
"inspect_native_initializers",
|
||||
).outputSchema.parse(response.structuredContent);
|
||||
expect(evidence.normalized_result.coverage).toMatchObject({
|
||||
status: "complete",
|
||||
total_entries: 7,
|
||||
returned_entries: 3,
|
||||
offset: 2,
|
||||
});
|
||||
expect(
|
||||
evidence.normalized_result.entries.map((entry) => entry.resolution),
|
||||
).toEqual(["relative_addend", "relative_addend", "unresolved_import"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"requires an architecture for a universal Mach-O and reads the selected slice",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const root = await createTestTempDirectory("rea-native-init-fat-mcp-");
|
||||
const path = join(root, "universal");
|
||||
await writeFile(path, machoArm64FatInitializers());
|
||||
const missing = await client.callTool({
|
||||
name: "inspect_native_initializers",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(missing.isError).toBe(true);
|
||||
const selected = await client.callTool({
|
||||
name: "inspect_native_initializers",
|
||||
arguments: { path, architecture: "arm64" },
|
||||
});
|
||||
expect(selected.isError, JSON.stringify(selected.content)).not.toBe(true);
|
||||
const evidence = toolContract(
|
||||
"inspect_native_initializers",
|
||||
).outputSchema.parse(selected.structuredContent);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
format: "mach-o",
|
||||
slice: { file_offset: 0x1000, architecture: "arm64" },
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"requires an absolute path for inspect_native_initializers",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "inspect_native_initializers",
|
||||
arguments: { path: "program" },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: "inspect_native_initializers" },
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -111,6 +111,7 @@ import { inspectPythonPycInputSchema } from "../../src/domain/python/pythonPyc.j
|
||||
import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js";
|
||||
import { Il2cppMetadataProvider } from "../../src/il2cpp/Il2cppMetadataProvider.js";
|
||||
import { ElfNotesProvider } from "../../src/native/elf/ElfNotesProvider.js";
|
||||
import { NativeInitializersProvider } from "../../src/native/initializers/NativeInitializersProvider.js";
|
||||
import { ElfUnwindRangesProvider } from "../../src/native/elf/ElfUnwindRangesProvider.js";
|
||||
import { AppleIpsProvider } from "../../src/native/apple/AppleIpsProvider.js";
|
||||
import { KernelModuleProvider } from "../../src/native/elf/KernelModuleProvider.js";
|
||||
@@ -121,6 +122,7 @@ import { WindowsShortcutProvider } from "../../src/native/windows/WindowsShortcu
|
||||
import { NativeExportsProvider } from "../../src/native/exports/NativeExportsProvider.js";
|
||||
import { BeamProvider } from "../../src/erlang/BeamProvider.js";
|
||||
import { inspectElfNotesInputSchema } from "../../src/domain/native/elfNotes.js";
|
||||
import { inspectNativeInitializersInputSchema } from "../../src/domain/native/nativeInitializers.js";
|
||||
import { inspectElfUnwindRangesInputSchema } from "../../src/domain/native/elfUnwindRanges.js";
|
||||
import { inspectAppleIpsInputSchema } from "../../src/domain/native/appleIps.js";
|
||||
import { inspectKernelModuleInputSchema } from "../../src/domain/native/kernelModule.js";
|
||||
@@ -1004,6 +1006,7 @@ it.each([
|
||||
const stableArtifactProviders = [
|
||||
...staticProviders,
|
||||
["ELF notes", () => new ElfNotesProvider()],
|
||||
["native initializers", () => new NativeInitializersProvider()],
|
||||
["ELF unwind ranges", () => new ElfUnwindRangesProvider()],
|
||||
["Apple IPS", () => new AppleIpsProvider()],
|
||||
["kernel module", () => new KernelModuleProvider()],
|
||||
@@ -1038,6 +1041,11 @@ const inspectFileFailure = (
|
||||
inspectElfNotesInputSchema.parse({ path }),
|
||||
options,
|
||||
);
|
||||
if (provider instanceof NativeInitializersProvider)
|
||||
return provider.inspect(
|
||||
inspectNativeInitializersInputSchema.parse({ path }),
|
||||
options,
|
||||
);
|
||||
if (provider instanceof ElfUnwindRangesProvider)
|
||||
return provider.inspect(
|
||||
inspectElfUnwindRangesInputSchema.parse({ path }),
|
||||
|
||||
Reference in new issue
Block a user