diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 85b871f574..e956f88ce8 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -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 diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index e89a8f208e..8f2b44d16b 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -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 diff --git a/docs/.vitepress/documentation.ts b/docs/.vitepress/documentation.ts index 91cee1a39d..992e6c79ba 100644 --- a/docs/.vitepress/documentation.ts +++ b/docs/.vitepress/documentation.ts @@ -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", diff --git a/docs/README.md b/docs/README.md index 5ee6630dbc..ddceb77cf7 100644 --- a/docs/README.md +++ b/docs/README.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) diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index d0e2d41cfc..8d701d3e9a 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.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 diff --git a/docs/guides/native/native-initializers.md b/docs/guides/native/native-initializers.md new file mode 100644 index 0000000000..281c548520 --- /dev/null +++ b/docs/guides/native/native-initializers.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 2b5786f17e..a48bf94153 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) | diff --git a/docs/specs/README.md b/docs/specs/README.md index 93831efc88..206f2c018e 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.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)) diff --git a/docs/specs/native/native-initializers/plan.md b/docs/specs/native/native-initializers/plan.md new file mode 100644 index 0000000000..642d762d19 --- /dev/null +++ b/docs/specs/native/native-initializers/plan.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. diff --git a/docs/specs/native/native-initializers/spec.md b/docs/specs/native/native-initializers/spec.md new file mode 100644 index 0000000000..253c7651b5 --- /dev/null +++ b/docs/specs/native/native-initializers/spec.md @@ -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. diff --git a/docs/specs/native/native-initializers/tasks.md b/docs/specs/native/native-initializers/tasks.md new file mode 100644 index 0000000000..77b3495ee6 --- /dev/null +++ b/docs/specs/native/native-initializers/tasks.md @@ -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. diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 52eaeb6d69..2259ad7085 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -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" || diff --git a/src/application/binaryDiagnostics/NativeInitializersPort.ts b/src/application/binaryDiagnostics/NativeInitializersPort.ts new file mode 100644 index 0000000000..06fdc1a9ff --- /dev/null +++ b/src/application/binaryDiagnostics/NativeInitializersPort.ts @@ -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; + inspect( + input: InspectNativeInitializersInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/NativeInitializersService.test.ts b/src/application/binaryDiagnostics/NativeInitializersService.test.ts new file mode 100644 index 0000000000..e0d836b181 --- /dev/null +++ b/src/application/binaryDiagnostics/NativeInitializersService.test.ts @@ -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); +}); diff --git a/src/application/binaryDiagnostics/NativeInitializersService.ts b/src/application/binaryDiagnostics/NativeInitializersService.ts new file mode 100644 index 0000000000..06da93aa7b --- /dev/null +++ b/src/application/binaryDiagnostics/NativeInitializersService.ts @@ -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 { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + 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), + ); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 32d10c53e8..98ef19ae97 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -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", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 847c33b327..83e3aa2d73 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -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", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index 9a33e7e3a0..381789750a 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -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()); diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index 8ab1705850..441d6d1842 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -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, [ diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index d43618acb5..b215962336 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -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()), diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 45880c9d65..3f28e8632d 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -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"), diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index fd4fa12dea..26131095fd 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -293,6 +293,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { 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, diff --git a/src/domain/apple/machoSectionHeader.test.ts b/src/domain/apple/machoSectionHeader.test.ts index f0939e3dd3..a842009b11 100644 --- a/src/domain/apple/machoSectionHeader.test.ts +++ b/src/domain/apple/machoSectionHeader.test.ts @@ -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, }); }, diff --git a/src/domain/apple/machoSectionHeader.ts b/src/domain/apple/machoSectionHeader.ts index 7fbdf5c9a7..b03640e10f 100644 --- a/src/domain/apple/machoSectionHeader.ts +++ b/src/domain/apple/machoSectionHeader.ts @@ -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), }); }; diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index 98821f4d9f..3de2e826ef 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -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", diff --git a/src/domain/native/nativeInitializers.test.ts b/src/domain/native/nativeInitializers.test.ts new file mode 100644 index 0000000000..873f3189a8 --- /dev/null +++ b/src/domain/native/nativeInitializers.test.ts @@ -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); +}); diff --git a/src/domain/native/nativeInitializers.ts b/src/domain/native/nativeInitializers.ts new file mode 100644 index 0000000000..d6c5be306c --- /dev/null +++ b/src/domain/native/nativeInitializers.ts @@ -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; + +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; + +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; + +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; + +/** + * 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; + +/** Parser result before the provider attaches the stable artifact identity. */ +export type NativeInitializerInspection = Omit; diff --git a/src/go/GoStringLiterals.test.ts b/src/go/GoStringLiterals.test.ts index 76a985cd6d..f3bc0ee15f 100644 --- a/src/go/GoStringLiterals.test.ts +++ b/src/go/GoStringLiterals.test.ts @@ -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, diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index dc653060d2..b7b32a292d 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -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, diff --git a/src/native/AppleDispatchMetadata.fixture.ts b/src/native/AppleDispatchMetadata.fixture.ts new file mode 100644 index 0000000000..48ecf852c3 --- /dev/null +++ b/src/native/AppleDispatchMetadata.fixture.ts @@ -0,0 +1,11 @@ +import { decodeAppleDispatchMetadataSteps } from "./AppleDispatchMetadata.js"; + +export const decodeAppleDispatchMetadata = ( + ...args: Parameters +) => { + const steps = decodeAppleDispatchMetadataSteps(...args); + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + } +}; diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 46e1e7b9db..f7b88371ab 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -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 = ( diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 405ccaf7e8..3f814833ac 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -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 { 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, }); }; diff --git a/src/native/AppleMachoFixups.fixture.ts b/src/native/AppleMachoFixups.fixture.ts new file mode 100644 index 0000000000..2480070809 --- /dev/null +++ b/src/native/AppleMachoFixups.fixture.ts @@ -0,0 +1,11 @@ +import { parsePointerFixupsSteps } from "./AppleMachoFixups.js"; + +export const parsePointerFixups = ( + ...args: Parameters +) => { + const steps = parsePointerFixupsSteps(...args); + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + } +}; diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 5eb1d03078..e31aa4ab77 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -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([ diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index b7608bd416..74998d0e64 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -124,8 +124,20 @@ interface BindRun { | Extract; } -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 { 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 { 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 { // 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 { 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 { + 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 { 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 { 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); }, }; -}; +} diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 2d9498ec81..2b77e3a003 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -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; diff --git a/src/native/AppleObjcClassCoverage.test.ts b/src/native/AppleObjcClassCoverage.test.ts index eb48d13763..d4752f2318 100644 --- a/src/native/AppleObjcClassCoverage.test.ts +++ b/src/native/AppleObjcClassCoverage.test.ts @@ -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 = { diff --git a/src/native/AppleObjcClassImportBindings.test.ts b/src/native/AppleObjcClassImportBindings.test.ts index bddc53eafc..3a640af22c 100644 --- a/src/native/AppleObjcClassImportBindings.test.ts +++ b/src/native/AppleObjcClassImportBindings.test.ts @@ -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"; diff --git a/src/native/AppleSwiftNominalTypes.test.ts b/src/native/AppleSwiftNominalTypes.test.ts index 88878aaff8..1ff37cc2e7 100644 --- a/src/native/AppleSwiftNominalTypes.test.ts +++ b/src/native/AppleSwiftNominalTypes.test.ts @@ -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; diff --git a/src/native/initializers/ElfInitializerSymbols.ts b/src/native/initializers/ElfInitializerSymbols.ts new file mode 100644 index 0000000000..c495e75813 --- /dev/null +++ b/src/native/initializers/ElfInitializerSymbols.ts @@ -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; + /** Relocatable objects: names keyed by `section:value`, since every section starts at 0. */ + readonly definedInSection: ReadonlyMap; + readonly tables: ReadonlyMap; + /** 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(); + 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 = ( + map: Map, + 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; + readonly tables: ReadonlyMap; + 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; + /** Slots a narrower, offset or repeated record overlaps; their value is unknown. */ + readonly clobbered: ReadonlySet; + /** + * 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 => { + const found = new Map(); + const clobbered = new Set(); + 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 }; +}; diff --git a/src/native/initializers/ElfInitializers.ts b/src/native/initializers/ElfInitializers.ts new file mode 100644 index 0000000000..2ee9c54f4b --- /dev/null +++ b/src/native/initializers/ElfInitializers.ts @@ -0,0 +1,1070 @@ +import { setImmediate as immediate } from "node:timers/promises"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { readElfDynamicEntry } from "../../domain/elfDynamicEntry.js"; +import { elfArchitecture } from "../../domain/elfFixedHeader.js"; +import { + MAX_NATIVE_INITIALIZER_ENTRIES, + MAX_NATIVE_INITIALIZER_NAME_BYTES, + type NativeInitializerInspection, + type NativeInitializerKind, +} from "../../domain/native/nativeInitializers.js"; +import type { ElfPointerWrite } from "../metadata/ElfPointerRelocations.js"; +import { + ElfPointerRelocationFailure, + elfRelativeRelocationType, + readElfRelocationWritesSteps, +} from "../metadata/ElfPointerRelocations.js"; +import { readElfImage } from "../metadata/ElfImage.js"; +import { + BinaryFormatFailure, + BinaryMappingFailure, + BinaryResourceFailure, + createFileMappingIndex, + mappedFileOffset, + type FileMappingIndex, +} from "../metadata/BinaryContainer.js"; +import { readElfDirectoryFromBuffer } from "../metadata/NativeSectionDirectory.js"; +import type { NativeSection } from "../metadata/NativeSectionDirectory.js"; +import { + loadElfSymbolIndex, + readPointerWord, + relocationFromEntry, + sectionRelocations, + sectionSymbolKey, + type ElfInitializerSymbol, + type SlotRelocation, +} from "./ElfInitializerSymbols.js"; +import { + finishInitializerReport, + type CollectedInitializer, + type InitializerPage, +} from "./initializerReport.js"; +import { InitializerNotes } from "./initializerNotes.js"; +import { buildAddressRangeIndex } from "./addressRangeIndex.js"; + +const DT_SYMTAB = 6n; +const DT_INIT = 12n; +const DT_FINI = 13n; +const DT_INIT_ARRAY = 25n; +const DT_FINI_ARRAY = 26n; +const DT_INIT_ARRAYSZ = 27n; +const DT_FINI_ARRAYSZ = 28n; +const DT_PREINIT_ARRAY = 32n; +const DT_PREINIT_ARRAYSZ = 33n; +/** + * The dynamic tags this inspection reads. Others, such as DT_NEEDED, repeat by + * design and are not tracked, so their repetition is not a duplicate. + */ +const CONSUMED_DYNAMIC_TAGS: ReadonlySet = new Set([ + DT_SYMTAB, + DT_INIT, + DT_FINI, + DT_INIT_ARRAY, + DT_FINI_ARRAY, + DT_INIT_ARRAYSZ, + DT_FINI_ARRAYSZ, + DT_PREINIT_ARRAY, + DT_PREINIT_ARRAYSZ, +]); +const SHT_INIT_ARRAY = 14; +const SHT_FINI_ARRAY = 15; +const SHT_PREINIT_ARRAY = 16; +const ET_REL = 1; +const EM_ARM = 40; + +interface DynTag { + readonly value: bigint; + readonly valueOffset: number; +} + +interface ArraySlot { + readonly mappingMismatch?: boolean; + readonly va: bigint; + readonly fileOffset: number | null; + readonly sectionName: string | null; + readonly direct?: undefined; +} + +/** DT_INIT / DT_FINI: the dynamic entry's value field holds the target itself. */ +interface DirectSlot { + /** Null when the value field is outside every load segment. */ + readonly va: bigint | null; + readonly fileOffset: number; + readonly sectionName: string | null; + /** The link-time address stored in the dynamic entry. */ + readonly direct: bigint; +} + +type Slot = ArraySlot | DirectSlot; + +interface Source { + readonly kind: NativeInitializerKind; + readonly sentinel: boolean; + readonly detail: string; + readonly slots: readonly Slot[]; +} + +interface ElfScan { + readonly bytes: Buffer; + readonly bits: 32 | 64; + readonly little: boolean; + readonly machine: number; + readonly relocatable: boolean; + readonly notes: InitializerNotes; + incomplete: boolean; + /** Array slots still walkable across every source, so a declared size never sets the work. */ + slotBudget: number; +} + +const note = (scan: ElfScan, message: string, incomplete = true): void => { + scan.notes.add(message); + if (incomplete) scan.incomplete = true; +}; + +const wordOf = (bits: 32 | 64): number => bits / 8; + +/** `.ctors` / `.dtors` sentinels: 0 and all-ones at the image's pointer width. */ +const isSentinel = (value: bigint, bits: 32 | 64): boolean => + value === 0n || value === (1n << BigInt(bits)) - 1n; + +/** Read the consumed DT_* tags. The first duplicate wins. A missing DT_NULL stays incomplete. */ +const readDynamicTags = ( + scan: ElfScan, + segment: { readonly offset: bigint; readonly size: bigint } | undefined, + extra: number, +): Map => { + const tags = new Map(); + if (segment === undefined) return tags; + if (extra > 0) + throw new BinaryFormatFailure("malformed", "multiple PT_DYNAMIC segments"); + const width = scan.bits === 64 ? 16 : 8; + const start = Number(segment.offset); + const size = Number(segment.size); + if (size > 16 * 1024 * 1024) + throw new BinaryResourceFailure( + "structure", + 16 * 1024 * 1024, + "ELF dynamic directory exceeds the 16 MiB structural allowance", + ); + if ( + size < 0 || + size % width !== 0 || + start < 0 || + start > scan.bytes.length || + size > scan.bytes.length - start + ) + throw new BinaryFormatFailure( + "malformed", + "PT_DYNAMIC is truncated or outside the file", + ); + let terminated = false; + for (let at = start; at + width <= start + size; at += width) { + const parsed = readElfDynamicEntry( + scan.bytes.subarray(at, at + width), + scan.bits, + scan.little, + ); + if (!parsed.ok) + throw new BinaryFormatFailure("malformed", parsed.error.message); + if (parsed.value.tag === 0n) { + terminated = true; + break; + } + if (!CONSUMED_DYNAMIC_TAGS.has(parsed.value.tag)) continue; + if (tags.has(parsed.value.tag)) { + note( + scan, + `Duplicate dynamic tag ${parsed.value.tag} was ignored after the first value.`, + ); + continue; + } + tags.set(parsed.value.tag, { + value: parsed.value.value, + valueOffset: at + wordOf(scan.bits), + }); + } + if (!terminated) + note( + scan, + "PT_DYNAMIC has no DT_NULL terminator. Tags seen before the end were used.", + ); + return tags; +}; + +/** + * File offset of `size` bytes at `address`: a whole slot, or a target's first + * byte. `mappings` is built once per image, so each lookup is logarithmic. Only + * a genuinely unmapped span falls back to section headers; an ambiguous one + * stays unknown and makes the report incomplete. + */ +const fileOffsetFor = ( + scan: ElfScan, + mappings: FileMappingIndex, + index: ElfSectionIndex, + address: bigint, + size: number, +): number | null => { + try { + return mappings.offset(address, size); + } catch (cause) { + if (!(cause instanceof BinaryMappingFailure)) throw cause; + if (cause.reason === "ambiguous") { + note(scan, AMBIGUOUS_MAPPING); + return null; + } + return index.fileOffsetAt(address, size); + } +}; + +const AMBIGUOUS_MAPPING = + "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."; + +const vaForFile = ( + mappings: Parameters[0], + fileOffset: number, +): bigint | null => { + for (const mapping of mappings) { + if ( + fileOffset < mapping.offset || + fileOffset >= mapping.offset + mapping.size + ) + continue; + return mapping.address + BigInt(fileOffset - mapping.offset); + } + return null; +}; + +/** Address lookups over the section headers, built once per image. */ +interface ElfSectionIndex { + /** Name of the smallest SHF_ALLOC section containing `address`; earlier headers win ties. */ + readonly nameAt: (address: bigint) => string | null; + /** + * File offset of `width` bytes at `address` in the first file-backed section, + * in header order, that covers `address`. Null when that section ends before + * the span does. + */ + readonly fileOffsetAt: (address: bigint, width: number) => number | null; +} + +const indexSections = (sections: readonly NativeSection[]): ElfSectionIndex => { + const ranges = (keep: (section: NativeSection) => boolean) => + sections.flatMap((section) => + keep(section) + ? [ + { + start: section.address, + end: section.address + section.size, + value: section, + }, + ] + : [], + ); + const named = buildAddressRangeIndex( + ranges((section) => section.size !== 0n && (section.flags & 2n) !== 0n), + (left, right) => + left.size < right.size + ? -1 + : left.size > right.size + ? 1 + : left.index - right.index, + ); + const backed = buildAddressRangeIndex( + ranges((section) => section.file_backed), + (left, right) => left.index - right.index, + ); + return { + nameAt(address) { + const name = named.at(address)?.value.name; + return name == null || name.length === 0 ? null : name; + }, + fileOffsetAt(address, width) { + const section = backed.at(address)?.value; + if ( + section === undefined || + address + BigInt(width) > section.address + section.size + ) + return null; + return Number(section.offset) + Number(address - section.address); + }, + }; +}; + +const slotsFromSpan = ( + scan: ElfScan, + base: bigint, + byteSize: bigint, + label: string, + locate: (address: bigint) => number | null, + sectionName: string | null, +): Slot[] => { + const stride = BigInt(wordOf(scan.bits)); + if (byteSize < 0n || byteSize % stride !== 0n) + note( + scan, + byteSize < 0n + ? `${label} size is negative.` + : `${label} size is not a multiple of the pointer width. Trailing bytes were not read.`, + ); + if (byteSize < 0n) return []; + const declared = byteSize / stride; + const budget = BigInt(scan.slotBudget); + const count = Number(declared < budget ? declared : budget); + scan.slotBudget -= count; + if (declared > budget) + note( + scan, + `${label} declares ${declared.toString()} slots. Scanning stopped at the ${String(MAX_NATIVE_INITIALIZER_ENTRIES)}-slot budget, so later slots were not read.`, + ); + const slots: Slot[] = []; + let missed = false; + for (let index = 0; index < count; index += 1) { + const va = base + BigInt(index) * stride; + const fileOffset = locate(va); + if (fileOffset === null) { + missed = true; + continue; + } + slots.push({ va, fileOffset, sectionName }); + } + if (missed) + note( + scan, + `${label} has slots that are not file-backed. Those slots were omitted.`, + ); + return slots; +}; + +/** Output sections, plus `.name.*` input sections in relocatable objects. */ +const matchesName = (section: NativeSection, name: string): boolean => + section.name === name || (section.name?.startsWith(`${name}.`) ?? false); + +const findSections = ( + sections: readonly NativeSection[], + type: number | null, + name: string, + namesKnown: boolean, +): NativeSection[] => + sections.filter( + (section) => + (type !== null && section.type === type) || + (namesKnown && matchesName(section, name)), + ); + +/** File offset of an address inside one file-backed section. */ +const withinSection = + (section: NativeSection, width: number) => + (address: bigint): number | null => { + if (!section.file_backed || address < section.address) return null; + const delta = address - section.address; + if (delta + BigInt(width) > section.size) return null; + return Number(section.offset + delta); + }; + +const sectionSource = ( + scan: ElfScan, + section: NativeSection, + kind: NativeInitializerKind, + sentinel: boolean, + fallbackName: string, +): Source => { + // An over-long section name would flood limitations, so the request name labels it. + const label = + section.name !== null && + section.name.length > 0 && + section.name.length <= MAX_NATIVE_INITIALIZER_NAME_BYTES + ? section.name + : fallbackName; + if (!section.file_backed) { + note(scan, `${label} is not file-backed. Its slots were omitted.`); + return { kind, sentinel, detail: label, slots: [] }; + } + return { + kind, + sentinel, + detail: label, + slots: slotsFromSpan( + scan, + section.address, + section.size, + label, + withinSection(section, wordOf(scan.bits)), + section.name, + ), + }; +}; + +interface ArrayRequest { + readonly kind: NativeInitializerKind; + readonly addressTag: bigint; + readonly sizeTag: bigint; + readonly sectionType: number; + readonly sectionName: string; + readonly detail: string; +} + +/** DT_* array when declared, otherwise every matching section in header order. */ +const arraySources = ( + scan: ElfScan, + tags: ReadonlyMap, + sections: readonly NativeSection[], + index: ElfSectionIndex, + request: ArrayRequest, + locate: (address: bigint) => number | null, + namesKnown: boolean, +): Source[] => { + const address = tags.get(request.addressTag); + if (address === undefined) + return findSections( + sections, + request.sectionType, + request.sectionName, + namesKnown, + ).map((section) => + sectionSource(scan, section, request.kind, false, request.sectionName), + ); + const size = tags.get(request.sizeTag); + if (size === undefined) { + note(scan, `${request.detail} has no size tag. Its slots were not read.`); + return []; + } + return [ + { + kind: request.kind, + sentinel: false, + detail: request.detail, + slots: slotsFromSpan( + scan, + address.value, + size.value, + request.detail, + locate, + index.nameAt(address.value), + ), + }, + ]; +}; + +const legacySources = ( + scan: ElfScan, + sections: readonly NativeSection[], + name: ".ctors" | ".dtors", + kind: NativeInitializerKind, + namesKnown: boolean, +): Source[] => + findSections(sections, null, name, namesKnown).map((section) => + sectionSource(scan, section, kind, true, name), + ); + +const directSource = ( + scan: ElfScan, + tags: ReadonlyMap, + index: ElfSectionIndex, + mappings: Parameters[0], + tag: bigint, + kind: NativeInitializerKind, + detail: string, +): Source | null => { + const entry = tags.get(tag); + if (entry === undefined) return null; + const va = vaForFile(mappings, entry.valueOffset); + // DT_INIT / DT_FINI store the target, not a slot pointer, so an unmapped field has no slot address. + if (va === null) + note(scan, `${detail} value field is not inside a load segment.`); + return { + kind, + sentinel: false, + detail, + slots: [ + { + va, + fileOffset: entry.valueOffset, + sectionName: va === null ? null : index.nameAt(va), + direct: entry.value, + }, + ], + }; +}; + +const ARRAYS: readonly ArrayRequest[] = [ + { + kind: "preinit_array", + addressTag: DT_PREINIT_ARRAY, + sizeTag: DT_PREINIT_ARRAYSZ, + sectionType: SHT_PREINIT_ARRAY, + sectionName: ".preinit_array", + detail: "DT_PREINIT_ARRAY", + }, + { + kind: "init_array", + addressTag: DT_INIT_ARRAY, + sizeTag: DT_INIT_ARRAYSZ, + sectionType: SHT_INIT_ARRAY, + sectionName: ".init_array", + detail: "DT_INIT_ARRAY", + }, + { + kind: "fini_array", + addressTag: DT_FINI_ARRAY, + sizeTag: DT_FINI_ARRAYSZ, + sectionType: SHT_FINI_ARRAY, + sectionName: ".fini_array", + detail: "DT_FINI_ARRAY", + }, +]; + +const collectSources = ( + scan: ElfScan, + tags: ReadonlyMap, + sections: readonly NativeSection[], + index: ElfSectionIndex, + mappings: Parameters[0], + locate: (address: bigint) => number | null, + namesKnown: boolean, +): Source[] => { + const [preinit, initArray, finiArray] = ARRAYS.map((request) => + arraySources(scan, tags, sections, index, request, locate, namesKnown), + ); + const init = directSource( + scan, + tags, + index, + mappings, + DT_INIT, + "init", + "DT_INIT", + ); + const fini = directSource( + scan, + tags, + index, + mappings, + DT_FINI, + "fini", + "DT_FINI", + ); + return [ + ...(preinit ?? []), + ...(init === null ? [] : [init]), + ...(initArray ?? []), + ...legacySources(scan, sections, ".ctors", "ctors", namesKnown), + ...(fini === null ? [] : [fini]), + ...(finiArray ?? []), + ...legacySources(scan, sections, ".dtors", "dtors", namesKnown), + ]; +}; + +interface WriteLoad { + /** Exactly one full-width write starts at each slot here, with nothing else overlapping it. */ + readonly writes: ReadonlyMap; + /** Slots that a narrower, offset or conflicting write overlaps; their value is unknown. */ + readonly clobbered: ReadonlySet; + readonly trustRaw: boolean; +} + +/** + * Index of the first slot whose word could overlap a write starting at + * `address`, that is the first slot above `address - word`. + */ +const firstOverlap = ( + slots: readonly bigint[], + address: bigint, + word: number, +): number => { + const from = address - BigInt(word); + let low = 0; + let high = slots.length; + while (low < high) { + const mid = (low + high) >>> 1; + if ((slots[mid] ?? address) <= from) low = mid + 1; + else high = mid; + } + return low; +}; + +/** + * Drain the shared dynamic-relocation reader, keeping only writes that touch a + * wanted slot. A slot is resolved by a write only when that write starts at the + * slot with the pointer width and no other write overlaps the slot, which + * matches the shared overlay's `pointerAt` rule without retaining every write. + * The reader pauses at the generator's own yield points so a large table does + * not block the event loop, and observes cancellation there. + */ +const loadWrites = async ( + scan: ElfScan, + image: ReturnType, + wanted: ReadonlySet, + signal: AbortSignal | undefined, +): Promise => { + if (wanted.size === 0 || image.elf.dynamicSegments.length === 0) + return { writes: new Map(), clobbered: new Set(), trustRaw: true }; + const word = wordOf(scan.bits); + const slots = [...wanted].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); + const writes = new Map(); + const clobbered = new Set(); + const consider = (write: ElfPointerWrite): void => { + const end = write.address + BigInt(write.width); + for ( + let at = firstOverlap(slots, write.address, word); + at < slots.length; + at += 1 + ) { + const slot = slots[at]; + if (slot === undefined || slot >= end) break; + if (clobbered.has(slot)) continue; + const exact = slot === write.address && write.width === word; + const previous = writes.get(slot); + if (exact && previous === undefined) { + writes.set(slot, write); + continue; + } + writes.delete(slot); + clobbered.add(slot); + } + }; + try { + const steps = readElfRelocationWritesSteps( + scan.bytes, + { + bits: scan.bits, + little: scan.little, + machine: scan.machine, + dynamicSegments: image.elf.dynamicSegments, + mappings: image.mappings, + zeroFills: image.zero_fills, + }, + { unsupported: "record" }, + ); + let next = steps.next(); + while (!next.done) { + if (next.value === undefined) { + await immediate(); + assertArtifactReadActive(signal); + } else consider(next.value); + next = steps.next(); + } + if (clobbered.size > 0) + note( + scan, + `${String(clobbered.size)} initializer slot(s) are partly overwritten or written more than once by dynamic relocations and are listed as unresolved.`, + ); + if (!next.value.pointer_coverage_known) { + const issues = next.value.issues; + if (issues.length === 0) + note(scan, "Dynamic relocation coverage is incomplete."); + for (const issue of issues) note(scan, issue); + return { writes, clobbered, trustRaw: false }; + } + return { writes, clobbered, trustRaw: true }; + } catch (cause) { + if (!(cause instanceof ElfPointerRelocationFailure)) throw cause; + if (cause.reason === "native-index-capacity") + throw new BinaryResourceFailure( + "structure", + 16 * 1024 * 1024, + cause.message, + ); + note(scan, cause.message); + return { writes: new Map(), clobbered: new Set(), trustRaw: false }; + } +}; + +const dynamicSymbols = ( + sections: readonly NativeSection[], + tags: ReadonlyMap, + tables: ReadonlyMap, +): readonly ElfInitializerSymbol[] | undefined => { + const declared = tags.get(DT_SYMTAB); + if (declared !== undefined) { + const match = sections.findIndex( + (section) => + section.address === declared.value && + (section.type === 11 || section.type === 2), + ); + if (match >= 0) return tables.get(match); + } + const dynsym = sections.findIndex((section) => section.type === 11); + return dynsym < 0 ? undefined : tables.get(dynsym); +}; + +const fromDynamicWrite = ( + scan: ElfScan, + write: ElfPointerWrite, + symbols: readonly ElfInitializerSymbol[] | undefined, +): SlotRelocation => { + if (write.source.encoding === "relr") + return { + method: "relr", + value: write.value, + importName: null, + definedName: null, + }; + const relative = elfRelativeRelocationType(scan.machine); + if (write.value !== null && write.source.kind === relative) + return { + method: "relative_addend", + value: write.value, + importName: null, + definedName: null, + }; + const { encoding, kind } = write.source; + if (kind === null || write.symbol_index === null) + return { + method: "unresolved", + value: null, + importName: null, + definedName: null, + }; + return relocationFromEntry( + scan, + { + type: kind, + symbolIndex: write.symbol_index, + ...(write.addend === null ? {} : { addend: write.addend }), + }, + symbols, + encoding === "rel" + ? readPointerWord(scan.bytes, write.file_offset, scan.bits, scan.little) + : null, + ); +}; + +const rawRelocation = ( + scan: ElfScan, + fileOffset: number | null, +): SlotRelocation => { + const value = readPointerWord(scan.bytes, fileOffset, scan.bits, scan.little); + return value === null + ? { method: "unresolved", value: null, importName: null, definedName: null } + : { method: "raw", value, importName: null, definedName: null }; +}; + +const UNRESOLVED: SlotRelocation = { + method: "unresolved", + value: null, + importName: null, + definedName: null, +}; + +/** + * One array slot. `trustRaw` is false when dynamic or section relocation + * coverage is incomplete, so a slot with no relocation found may still have one. + */ +const resolveSlot = ( + scan: ElfScan, + slot: Slot, + write: ElfPointerWrite | undefined, + clobbered: boolean, + sectionRel: SlotRelocation | undefined, + symbols: readonly ElfInitializerSymbol[] | undefined, + trustRaw: boolean, +): SlotRelocation => { + if (clobbered) return UNRESOLVED; + if (write !== undefined) return fromDynamicWrite(scan, write, symbols); + if (sectionRel !== undefined) return sectionRel; + if (!trustRaw) return UNRESOLVED; + return rawRelocation(scan, slot.fileOffset); +}; + +interface SymbolNames { + readonly defined: ReadonlyMap; + readonly definedInSection: ReadonlyMap; +} + +const symbolOf = ( + scan: ElfScan, + applied: SlotRelocation, + names: SymbolNames, +): string | null => { + if (applied.method === "unresolved_import") return applied.importName; + if (applied.method === "symbol_addend" && applied.definedName !== null) + return applied.definedName; + if (applied.value === null) return null; + if (scan.relocatable) + return applied.sectionIndex === undefined + ? null + : (names.definedInSection.get( + sectionSymbolKey(applied.sectionIndex, applied.value), + ) ?? null); + return names.defined.get(applied.value) ?? null; +}; + +interface Target { + readonly section: string | null; + readonly fileOffset: number | null; +} + +/** + * Where a resolved value points. `locateTarget` maps one byte, since a function + * only needs its first byte mapped. ET_REL values are offsets inside the + * relocation symbol's section. On 32-bit ARM, bit 0 of a function pointer + * selects Thumb state and is not part of the code address, so it is cleared + * for the lookup while `value_va` keeps the callable value. + */ +const targetOf = ( + scan: ElfScan, + applied: SlotRelocation, + sections: readonly NativeSection[], + index: ElfSectionIndex, + locateTarget: (address: bigint) => number | null, +): Target => { + if (applied.value === null) return { section: null, fileOffset: null }; + const code = scan.machine === EM_ARM ? applied.value & ~1n : applied.value; + if (!scan.relocatable) + return { + section: index.nameAt(code), + fileOffset: locateTarget(code), + }; + const section = + applied.sectionIndex === undefined + ? undefined + : sections[applied.sectionIndex]; + if (section === undefined) return { section: null, fileOffset: null }; + return { + section: section.name, + fileOffset: withinSection(section, 1)(section.address + code), + }; +}; + +const collectEntries = ( + scan: ElfScan, + sources: readonly Source[], + loaded: WriteLoad, + sectionRels: ReadonlyMap, + sectionClobbered: ReadonlySet, + symbols: readonly ElfInitializerSymbol[] | undefined, + names: SymbolNames, + trustRaw: boolean, + sections: readonly NativeSection[], + index: ElfSectionIndex, + locateTarget: (address: bigint) => number | null, + signal: AbortSignal | undefined, +): { readonly entries: CollectedInitializer[]; readonly examined: number } => { + const entries: CollectedInitializer[] = []; + let scanned = 0; + let skipped = 0; + let zeroNoted = false; + let stopped = false; + for (const source of sources) { + for (const slot of source.slots) { + if (scanned >= MAX_NATIVE_INITIALIZER_ENTRIES) { + stopped = true; + break; + } + scanned += 1; + if (scanned % 1024 === 0) assertArtifactReadActive(signal); + const applied: SlotRelocation = + slot.direct === undefined && slot.mappingMismatch === true + ? { + method: "unresolved", + value: null, + importName: null, + definedName: null, + } + : slot.direct === undefined + ? resolveSlot( + scan, + slot, + scan.relocatable ? undefined : loaded.writes.get(slot.va), + (!scan.relocatable && loaded.clobbered.has(slot.va)) || + (slot.fileOffset !== null && + sectionClobbered.has(slot.fileOffset)), + slot.fileOffset === null + ? undefined + : sectionRels.get(slot.fileOffset), + symbols, + trustRaw, + ) + : { + method: "raw", + value: slot.direct, + importName: null, + definedName: null, + }; + if ( + source.sentinel && + applied.method === "raw" && + applied.value !== null && + isSentinel(applied.value, scan.bits) + ) { + skipped += 1; + continue; + } + if (!zeroNoted && applied.method === "raw" && applied.value === 0n) { + zeroNoted = true; + note( + scan, + "A stored zero slot is listed so the array length stays visible. It is not a function address.", + false, + ); + } + const target = targetOf(scan, applied, sections, index, locateTarget); + entries.push({ + index: entries.length, + kind: source.kind, + slotVa: slot.va, + slotFileOffset: slot.fileOffset, + slotBytes: scan.bits === 64 ? 8 : 4, + valueVa: applied.value, + valueFileOffset: target.fileOffset, + slotSection: slot.sectionName, + section: target.section, + symbol: symbolOf(scan, applied, names), + resolution: applied.method, + detail: source.detail, + }); + } + if (stopped) break; + } + if (stopped) + note( + scan, + "Initializer scanning stopped at 65536 slots. Later slots were not read.", + ); + if (skipped > 0) + note( + scan, + `Omitted ${String(skipped)} .ctors or .dtors sentinel values (0 or -1).`, + false, + ); + return { entries, examined: scanned }; +}; + +/** Decode ELF initializer and finalizer slots for one thin image. */ +export const readElfInitializers = async ( + bytes: Buffer, + page: InitializerPage, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const image = readElfImage(bytes); + const directory = readElfDirectoryFromBuffer(bytes); + const scan: ElfScan = { + bytes, + bits: image.bits, + little: image.byte_order === "little", + machine: image.elf.machine, + relocatable: directory.elf_type === ET_REL, + notes: new InitializerNotes(), + incomplete: false, + slotBudget: MAX_NATIVE_INITIALIZER_ENTRIES, + }; + if (scan.relocatable) + note( + scan, + "Relocatable object (ET_REL): slot and value addresses are offsets inside the named section, not load addresses.", + false, + ); + const tags = readDynamicTags( + scan, + image.elf.dynamicSegments[0], + image.elf.dynamicSegments.length > 1 ? 1 : 0, + ); + const namesKnown = directory.section_names_limitation === undefined; + if (!namesKnown && directory.section_names_limitation !== undefined) + note(scan, directory.section_names_limitation); + const index = indexSections(directory.sections); + const mappings = createFileMappingIndex(image.mappings, image.zero_fills); + const locate = (address: bigint): number | null => + fileOffsetFor(scan, mappings, index, address, wordOf(scan.bits)); + const locateTarget = (address: bigint): number | null => + fileOffsetFor(scan, mappings, index, address, 1); + const declaredSources = collectSources( + scan, + tags, + directory.sections, + index, + image.mappings, + locate, + namesKnown, + ); + const sources = scan.relocatable + ? declaredSources + : declaredSources.map((source) => ({ + ...source, + slots: source.slots.map((slot) => { + if (slot.direct !== undefined || slot.fileOffset === null) + return slot; + let mapped: number | null = null; + try { + mapped = mappings.offset(slot.va, wordOf(scan.bits)); + } catch (cause) { + if (!(cause instanceof BinaryMappingFailure)) throw cause; + } + if (mapped === slot.fileOffset) return slot; + note( + scan, + "An ELF initializer slot's section offset disagrees with its load mapping; affected slots are unresolved.", + ); + return { ...slot, mappingMismatch: true }; + }), + })); + const wanted = new Set( + sources.flatMap((source) => + source.slots.flatMap((slot) => + slot.direct === undefined ? [slot.va] : [], + ), + ), + ); + const loaded = await loadWrites(scan, image, wanted, signal); + assertArtifactReadActive(signal); + const symbols = loadElfSymbolIndex(scan, directory.sections, scan.notes); + if (symbols.incomplete) scan.incomplete = true; + const uncovered = new Set( + sources.flatMap((source) => + source.slots.flatMap((slot) => + slot.direct === undefined && + slot.fileOffset !== null && + (scan.relocatable || + (!loaded.writes.has(slot.va) && !loaded.clobbered.has(slot.va))) + ? [slot.fileOffset] + : [], + ), + ), + ); + const notesBeforeRelocs = scan.notes.recorded; + const sectionRels = await sectionRelocations({ + ...scan, + sections: directory.sections, + wanted: uncovered, + tables: symbols.tables, + notes: scan.notes, + fileOffset: (address, width) => + fileOffsetFor(scan, mappings, index, address, width), + signal, + }); + if (scan.notes.recorded !== notesBeforeRelocs || !sectionRels.complete) + scan.incomplete = true; + const collected = collectEntries( + scan, + sources, + loaded, + sectionRels.found, + sectionRels.clobbered, + dynamicSymbols(directory.sections, tags, symbols.tables), + symbols, + loaded.trustRaw && sectionRels.complete, + directory.sections, + index, + locateTarget, + signal, + ); + const architecture = elfArchitecture(scan.machine) ?? null; + return finishInitializerReport( + { + format: "elf", + architecture, + bits: scan.bits, + byte_order: image.byte_order, + machine: scan.machine, + elf_type: directory.elf_type, + slice: null, + }, + collected.entries, + collected.examined, + scan.notes, + page, + scan.incomplete, + ); +}; diff --git a/src/native/initializers/MachoInitializers.ts b/src/native/initializers/MachoInitializers.ts new file mode 100644 index 0000000000..24daaf0342 --- /dev/null +++ b/src/native/initializers/MachoInitializers.ts @@ -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 +>([ + [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["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; + +/** 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, + 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; + 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(); + 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(); + 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; + 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; + 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; + /** 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(); + 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, + kind: NativeInitializerKind, + detail: string, + offsets: boolean, + entries: CollectedInitializer[], +): Promise => { + 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 => { + 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, + ); +}; diff --git a/src/native/initializers/NativeInitializers.fixture.ts b/src/native/initializers/NativeInitializers.fixture.ts new file mode 100644 index 0000000000..2893fb4dba --- /dev/null +++ b/src/native/initializers/NativeInitializers.fixture.ts @@ -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; +}; diff --git a/src/native/initializers/NativeInitializersParsers.test.ts b/src/native/initializers/NativeInitializersParsers.test.ts new file mode 100644 index 0000000000..5d76051d3f --- /dev/null +++ b/src/native/initializers/NativeInitializersParsers.test.ts @@ -0,0 +1,1417 @@ +import { describe, expect, it, vi } from "vitest"; +import { readNativeInitializers } from "./NativeInitializersProvider.js"; + +/** Pass-through spies on the shared mapping module, so tests can count lookups made from other modules. */ +const mapping = vi.hoisted(() => ({ + scans: vi.fn(), + indexes: vi.fn(), +})); +vi.mock("../metadata/BinaryContainer.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + mappedFileOffset: ( + ...args: Parameters + ): number => { + mapping.scans(...args); + return actual.mappedFileOffset(...args); + }, + createFileMappingIndex: ( + ...args: Parameters + ) => { + mapping.indexes(...args); + return actual.createFileMappingIndex(...args); + }, + }; +}); +import { + elf32RelativeInitializers, + elf64PieInitializers, + elfRelocatableCtors, + macho32Initializers, + machoArm64FatInitializers, + machoArm64Initializers, + machoArm64LegacyRebaseInitializers, + machoSparseBindInitializers, + machoX8664ObjectInitializers, +} from "./NativeInitializers.fixture.js"; +import { + MAX_NATIVE_INITIALIZER_ENTRIES, + MAX_NATIVE_INITIALIZER_LIMITATIONS, + MAX_NATIVE_INITIALIZER_NAME_BYTES, + MAX_NATIVE_INITIALIZER_NOTES, + NATIVE_INITIALIZER_LIMITATIONS, + nativeInitializersSchema, +} from "../../domain/native/nativeInitializers.js"; +import { buildAddressRangeIndex } from "./addressRangeIndex.js"; +import { InitializerNotes } from "./initializerNotes.js"; +import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; + +const artifact = (bytes: Buffer) => ({ + path: "/artifacts/image", + sha256: "a".repeat(64), + bytes: bytes.length, +}); + +const read = async ( + bytes: Buffer, + page: { offset?: number; limit?: number; architecture?: "arm64" } = {}, +) => + nativeInitializersSchema.parse( + await readNativeInitializers(bytes, artifact(bytes), { + path: "/artifacts/image", + offset: page.offset ?? 0, + limit: page.limit ?? 64, + ...(page.architecture === undefined + ? {} + : { architecture: page.architecture }), + }), + ); + +/** One ELF64 section header for {@link withExtraSections}. */ +const sectionHeader = ( + type: number, + offset: number, + size: number, + link: number, + entsize: number, + info = 0, +): Buffer => { + const header = Buffer.alloc(64); + header.writeUInt32LE(type, 4); + header.writeBigUInt64LE(BigInt(offset), 24); + header.writeBigUInt64LE(BigInt(size), 32); + header.writeUInt32LE(link, 40); + header.writeUInt32LE(info, 44); + header.writeBigUInt64LE(BigInt(entsize), 56); + return header; +}; + +/** + * The relocatable fixture with `payload` appended and a moved section-header + * table that keeps its nine headers and adds `extra`. Extra offsets are + * relative to the payload. + */ +const withExtraSections = ( + payload: Buffer, + extra: (payloadAt: number) => readonly Buffer[], +): Buffer => { + const base = elfRelocatableCtors(); + const headers = extra(base.length); + const table = Buffer.concat([ + base.subarray(0x500, 0x500 + 9 * 64), + ...headers, + ]); + const bytes = Buffer.concat([base, payload, table]); + bytes.writeBigUInt64LE(BigInt(base.length + payload.length), 40); + bytes.writeUInt16LE(9 + headers.length, 60); + return bytes; +}; + +const row = (report: Awaited>) => + report.entries.map((entry) => ({ + kind: entry.kind, + value: entry.value_va, + section: entry.section?.name ?? null, + symbol: entry.symbol?.name ?? null, + resolution: entry.resolution, + })); + +describe("ELF", () => { + it("does not treat repeated DT_NEEDED entries as duplicate tags", async () => { + const report = await read(elf64PieInitializers(3)); + expect(report.status).toBe("parsed"); + expect(report.coverage.status).toBe("complete"); + expect(row(report)).toEqual(row(await read(elf64PieInitializers()))); + expect( + report.limitations.some((line) => + line.startsWith("Duplicate dynamic tag"), + ), + ).toBe(false); + }); + + it("lists PIE slots in declaration order through RELA, RELR and imports", async () => { + const report = await read(elf64PieInitializers()); + expect(report).toMatchObject({ + format: "elf", + status: "parsed", + architecture: "x86_64", + bits: 64, + elf_type: 3, + coverage: { status: "complete", examined_entries: 7, total_entries: 7 }, + }); + expect(row(report)).toEqual([ + { + kind: "preinit_array", + value: "0x100", + section: ".text", + symbol: "ctor_a", + resolution: "raw", + }, + { + kind: "init", + value: "0x100", + section: ".text", + symbol: "ctor_a", + resolution: "raw", + }, + { + kind: "init_array", + value: "0x100", + section: ".text", + symbol: "ctor_a", + resolution: "relative_addend", + }, + { + kind: "init_array", + value: "0x110", + section: ".text", + symbol: "ctor_b", + resolution: "relative_addend", + }, + { + kind: "init_array", + value: null, + section: null, + symbol: "imp_init", + resolution: "unresolved_import", + }, + { + kind: "fini", + value: "0x120", + section: ".text", + symbol: "fini_fn", + resolution: "raw", + }, + { + kind: "fini_array", + value: "0x120", + section: ".text", + symbol: "fini_fn", + resolution: "relr", + }, + ]); + expect(report.entries[2]).toMatchObject({ + slot_va: "0x200", + slot_file_offset: 0x200, + slot_bytes: 8, + value_file_offset: 0x100, + slot_section: { name: ".init_array", trust: "observed-untrusted" }, + detail: "DT_INIT_ARRAY", + }); + expect( + report.limitations.slice(0, NATIVE_INITIALIZER_LIMITATIONS.length), + ).toEqual([...NATIVE_INITIALIZER_LIMITATIONS]); + }); + + it("pages entries without changing their indexes", async () => { + const report = await read(elf64PieInitializers(), { offset: 2, limit: 2 }); + expect(report.coverage).toMatchObject({ + status: "complete", + examined_entries: 7, + returned_entries: 2, + offset: 2, + limit: 2, + }); + expect(report.entries.map((entry) => entry.index)).toEqual([2, 3]); + }); + + it("resolves an ELF32 REL RELATIVE slot from its in-place addend", async () => { + const report = await read(elf32RelativeInitializers()); + expect(report).toMatchObject({ bits: 32, architecture: "x86" }); + expect(row(report)).toEqual([ + { + kind: "init_array", + value: "0x80", + section: ".text", + symbol: "ctor", + resolution: "relative_addend", + }, + ]); + expect(report.entries[0]?.slot_bytes).toBe(4); + }); + + it("reads relocatable-object sections by section-relative offset and skips sentinels", async () => { + const report = await read(elfRelocatableCtors()); + expect(report.elf_type).toBe(1); + expect(row(report)).toEqual([ + { + kind: "init_array", + value: "0x8", + section: ".text", + symbol: "init_b", + resolution: "symbol_addend", + }, + { + kind: "ctors", + value: "0x0", + section: ".text", + symbol: "ctor", + resolution: "symbol_addend", + }, + ]); + expect(report.entries[1]).toMatchObject({ + slot_va: "0x8", + slot_file_offset: 0x208, + }); + expect(report.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("Relocatable object (ET_REL)"), + "Omitted 2 .ctors or .dtors sentinel values (0 or -1).", + ]), + ); + expect(report.status).toBe("parsed"); + // Three .ctors slots, including both sentinels, and one .init_array slot. + expect(report.coverage).toMatchObject({ + examined_entries: 4, + total_entries: 2, + }); + }); +}); + +describe("ELF slot semantics", () => { + it("keeps a 32-bit all-ones word in a 64-bit .ctors as a listed value", async () => { + const bytes = elfRelocatableCtors(); + // The leading .ctors sentinel becomes 0x00000000ffffffff, which is not -1 in ELF64. + bytes.writeBigUInt64LE(0xffffffffn, 0x200); + const report = await read(bytes); + expect(row(report).filter((entry) => entry.kind === "ctors")).toEqual([ + { + kind: "ctors", + value: "0xffffffff", + section: null, + symbol: null, + resolution: "raw", + }, + { + kind: "ctors", + value: "0x0", + section: ".text", + symbol: "ctor", + resolution: "symbol_addend", + }, + ]); + expect(report.limitations).toContain( + "Omitted 1 .ctors or .dtors sentinel values (0 or -1).", + ); + }); + + it("reports no slot address for DT_INIT and DT_FINI fields outside every load segment", async () => { + const bytes = elf64PieInitializers(); + // Shrink the only PT_LOAD so PT_DYNAMIC (file offset 0x300) is no longer mapped. + bytes.writeBigUInt64LE(0x300n, 64 + 32); + bytes.writeBigUInt64LE(0x300n, 64 + 40); + const report = await read(bytes); + const direct = report.entries.filter( + (entry) => entry.kind === "init" || entry.kind === "fini", + ); + expect(direct).toHaveLength(2); + for (const entry of direct) + expect(entry).toMatchObject({ slot_va: null, slot_section: null }); + expect(direct.map((entry) => entry.value_va)).toEqual(["0x100", "0x120"]); + expect(report.limitations).toEqual( + expect.arrayContaining([ + "DT_INIT value field is not inside a load segment.", + "DT_FINI value field is not inside a load segment.", + ]), + ); + }); +}); + +describe("ELF incomplete inputs", () => { + it("reports an unsupported relocation machine as incomplete, not as raw zeros", async () => { + const bytes = elf64PieInitializers(); + bytes.writeUInt16LE(243, 18); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.coverage.total_entries).toBeNull(); + const slots = report.entries.filter((entry) => entry.kind === "init_array"); + expect(slots.map((entry) => entry.resolution)).toEqual([ + "unresolved", + "unresolved", + "unresolved_import", + ]); + expect(slots.every((entry) => entry.value_va === null)).toBe(true); + expect( + report.entries.find((entry) => entry.kind === "init")?.resolution, + ).toBe("raw"); + }); + + it("bounds a huge declared DT_INIT_ARRAYSZ by the slot budget", async () => { + const bytes = elf64PieInitializers(); + // DT_INIT_ARRAYSZ is the fourth dynamic entry; 2^50 slots is still a safe integer. + bytes.writeBigUInt64LE(8n << 50n, 0x300 + 3 * 16 + 8); + const started = performance.now(); + const report = await read(bytes); + expect(performance.now() - started).toBeLessThan(10_000); + expect(report.status).toBe("incomplete"); + expect(report.coverage).toMatchObject({ + status: "incomplete", + total_entries: null, + }); + expect(report.coverage.examined_entries).toBeLessThanOrEqual( + MAX_NATIVE_INITIALIZER_ENTRIES, + ); + expect(report.limitations).toContain( + `DT_INIT_ARRAY declares ${String(1n << 50n)} slots. Scanning stopped at the 65536-slot budget, so later slots were not read.`, + ); + }); + + it("stops a file-backed section-derived init_array at the slot budget", async () => { + const base = elfRelocatableCtors(); + const slots = MAX_NATIVE_INITIALIZER_ENTRIES + 4096; + const bytes = Buffer.concat([base, Buffer.alloc(slots * 8)]); + // .init_array is section 7 of the relocatable fixture: sh_offset at +24, sh_size at +32. + bytes.writeBigUInt64LE(BigInt(base.length), 0x500 + 7 * 64 + 24); + bytes.writeBigUInt64LE(BigInt(slots * 8), 0x500 + 7 * 64 + 32); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.coverage).toMatchObject({ + status: "incomplete", + examined_entries: MAX_NATIVE_INITIALIZER_ENTRIES, + total_entries: null, + }); + expect(report.limitations).toContain( + `.init_array declares ${String(slots)} slots. Scanning stopped at the 65536-slot budget, so later slots were not read.`, + ); + }); + + it("reports over-long section and symbol names as null instead of failing the schema", async () => { + const base = elf64PieInitializers(); + const longSection = `.init_array.${"s".repeat(1100)}`; + const longSymbol = `ctor_${"y".repeat(1100)}`; + const tail = Buffer.from(`${longSection}\0${longSymbol}\0`, "utf8"); + const bytes = Buffer.concat([base, tail]); + const sectionAt = base.length; + const symbolAt = base.length + longSection.length + 1; + // .shstrtab (section 11) at 0x680 and .strtab (section 10) at 0x600 grow to cover the tail. + bytes.writeBigUInt64LE(BigInt(bytes.length - 0x680), 0x800 + 11 * 64 + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length - 0x600), 0x800 + 10 * 64 + 32); + // Rename .init_array (section 3) and ctor_a (first .symtab entry after the null symbol). + bytes.writeUInt32LE(sectionAt - 0x680, 0x800 + 3 * 64); + bytes.writeUInt32LE(symbolAt - 0x600, 0x598); + const report = await read(bytes); + const slot = report.entries.find((entry) => entry.kind === "init_array"); + expect(slot?.slot_section).toBeNull(); + expect( + report.entries.every( + (entry) => + (entry.symbol?.name.length ?? 0) <= MAX_NATIVE_INITIALIZER_NAME_BYTES, + ), + ).toBe(true); + expect( + report.limitations.some((line) => + line.includes("exceeded the 1024-byte name budget"), + ), + ).toBe(true); + }); + + it("labels an over-long section-derived array with the request name", async () => { + const base = elfRelocatableCtors(); + const longSection = `.init_array.${"s".repeat(1100)}`; + const bytes = Buffer.concat([base, Buffer.from(`${longSection}\0`)]); + // .shstrtab (section 6) at 0x400 grows to cover the tail; rename .init_array (section 7). + bytes.writeBigUInt64LE(BigInt(bytes.length - 0x400), 0x500 + 6 * 64 + 32); + bytes.writeUInt32LE(base.length - 0x400, 0x500 + 7 * 64); + const report = await read(bytes); + const slot = report.entries.find((entry) => entry.kind === "init_array"); + expect(slot).toMatchObject({ slot_section: null, detail: ".init_array" }); + expect(report.status).toBe("incomplete"); + const beyond = await read(bytes, { offset: 100 }); + expect(beyond.entries).toHaveLength(0); + expect(beyond.status).toBe("incomplete"); + expect(beyond.coverage.status).toBe("incomplete"); + expect( + beyond.limitations.some((line) => + line.includes("exceeded the 1024-byte name budget"), + ), + ).toBe(true); + expect( + report.limitations.some((line) => + line.includes("exceeded the 1024-byte name budget"), + ), + ).toBe(true); + }); +}); + +describe("ELF table budgets", () => { + it("caps symbols in total across every symbol table, not per table", async () => { + const perTable = 40_000; + // Two zeroed SHT_SYMTAB tables, each under the cap, that exceed it together. + const bytes = withExtraSections(Buffer.alloc(perTable * 24), (at) => [ + sectionHeader(2, at, perTable * 24, 5, 24), + sectionHeader(2, at, perTable * 24, 5, 24), + ]); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.coverage.total_entries).toBeNull(); + expect(report.limitations).toContain( + "Symbol table scanning stopped at 65536 symbols across all symbol tables. Later names may be missing.", + ); + }); + + it("reports an unreadable symbol table as incomplete rather than absent", async () => { + // An SHT_SYMTAB whose size is not a whole number of 24-byte entries. + const bytes = withExtraSections(Buffer.alloc(32), (at) => [ + sectionHeader(2, at, 25, 5, 24), + ]); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "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.", + ); + }); + + const repeatedRelocations = (): Buffer => { + const records = 65_536; + // Seventeen SHT_RELA headers over one zeroed range targeting .text, where no slot lives. + return withExtraSections(Buffer.alloc(records * 24), (at) => + Array.from({ length: 17 }, () => + sectionHeader(4, at, records * 24, 4, 24, 1), + ), + ); + }; + + it("bounds section relocation records in total across repeated headers", async () => { + const report = await read(repeatedRelocations()); + expect(report.status).toBe("incomplete"); + expect(report.coverage.total_entries).toBeNull(); + expect( + report.limitations.filter((line) => + line.startsWith( + "Section relocation scanning stopped at 1048576 records", + ), + ), + ).toHaveLength(1); + // Records past the budget were not applied, but earlier slot results stand. + expect(row(report)).toEqual(row(await read(elfRelocatableCtors()))); + }); + + it("reports a relocation section reached after the record budget is spent", async () => { + // Sixteen full headers spend the budget exactly; the seventeenth is not read. + const records = 65_536; + const bytes = withExtraSections(Buffer.alloc(records * 24), (at) => [ + ...Array.from({ length: 16 }, () => + sectionHeader(4, at, records * 24, 4, 24, 1), + ), + sectionHeader(4, at, 24, 4, 24, 1), + ]); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect( + report.limitations.filter((line) => + line.startsWith( + "Section relocation scanning stopped at 1048576 records", + ), + ), + ).toHaveLength(1); + }); + + it("observes cancellation during the section relocation walk", async () => { + const bytes = repeatedRelocations(); + const controller = new AbortController(); + setImmediate(() => controller.abort(new Error("cancelled by test"))); + await expect( + readNativeInitializers( + bytes, + artifact(bytes), + { path: "/artifacts/image", offset: 0, limit: 64 }, + controller.signal, + ), + ).rejects.toThrow(ArtifactReaderFailure); + }); +}); + +describe("Mach-O", () => { + it("decodes chained pointers, init offsets and raw terminators", async () => { + const report = await read(machoArm64Initializers()); + expect(report).toMatchObject({ + format: "mach-o", + status: "parsed", + architecture: "arm64", + slice: null, + elf_type: null, + }); + expect(row(report)).toEqual([ + { + kind: "mod_init", + value: "0x100001000", + section: "__TEXT,__text", + symbol: "_init", + resolution: "chained_fixup", + }, + { + kind: "init_offsets", + value: "0x100001000", + section: "__TEXT,__text", + symbol: "_init", + resolution: "offset_table", + }, + { + kind: "mod_term", + value: "0x100001100", + section: "__TEXT,__text", + symbol: "_term", + resolution: "raw", + }, + ]); + expect(report.entries[1]).toMatchObject({ + slot_bytes: 4, + slot_file_offset: 0x4010, + }); + }); + + it("marks the report incomplete when a chained-fixup slot cannot be decoded", async () => { + const bytes = machoArm64Initializers(); + // A DYLD_CHAINED_PTR_64_OFFSET bind with ordinal 5, but the image has no imports. + bytes.writeBigUInt64LE((1n << 63n) | 5n, 0x4000); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + kind: "mod_init", + resolution: "unresolved", + value_va: null, + }); + expect(report.status).toBe("incomplete"); + expect(report.coverage).toMatchObject({ + status: "incomplete", + total_entries: null, + }); + expect(report.limitations).toEqual( + expect.arrayContaining([ + "Mach-O chained fixup coverage is incomplete.", + "1 pointer slot(s) could not be decoded through Mach-O fixups and are listed as unresolved.", + ]), + ); + }); + + it("reports container file offsets for a selected universal slice", async () => { + const report = await read(machoArm64FatInitializers(), { + architecture: "arm64", + }); + expect(report.slice).toMatchObject({ + file_offset: 0x1000, + architecture: "arm64", + }); + expect(report.entries[0]).toMatchObject({ + slot_file_offset: 0x5000, + value_file_offset: 0x2000, + }); + }); + + it("rejects bytes that are neither ELF nor Mach-O", async () => { + await expect(read(Buffer.alloc(128))).rejects.toThrow(BinaryFormatFailure); + }); +}); + +describe("ELF repeated observations", () => { + it("tallies every over-long symbol name into one counted limitation", async () => { + const rows = 200; + const strings = Buffer.from(`\0${"x".repeat(1100)}\0`, "utf8"); + const symbolsAt = 1104; + const symbols = Buffer.alloc(rows * 24); + for (let row = 0; row < rows; row += 1) { + symbols.writeUInt32LE(1, row * 24); + symbols.writeUInt8(0x12, row * 24 + 4); + symbols.writeUInt16LE(1, row * 24 + 6); + } + const payload = Buffer.concat([ + strings, + Buffer.alloc(symbolsAt - strings.length), + symbols, + ]); + // Section 9 is the string table, section 10 a symbol table whose every row names it. + const bytes = withExtraSections(payload, (at) => [ + sectionHeader(3, at, strings.length, 0, 0), + sectionHeader(2, at + symbolsAt, rows * 24, 9, 24), + ]); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect( + report.limitations.filter((line) => + line.includes("exceeded the 1024-byte name budget"), + ), + ).toEqual([ + `${String(rows)} symbol name(s) exceeded the 1024-byte name budget and were not used.`, + ]); + expect(report.limitations.length).toBeLessThanOrEqual( + MAX_NATIVE_INITIALIZER_LIMITATIONS, + ); + }); + + it("tallies relocation sections with an unexpected entry size", async () => { + const sections = 40; + const bytes = withExtraSections(Buffer.alloc(48), (at) => + Array.from({ length: sections }, () => + sectionHeader(4, at, 48, 4, 16, 7), + ), + ); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect( + report.limitations.filter((line) => line.includes("entry size")), + ).toEqual([ + `${String(sections)} relocation section(s) had an unexpected entry size and were not applied.`, + ]); + expect(report.limitations.length).toBeLessThanOrEqual( + MAX_NATIVE_INITIALIZER_LIMITATIONS, + ); + }); + + it("keeps distinct notes to the budget and counts the rest", async () => { + const notes = new InitializerNotes(); + for (let index = 0; index < 100; index += 1) { + notes.add(`note ${String(index)}`); + notes.add(`note ${String(index)}`); + notes.tally("long-symbol-name"); + notes.tally("relocation-entry-size"); + } + const lines = notes.list(); + expect(lines.slice(0, MAX_NATIVE_INITIALIZER_NOTES)).toEqual( + Array.from( + { length: MAX_NATIVE_INITIALIZER_NOTES }, + (_, index) => `note ${String(index)}`, + ), + ); + expect(lines.slice(MAX_NATIVE_INITIALIZER_NOTES)).toEqual([ + "100 symbol name(s) exceeded the 1024-byte name budget and were not used.", + "100 relocation section(s) had an unexpected entry size and were not applied.", + // Repeats of an unlisted message cannot be told apart once the budget is full. + `${String(2 * (100 - MAX_NATIVE_INITIALIZER_NOTES))} further observation(s), repeats included, were not listed after the 24-note budget.`, + ]); + // Standing limitations, the full note list and the withheld-name line fit the schema. + expect( + NATIVE_INITIALIZER_LIMITATIONS.length + lines.length + 1, + ).toBeLessThanOrEqual(MAX_NATIVE_INITIALIZER_LIMITATIONS); + expect(notes.recorded).toBe(400); + }); +}); + +describe("ELF section lookups", () => { + it("names the smallest covering range and breaks ties by header order", async () => { + const index = buildAddressRangeIndex( + [ + { + start: 0n, + end: 0x100n, + value: { name: "outer", size: 0x100, order: 0 }, + }, + { + start: 0x10n, + end: 0x20n, + value: { name: "inner", size: 0x10, order: 1 }, + }, + { + start: 0x10n, + end: 0x20n, + value: { name: "twin", size: 0x10, order: 2 }, + }, + { + start: 0x200n, + end: 0x200n, + value: { name: "empty", size: 0, order: 3 }, + }, + ], + (left, right) => left.size - right.size || left.order - right.order, + ); + const name = (address: bigint) => index.at(address)?.value.name ?? null; + expect( + [0n, 0xfn, 0x10n, 0x1fn, 0x20n, 0xffn, 0x100n, 0x200n].map(name), + ).toEqual([ + "outer", + "outer", + "inner", + "inner", + "outer", + "outer", + null, + null, + ]); + }); + + it("resolves a full slot budget against a near-maximal section table in one pass", async () => { + const base = elf64PieInitializers(); + const slots = MAX_NATIVE_INITIALIZER_ENTRIES; + const payload = Buffer.alloc(slots * 8); + for (let slot = 0; slot < slots; slot += 1) + payload.writeBigUInt64LE(0x100n, slot * 8); + // Allocated, unnamed sections that cover every slot and target address. + const extra = Buffer.alloc((0xffff - 12) * 64); + for (let at = 0; at < extra.length; at += 64) { + extra.writeUInt32LE(1, at + 4); + extra.writeBigUInt64LE(2n, at + 8); + extra.writeBigUInt64LE(0x100000n, at + 32); + } + const bytes = Buffer.concat([ + base, + payload, + base.subarray(0x800, 0x800 + 12 * 64), + extra, + ]); + bytes.writeBigUInt64LE(BigInt(base.length + payload.length), 40); + bytes.writeUInt16LE(0xffff, 60); + // The single PT_LOAD maps the whole file; DT_INIT_ARRAY moves to the payload. + bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 40); + bytes.writeBigUInt64LE(BigInt(base.length), 0x300 + 2 * 16 + 8); + bytes.writeBigUInt64LE(BigInt(slots * 8), 0x300 + 3 * 16 + 8); + const started = performance.now(); + const report = await read(bytes); + // A per-lookup header walk is about 4e9 comparisons here; the index is a few hundred ms. + expect(performance.now() - started).toBeLessThan(10_000); + expect(report.coverage.examined_entries).toBe(slots); + const slot = report.entries.find((entry) => entry.kind === "init_array"); + expect(slot).toMatchObject({ + value_va: "0x100", + slot_section: null, + section: { name: ".text" }, + symbol: { name: "ctor_a" }, + }); + }, 180_000); +}); + +describe("Mach-O symbol tables", () => { + const SYMTAB_COMMAND = 24; + /** The arm64 fixture with `extra` LC_SYMTAB commands after its own and `payload` appended. */ + const withSymtabs = ( + payload: Buffer, + extra: (payloadAt: number, original: Buffer) => readonly Buffer[], + ): Buffer => { + const base = machoArm64Initializers(); + const original = Buffer.from(base.subarray(512, 512 + SYMTAB_COMMAND)); + const commands = extra(base.length, original); + const bytes = Buffer.concat([base, payload]); + Buffer.concat(commands).copy(bytes, 512 + SYMTAB_COMMAND); + bytes.writeUInt32LE(4 + commands.length, 16); + bytes.writeUInt32LE(504 + commands.length * SYMTAB_COMMAND, 20); + return bytes; + }; + const symtab = (original: Buffer, symoff: number, nsyms: number): Buffer => { + const command = Buffer.from(original); + command.writeUInt32LE(symoff, 8); + command.writeUInt32LE(nsyms, 12); + return command; + }; + + it("caps symbols in total across every LC_SYMTAB, not per command", async () => { + const perTable = 40_000; + const bytes = withSymtabs( + Buffer.alloc((perTable + 1) * 16), + (at, original) => [ + symtab(original, at, perTable), + symtab(original, at + 16, perTable), + ], + ); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.coverage.total_entries).toBeNull(); + expect(report.limitations).toContain( + "Symbol scanning stopped at 65536 symbols across all LC_SYMTAB commands. Later names may be missing.", + ); + expect(report.entries[0]?.symbol?.name).toBe("_init"); + }); + + it("ignores a repeated LC_SYMTAB over the same ranges", async () => { + const bytes = withSymtabs(Buffer.alloc(0), (_, original) => [original]); + const report = await read(bytes); + expect(report.status).toBe("parsed"); + expect(row(report)).toEqual(row(await read(machoArm64Initializers()))); + expect(report.limitations).toContain( + "A repeated LC_SYMTAB names the same symbol and string ranges and was ignored.", + ); + }); +}); + +describe("ELF relocation coverage", () => { + it("ignores no-op relocation records overlapping an initializer", async () => { + const base = elfRelocatableCtors(); + const records = Buffer.alloc(48); + base.copy(records, 0, 0x300, 0x318); + base.copy(records, 24, 0x300, 0x318); + records.writeBigUInt64LE(0n, 32); + const bytes = Buffer.concat([base, records]); + bytes.writeBigUInt64LE(BigInt(base.length), 0x500 + 3 * 64 + 24); + bytes.writeBigUInt64LE(48n, 0x500 + 3 * 64 + 32); + expect(row(await read(bytes))).toEqual(row(await read(base))); + }); + + it("keeps slots unresolved when a section relocation table that reaches them is skipped", async () => { + const bytes = elfRelocatableCtors(); + // .rela.ctors (section 3) and .rela.init_array (section 8) get a 16-byte entry size. + bytes.writeBigUInt64LE(16n, 0x500 + 3 * 64 + 56); + bytes.writeBigUInt64LE(16n, 0x500 + 8 * 64 + 56); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + // No stored zero becomes a raw value, and no .ctors slot is dropped as a sentinel. + expect( + report.entries.map((entry) => [entry.kind, entry.resolution]), + ).toEqual([ + ["init_array", "unresolved"], + ["ctors", "unresolved"], + ["ctors", "unresolved"], + ["ctors", "unresolved"], + ]); + expect(report.entries.every((entry) => entry.value_va === null)).toBe(true); + }); + + it("does not treat a dynamic write that overlaps half a slot as leaving it untouched", async () => { + const bytes = elf64PieInitializers(); + // The leading null RELA record becomes R_X86_64_32 at 0x204, the upper half of slot 0x200. + bytes.writeBigUInt64LE(0x204n, 0x480); + bytes.writeBigUInt64LE(10n, 0x488); + const report = await read(bytes); + const slots = report.entries.filter((entry) => entry.kind === "init_array"); + expect(slots.map((entry) => [entry.slot_va, entry.resolution])).toEqual([ + ["0x200", "unresolved"], + ["0x208", "relative_addend"], + ["0x210", "unresolved_import"], + ]); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "1 initializer slot(s) are partly overwritten or written more than once by dynamic relocations and are listed as unresolved.", + ); + }); + + it("does not reapply a linked image's retained link-time REL record", async () => { + const base = elf32RelativeInitializers(); + const bytes = Buffer.from(base); + bytes.writeUInt16LE(2, 16); + // Move the loader RELATIVE record off the slot so only the retained record reaches it. + bytes.writeUInt32LE(0x104, 0x180); + // A non-SHF_ALLOC SHT_REL with R_386_32 against `ctor` at slot 0x100, which already holds 0x80. + bytes.writeUInt32LE(0x100, 0x190); + bytes.writeUInt32LE((1 << 8) | 1, 0x194); + const header = Buffer.alloc(40); + header.writeUInt32LE(9, 4); + header.writeUInt32LE(0x190, 16); + header.writeUInt32LE(8, 20); + header.writeUInt32LE(5, 24); + header.writeUInt32LE(2, 28); + header.writeUInt32LE(8, 36); + header.copy(bytes, 0x280 + 8 * 40); + bytes.writeUInt16LE(9, 48); + const report = await read(bytes); + expect(row(report)).toEqual([ + { + kind: "init_array", + value: "0x80", + section: ".text", + symbol: "ctor", + resolution: "raw", + }, + ]); + expect(report.status).toBe("parsed"); + }); + + it("maps a resolved target whose first byte ends a load segment", async () => { + const bytes = elf64PieInitializers(); + // The first init_array RELATIVE addend points at the last mapped byte. + bytes.writeBigInt64LE(BigInt(bytes.length - 1), 0x498 + 16); + const report = await read(bytes); + const slot = report.entries.find((entry) => entry.kind === "init_array"); + expect(slot).toMatchObject({ + value_va: `0x${(bytes.length - 1).toString(16)}`, + value_file_offset: bytes.length - 1, + }); + }); + + it("pauses during a large dynamic relocation table and observes cancellation there", async () => { + const base = elf64PieInitializers(); + const words = 64 * 1024; + const relr = Buffer.alloc(words * 8); + // One address entry for the fini slot, then empty bitmaps that only advance the cursor. + relr.writeBigUInt64LE(0x220n, 0); + for (let word = 1; word < words; word += 1) + relr.writeBigUInt64LE(1n, word * 8); + const bytes = Buffer.concat([base, relr]); + bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 40); + // DT_RELR and DT_RELRSZ are the twelfth and thirteenth dynamic entries. + bytes.writeBigUInt64LE(BigInt(base.length), 0x300 + 11 * 16 + 8); + bytes.writeBigUInt64LE(BigInt(relr.length), 0x300 + 12 * 16 + 8); + expect(row(await read(bytes))).toEqual(row(await read(base))); + const controller = new AbortController(); + let turns = 0; + const tick = (): void => { + turns += 1; + if (turns === 2) controller.abort(new Error("cancelled by test")); + else setImmediate(tick); + }; + setImmediate(tick); + await expect( + readNativeInitializers( + bytes, + artifact(bytes), + { path: "/artifacts/image", offset: 0, limit: 64 }, + controller.signal, + ), + ).rejects.toThrow(ArtifactReaderFailure); + }); +}); + +describe("Unreadable symbol names", () => { + it("reports an ELF name offset outside its string table as incomplete", async () => { + const bytes = elf64PieInitializers(); + // ctor_a, the first .symtab entry after the null symbol. + bytes.writeUInt32LE(0x7fff, 0x598); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.entries[0]?.symbol).toBeNull(); + expect(report.limitations).toContain( + "1 symbol name(s) could not be read because the string table is missing or the name offset is outside it.", + ); + }); + + it("reports a Mach-O name index outside its string table as incomplete", async () => { + const bytes = machoArm64Initializers(); + bytes.writeUInt32LE(0x7fff, 0x3000); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.entries[0]?.symbol).toBeNull(); + expect(report.limitations).toContain( + "1 symbol name(s) could not be read because the string table is missing or the name offset is outside it.", + ); + }); +}); + +describe("Mach-O fixup sources", () => { + it("yields and cancels while expanding legacy rebase runs", async () => { + const bytes = machoArm64LegacyRebaseInitializers(); + const run = Buffer.from([0x11, 0x21, 0, 0x60, 0x80, 0x10]); + const stream = Buffer.concat([ + ...Array.from({ length: 12 }, () => run), + Buffer.from([0]), + ]); + stream.copy(bytes, 0x3300); + bytes.writeUInt32LE(stream.length, 548); + const controller = new AbortController(); + setImmediate(() => controller.abort(new Error("cancel during rebase"))); + await expect( + readNativeInitializers( + bytes, + artifact(bytes), + { path: "/artifacts/image", offset: 0, limit: 64 }, + controller.signal, + ), + ).rejects.toThrow(ArtifactReaderFailure); + }); + + it("keeps both initializer slots unresolved when a legacy bind crosses their boundary", async () => { + const bytes = machoArm64LegacyRebaseInitializers(); + const bind = Buffer.from([ + 0x40, + ...Buffer.from("_foo\0"), + 0x51, + 0x71, + 0x04, + 0x90, + 0x00, + ]); + bytes.writeBigUInt64LE(16n, 256 + 40); + bytes.writeUInt32LE(0, 548); + bytes.writeUInt32LE(0x3300, 552); + bytes.writeUInt32LE(bind.length, 556); + bind.copy(bytes, 0x3300); + const report = await read(bytes); + expect(report.entries.slice(0, 2).map((entry) => entry.resolution)).toEqual( + ["unresolved", "unresolved"], + ); + expect(report.status).toBe("incomplete"); + }); + + it("does not resolve an initializer from a section offset that disagrees with its load mapping", async () => { + const bytes = machoArm64LegacyRebaseInitializers(); + bytes.writeUInt32LE(0x4030, 256 + 48); + bytes.writeBigUInt64LE(0x100001100n, 0x4030); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + resolution: "unresolved", + value_va: null, + }); + expect(report.status).toBe("incomplete"); + }); + + it("labels an LC_DYLD_INFO rebase as a legacy rebase, not a chained fixup", async () => { + const report = await read(machoArm64LegacyRebaseInitializers()); + expect(report.status).toBe("parsed"); + expect(row(report)[0]).toEqual({ + kind: "mod_init", + value: "0x100001000", + section: "__TEXT,__text", + symbol: "_init", + resolution: "dyld_info_rebase", + }); + }); + + it("keeps a 32-bit pointer slot unresolved when undecoded bind opcodes could replace it", async () => { + expect(row(await read(macho32Initializers(0)))).toEqual([ + { + kind: "mod_init", + value: "0x1100", + section: "__TEXT,__text", + symbol: "_init", + resolution: "raw", + }, + ]); + const report = await read(macho32Initializers(8)); + expect(report.entries[0]).toMatchObject({ + resolution: "unresolved", + value_va: null, + }); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "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.", + ); + }); + + it("applies object-file section relocations instead of reading the stored addend as a target", async () => { + const report = await read(machoX8664ObjectInitializers()); + expect(row(report)).toEqual([ + { + kind: "mod_init", + value: "0x10", + section: "__TEXT,__text", + symbol: "_startup", + resolution: "symbol_addend", + }, + { + kind: "mod_init", + value: "0x0", + section: "__TEXT,__text", + symbol: "_before", + resolution: "raw", + }, + ]); + expect(report.status).toBe("parsed"); + }); + + it("decodes UTF-8 Mach-O nlist names and retains invalid text as unknown", async () => { + const image = machoX8664ObjectInitializers(); + const strings = Buffer.from("\0_before\0_démarre\0", "utf8"); + strings.copy(image, 0x270); + image.writeUInt32LE(strings.length, 284); + const report = await read(image); + expect(report.entries[0]?.symbol?.name).toBe("_démarre"); + const invalid = Buffer.from(image); + invalid[0x270 + 10] = 0xff; + const unknown = await read(invalid); + expect(unknown.entries[0]?.symbol).toBeNull(); + expect(unknown.status).toBe("incomplete"); + }); + + it("keeps both object-file slots unresolved when a relocation crosses their boundary", async () => { + const bytes = machoX8664ObjectInitializers(); + bytes.writeUInt32LE(4, 0x248); + const report = await read(bytes); + expect(report.entries.map((entry) => entry.resolution)).toEqual([ + "unresolved", + "unresolved", + ]); + expect(report.status).toBe("incomplete"); + }); + + it("keeps an object-file slot unresolved under a relocation it does not apply", async () => { + const bytes = machoX8664ObjectInitializers(); + // The first entry becomes X86_64_RELOC_SUBTRACTOR (type 5). + bytes.writeUInt32LE(1 | (3 << 25) | (1 << 27) | (5 << 28), 0x244); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + resolution: "unresolved", + value_va: null, + }); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "1 object-file slot(s) have section relocations this reader does not apply and are listed as unresolved.", + ); + }); +}); + +describe("Initializer target semantics", () => { + it("keeps an absolute relocation against an IFUNC symbol unresolved", async () => { + const bytes = elfRelocatableCtors(); + // `ctor` becomes a global STT_GNU_IFUNC, whose value is its resolver. + bytes.writeUInt8(0x1a, 0x338 + 4); + const report = await read(bytes); + const ctor = report.entries.find((entry) => entry.kind === "ctors"); + expect(ctor).toMatchObject({ + slot_va: "0x8", + resolution: "unresolved", + value_va: null, + }); + }); + + it("keeps a slot a narrower section relocation partly overwrites unresolved", async () => { + const bytes = elfRelocatableCtors(); + // The .ctors record becomes R_X86_64_32 at byte 4 of slot 0x8. + bytes.writeBigUInt64LE(0xcn, 0x300); + bytes.writeBigUInt64LE((1n << 32n) | 10n, 0x308); + const report = await read(bytes); + const ctors = report.entries.filter((entry) => entry.kind === "ctors"); + expect(ctors.map((entry) => [entry.slot_va, entry.resolution])).toEqual([ + ["0x8", "unresolved"], + ]); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "1 initializer slot(s) are partly overwritten or relocated more than once by section relocations and are listed as unresolved.", + ); + }); + + it("clears the ELF ARM Thumb bit when locating a target but keeps the callable value", async () => { + const bytes = elf32RelativeInitializers(); + bytes.writeUInt16LE(40, 18); + // R_ARM_RELATIVE with in-place addend 0x81, and a Thumb `ctor` at 0x81. + bytes.writeUInt32LE(23, 0x184); + bytes.writeUInt32LE(0x81, 0x100); + bytes.writeUInt32LE(0x81, 0x1b4); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + value_va: "0x81", + value_file_offset: 0x80, + section: { name: ".text" }, + symbol: { name: "ctor" }, + resolution: "relative_addend", + }); + }); + + it("clears the Mach-O ARM Thumb bit when locating and naming a target", async () => { + const bytes = macho32Initializers(0); + bytes.writeUInt32LE(12, 4); + bytes.writeUInt32LE(0x1101, 0x1000); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + value_va: "0x1101", + value_file_offset: 0x100, + section: { name: "__TEXT,__text" }, + symbol: { name: "_init" }, + resolution: "raw", + }); + }); + + it("rejects an arm64_32 image that declares a 64-bit header", async () => { + const bytes = machoArm64Initializers(); + bytes.writeUInt32LE(0x0200000c, 4); + await expect(read(bytes)).rejects.toThrow(BinaryFormatFailure); + }); +}); + +describe("Initializer address mapping", () => { + it("reports no target file offset when load segments map it ambiguously", async () => { + const bytes = elf64PieInitializers(); + // A third PT_LOAD maps 0x100..0x140 from file offset 0x200, conflicting with the identity load. + bytes.writeUInt16LE(3, 56); + bytes.writeUInt32LE(1, 176); + bytes.writeUInt32LE(5, 180); + bytes.writeBigUInt64LE(0x200n, 184); + bytes.writeBigUInt64LE(0x100n, 192); + bytes.writeBigUInt64LE(0x100n, 200); + bytes.writeBigUInt64LE(0x40n, 208); + bytes.writeBigUInt64LE(0x40n, 216); + bytes.writeBigUInt64LE(1n, 224); + const report = await read(bytes); + const preinit = report.entries.find( + (entry) => entry.kind === "preinit_array", + ); + expect(preinit).toMatchObject({ + value_va: "0x100", + value_file_offset: null, + }); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "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.", + ); + }); + + /** ELF64 ET_EXEC with `loads` identical identity PT_LOADs and an `.init_array` of `slots` slots, each pointing at 0x40. */ + const manyLoads = (loads: number, slots: number): Buffer => { + const arrayAt = 0x1000; + const shstr = Buffer.from("\0.init_array\0.shstrtab\0", "utf8"); + const stringsAt = arrayAt + slots * 8; + const headersAt = stringsAt + 0x40; + const bytes = Buffer.alloc(headersAt + 3 * 64); + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1], 0); + bytes.writeUInt16LE(2, 16); + bytes.writeUInt16LE(62, 18); + bytes.writeUInt32LE(1, 20); + bytes.writeBigUInt64LE(64n, 32); + bytes.writeBigUInt64LE(BigInt(headersAt), 40); + bytes.writeUInt16LE(64, 52); + bytes.writeUInt16LE(56, 54); + bytes.writeUInt16LE(loads, 56); + bytes.writeUInt16LE(64, 58); + bytes.writeUInt16LE(3, 60); + bytes.writeUInt16LE(2, 62); + for (let load = 0; load < loads; load += 1) { + const at = 64 + load * 56; + bytes.writeUInt32LE(1, at); + bytes.writeUInt32LE(5, at + 4); + bytes.writeBigUInt64LE(BigInt(bytes.length), at + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length), at + 40); + bytes.writeBigUInt64LE(1n, at + 48); + } + for (let slot = 0; slot < slots; slot += 1) + bytes.writeBigUInt64LE(0x40n, arrayAt + slot * 8); + shstr.copy(bytes, stringsAt); + const header = ( + index: number, + name: number, + type: number, + offset: number, + size: number, + flags: number, + ): void => { + const at = headersAt + index * 64; + bytes.writeUInt32LE(name, at); + bytes.writeUInt32LE(type, at + 4); + bytes.writeBigUInt64LE(BigInt(flags), at + 8); + bytes.writeBigUInt64LE(BigInt(offset), at + 16); + bytes.writeBigUInt64LE(BigInt(offset), at + 24); + bytes.writeBigUInt64LE(BigInt(size), at + 32); + bytes.writeBigUInt64LE(1n, at + 48); + }; + header(1, 1, 14, arrayAt, slots * 8, 3); + header(2, 13, 3, stringsAt, shstr.length, 0); + return bytes; + }; + + it("builds one mapping index per image instead of scanning every load per slot", async () => { + // Calls into the mapping module made from other modules, per inspection. + const mappingCalls = async (slots: number) => { + mapping.scans.mockClear(); + mapping.indexes.mockClear(); + const report = await read(manyLoads(4, slots)); + expect(report.coverage.examined_entries).toBe(slots); + expect(report.entries[0]).toMatchObject({ + value_va: "0x40", + value_file_offset: 0x40, + }); + return { + scans: mapping.scans.mock.calls.length, + indexes: mapping.indexes.mock.calls.length, + }; + }; + const few = await mappingCalls(8); + const many = await mappingCalls(64); + // A per-slot linear scan grows with the slot count; indexed lookups do not. + expect(many).toEqual(few); + expect(many.indexes).toBeGreaterThan(0); + }); + + it("reports a Mach-O bind name over the name budget as incomplete", async () => { + const bytes = machoArm64Initializers(); + const name = `_${"b".repeat(1100)}`; + // Rebuild the chained-fixups payload with one DYLD_CHAINED_IMPORT bound by flat lookup. + const starts = Buffer.from(bytes.subarray(0x3200 + 28, 0x3200 + 64)); + const header = Buffer.alloc(28); + header.writeUInt32LE(28, 4); + header.writeUInt32LE(28 + starts.length, 8); + header.writeUInt32LE(28 + starts.length + 4, 12); + header.writeUInt32LE(1, 16); + header.writeUInt32LE(1, 20); + const entry = Buffer.alloc(4); + entry.writeUInt32LE(0xfe, 0); + const chained = Buffer.concat([ + header, + starts, + entry, + Buffer.from(`${name}\0`), + ]); + chained.copy(bytes, 0x3200); + bytes.writeUInt32LE(chained.length, 508); + bytes.writeBigUInt64LE(1n << 63n, 0x4000); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + kind: "mod_init", + resolution: "unresolved_import", + symbol: null, + }); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "1 symbol name(s) exceeded the 1024-byte name budget and were not used.", + ); + }); +}); + +it("keeps a slot touched by an overrun section relocation unresolved", async () => { + const bytes = elfRelocatableCtors(); + bytes.writeBigUInt64LE(4n, 0x318); + bytes.writeBigUInt64LE(0x10n, 0x220); + const report = await readNativeInitializers(bytes, artifact(bytes), { + path: "/artifacts/image", + offset: 0, + limit: 64, + }); + expect(report.coverage.status).toBe("incomplete"); + expect( + report.entries.find((entry) => entry.kind === "init_array"), + ).toMatchObject({ resolution: "unresolved", value_va: null }); +}); + +it("keeps identical repeated dynamic writes unresolved", async () => { + const bytes = elf64PieInitializers(); + bytes.writeBigUInt64LE(0x200n, 0x480); + bytes.writeBigUInt64LE(8n, 0x488); + bytes.writeBigInt64LE(0x100n, 0x490); + const report = await readNativeInitializers(bytes, artifact(bytes), { + path: "/artifacts/image", + offset: 0, + limit: 64, + }); + expect( + report.entries.find((entry) => entry.slot_va === "0x200"), + ).toMatchObject({ resolution: "unresolved", value_va: null }); +}); + +it("retains unknown coverage when sparse bind lookups exhaust their allowance", async () => { + const bytes = machoSparseBindInitializers(4097); + const report = await readNativeInitializers(bytes, artifact(bytes), { + path: "/artifacts/image", + offset: 0, + limit: 1, + }); + expect(report.coverage.status).toBe("incomplete"); + expect(report.entries[0]).toMatchObject({ + resolution: "unresolved", + value_va: null, + }); + expect(report.limitations.join("\n")).toContain( + "bind lookup candidate allowance exhausted", + ); +}); + +it("yields and observes cancellation during sparse bind analysis", async () => { + const bytes = machoSparseBindInitializers(2048); + const controller = new AbortController(); + setImmediate(() => controller.abort()); + await expect( + readNativeInitializers( + bytes, + artifact(bytes), + { path: "/artifacts/image", offset: 0, limit: 1 }, + controller.signal, + ), + ).rejects.toThrow(ArtifactReaderFailure); +}); + +it("keeps a linked section-fallback slot unresolved when its load mapping disagrees", async () => { + const bytes = elf64PieInitializers(); + for (let at = 0x300; at < 0x430; at += 16) { + if (bytes.readBigUInt64LE(at) === 25n) + bytes.writeBigUInt64LE(0x70000000n, at); + } + bytes.writeBigUInt64LE(0x260n, 0x800 + 3 * 64 + 24); + bytes.writeBigUInt64LE(0x110n, 0x260); + const report = await readNativeInitializers(bytes, artifact(bytes), { + path: "/artifacts/image", + offset: 0, + limit: 64, + }); + expect(report.coverage.status).toBe("incomplete"); + expect( + report.entries.find((entry) => entry.kind === "init_array"), + ).toMatchObject({ + resolution: "unresolved", + value_va: null, + slot_file_offset: 0x260, + }); +}); diff --git a/src/native/initializers/NativeInitializersProvider.ts b/src/native/initializers/NativeInitializersProvider.ts new file mode 100644 index 0000000000..9386f327b2 --- /dev/null +++ b/src/native/initializers/NativeInitializersProvider.ts @@ -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 { + 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 => { + 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, + page: { readonly offset: number; readonly limit: number }, + signal: AbortSignal | undefined, +): Promise => { + 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, + ); diff --git a/src/native/initializers/addressRangeIndex.ts b/src/native/initializers/addressRangeIndex.ts new file mode 100644 index 0000000000..cd16b4eed5 --- /dev/null +++ b/src/native/initializers/addressRangeIndex.ts @@ -0,0 +1,118 @@ +/** One half-open address range `[start, end)` and the value it names. */ +export interface AddressRange { + 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 = (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 { + /** The preferred range covering `address`, if any. */ + readonly at: (address: bigint) => AddressRange | undefined; +} + +/** `prefer(a, b) < 0` when `a` wins where both cover an address. It must be a total order. */ +export const buildAddressRangeIndex = ( + ranges: readonly AddressRange[], + prefer: (left: T, right: T) => number, +): AddressRangeIndex => { + 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>( + (left, right) => prefer(left.value, right.value) < 0, + ); + const starts: bigint[] = []; + const ends: bigint[] = []; + const winners: AddressRange[] = []; + 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).start <= bound + ) { + active.push(sorted[next] as AddressRange); + 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; + }, + }; +}; diff --git a/src/native/initializers/initializerNotes.ts b/src/native/initializers/initializerNotes.ts new file mode 100644 index 0000000000..aa5f00cf58 --- /dev/null +++ b/src/native/initializers/initializerNotes.ts @@ -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(); + 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.`, + ]), + ]; + } +} diff --git a/src/native/initializers/initializerReport.ts b/src/native/initializers/initializerReport.ts new file mode 100644 index 0000000000..e17ed5c29a --- /dev/null +++ b/src/native/initializers/initializerReport.ts @@ -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.`, + ]), + ], + }; +}; diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index d19e0f21b3..8477045916 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -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 }; } ); diff --git a/src/native/metadata/ElfPointerRelocations.test.ts b/src/native/metadata/ElfPointerRelocations.test.ts new file mode 100644 index 0000000000..c5bde4385e --- /dev/null +++ b/src/native/metadata/ElfPointerRelocations.test.ts @@ -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(); +}); diff --git a/src/native/metadata/ElfPointerRelocations.ts b/src/native/metadata/ElfPointerRelocations.ts index a59470dbf1..281e276380 100644 --- a/src/native/metadata/ElfPointerRelocations.ts +++ b/src/native/metadata/ElfPointerRelocations.ts @@ -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 => ({ 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) { diff --git a/src/native/metadata/MachoImage.ts b/src/native/metadata/MachoImage.ts index f925ca4a61..2b352afbd4 100644 --- a/src/native/metadata/MachoImage.ts +++ b/src/native/metadata/MachoImage.ts @@ -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, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index c2ef9c3755..e1ce7342dc 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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, diff --git a/src/server/registerNativeInitializersTool.ts b/src/server/registerNativeInitializersTool.ts new file mode 100644 index 0000000000..7be1df0b5e --- /dev/null +++ b/src/server/registerNativeInitializersTool.ts @@ -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), + ); +}; diff --git a/tests/acceptance/localPathAdmissionCli.test.ts b/tests/acceptance/localPathAdmissionCli.test.ts index 2c78ab2692..ecc1c46842 100644 --- a/tests/acceptance/localPathAdmissionCli.test.ts +++ b/tests/acceptance/localPathAdmissionCli.test.ts @@ -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", diff --git a/tests/acceptance/nativeInitializersCli.test.ts b/tests/acceptance/nativeInitializersCli.test.ts new file mode 100644 index 0000000000..bc19e81595 --- /dev/null +++ b/tests/acceptance/nativeInitializersCli.test.ts @@ -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" }, + }); +}); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 3b4270ae73..df327bd945 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -250,6 +250,7 @@ const pathRequests: readonly [string, Record][] = [ ["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 }], diff --git a/tests/boundary/cli/cliDecimalArguments.test.ts b/tests/boundary/cli/cliDecimalArguments.test.ts index 6d1e8f46cd..29aacd7883 100644 --- a/tests/boundary/cli/cliDecimalArguments.test.ts +++ b/tests/boundary/cli/cliDecimalArguments.test.ts @@ -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", diff --git a/tests/boundary/filesystem/nativeInitializers.test.ts b/tests/boundary/filesystem/nativeInitializers.test.ts new file mode 100644 index 0000000000..fa6767c4ef --- /dev/null +++ b/tests/boundary/filesystem/nativeInitializers.test.ts @@ -0,0 +1,57 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { 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"); + }); +}); diff --git a/tests/boundary/mcp/nativeInitializersMcp.test.ts b/tests/boundary/mcp/nativeInitializersMcp.test.ts new file mode 100644 index 0000000000..9b2c504e5c --- /dev/null +++ b/tests/boundary/mcp/nativeInitializersMcp.test.ts @@ -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(); + } + }, +); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index a61fcb4f5d..445ab97677 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -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 }),