From 933af60eba43c29f1fb1c606fddcd725ae0d9d96 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 22 Oct 2026 01:30:05 -0700 Subject: [PATCH] feat(native): inspect PE and ELF export tables without an engine (#6278) * feat(native): read PE and ELF export tables Add a host-independent export-table reader on the shared native container layer. PE images report each used export-address-table slot with its ordinal, one row per name, its address or forwarder text, and the export directory identity; the PE image reader now exposes its data directories. ELF images report defined default/protected global, weak and unique .dynsym symbols with type, binding and .gnu.version_d version (hidden versions kept distinct, unreadable version tables "unknown"). Unreadable entries are counted in issues and make coverage partial; an ELF without section headers is unsupported. Co-Authored-By: Claude Opus 5.5 * feat(native): inspect native export tables through CLI and MCP Expose the reader as inspect_native_exports / inspect-native-exports: one stable, digest-bound read of an explicit file with a caller-selected input budget, typed failures for malformed, unsupported, oversized and cancelled reads, and observed Evidence bound to the file path and SHA-256. Mach-O inputs point to inspect_macho. Co-Authored-By: Claude Opus 5.5 * docs(native): describe portable PE and ELF export inventories Co-Authored-By: Claude Opus 5.5 * fix(native): bound export inventories and read header-backed PE tables Address review of the export-table reader: - Charge every retained row and its name, forwarder and version text against shared 262144-row and 16 MiB allowances before storing it, so repeated long names or PE aliases cannot exhaust memory. - Map PE RVAs through the headers as well as sections; the PE image reader now exposes its SizeOfHeaders mapping. - Keep ELF exports with version "unknown" when .gnu.version lies outside the file, and report an empty version-definition name as unresolved. - Reject a .dynsym without its mandatory null entry as malformed. - Decode the ELF section directory once. - Correct the docs on which gaps drop rows and which keep them. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../skills/reverse-engineer-anything/SKILL.md | 2 + .../references/native-and-artifacts.md | 10 + docs/binary-diagnostics.md | 49 ++ scripts/lib/catalog-builders.mjs | 7 + src/application/CapabilityInventory.ts | 2 +- src/application/InvestigationProviders.ts | 7 + .../binaryDiagnostics/NativeExportsPort.ts | 20 + .../binaryDiagnostics/NativeExportsService.ts | 79 +++ src/cli/binaryDiagnosticsCommands.ts | 33 ++ src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 5 + .../native/binaryDiagnosticsToolContracts.ts | 19 + src/contracts/toolEffects.test.ts | 1 + src/contracts/toolEffects.ts | 1 + src/domain/native/nativeExports.ts | 128 +++++ src/native/exports/NativeExportParser.test.ts | 367 +++++++++++++ src/native/exports/NativeExportParser.ts | 502 ++++++++++++++++++ src/native/exports/NativeExportsProvider.ts | 212 ++++++++ src/native/metadata/BinaryContainer.ts | 10 + src/native/metadata/ElfImage.ts | 2 +- src/native/metadata/PeImage.ts | 11 + src/server/createServer.ts | 15 + src/server/registerNativeExportsTool.ts | 31 ++ .../tools/localToolContracts.test.ts | 1 + .../boundary/filesystem/nativeExports.test.ts | 104 ++++ .../mcp/absoluteLocalPathSchema.test.ts | 1 + .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 51 +- tests/fixtures/advertisedMcpTool.ts | 3 +- .../binaryDiagnostics/nativeExports.ts | 336 ++++++++++++ 29 files changed, 2006 insertions(+), 4 deletions(-) create mode 100644 src/application/binaryDiagnostics/NativeExportsPort.ts create mode 100644 src/application/binaryDiagnostics/NativeExportsService.ts create mode 100644 src/domain/native/nativeExports.ts create mode 100644 src/native/exports/NativeExportParser.test.ts create mode 100644 src/native/exports/NativeExportParser.ts create mode 100644 src/native/exports/NativeExportsProvider.ts create mode 100644 src/server/registerNativeExportsTool.ts create mode 100644 tests/boundary/filesystem/nativeExports.test.ts create mode 100644 tests/fixtures/binaryDiagnostics/nativeExports.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index c9203a2525..8391d3704d 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -247,6 +247,8 @@ explicit path or endpoint and do not need it. - PE resource type/name/language trees and static icon group references: `inspect_pe_resources` with an explicit path. This portable operation requires no active target or engine; opaque payloads retain file ranges and SHA-256. +- PE export directories and ELF dynamic-symbol exports: `inspect_native_exports` + with an explicit path; portable, no active target or engine. - Native executable, library, or analysis database: `open_binary`, then use focused analysis tools directly; call `binary_overview` when metadata or inventory context is useful and available from the selected provider. 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 b31021e6b1..5bb2956e8d 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -409,6 +409,16 @@ Other payloads are opaque, and this operation does not decode images or simulate Windows resource loading. Consult the budgets and supported tree profile in [offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md). +## Offline PE and ELF export tables + +Use `inspect_native_exports` with an absolute `path`, or +`rea inspect-native-exports ./libexample.so --json`, to read a PE export +directory or ELF dynamic symbol table on any host without opening a native +target. Ordinal-only, forwarded and hidden-version exports stay distinct; +partial or unsupported coverage means the inventory is incomplete, not that +exports are absent. Use `inspect_macho` for Mach-O. See +[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md). + For exact core-module import/export differences, use `compare_wasm_interfaces` with two complete inspection Evidence records (or current-session retained references) and explicit scope. Preserve unknown types/duplicate names; static diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index a17368b748..1e6ce267a1 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -299,3 +299,52 @@ arm64 host and Node version. Cold disk I/O, token counts and an agent investigation were not measured; there was no previous equivalent tool to benchmark. These figures do not establish a portable peak-memory bound, constant memory for retained Evidence or faster agent task completion. + +## Portable native exports + +`inspect_native_exports` / `inspect-native-exports` reads the export table of an +explicit PE or ELF image on any supported host, without an active target, +native engine, `objdump` or Windows SDK: + +```sh +rea inspect-native-exports ./runtimes/linux-x64/native/libexample.so --json +``` + +```json +{ + "name": "inspect_native_exports", + "arguments": { "path": "/artifacts/native/example.dll" } +} +``` + +The complete Evidence identifies the stable artifact snapshot by path, byte +count and SHA-256. + +- PE rows reproduce the export directory: each used export-address-table slot + with its `ordinal`, one row per name (aliases share an ordinal), `address` + as the preferred-base virtual address, or `forwarder` text when the slot + forwards to another DLL. Ordinal-only exports have a null `name`. The + directory's DLL name, ordinal base and timestamp are reported, and an image + without an export directory is a complete empty inventory. +- ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique + binding and default or protected visibility. Each row keeps `value` + (`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type` + and `binding`. `version` is null for an unversioned symbol, the + `.gnu.version_d` index, name and `hidden` flag for a versioned one, or + `"unknown"` when the version table cannot be read. A hidden version is not + the default version that a by-name `dlsym` lookup selects. + +Unreadable names, out-of-range name ordinals and unreadable forwarders are +listed in `issues`, their entries are not reported, and coverage is `partial`. +Unresolved symbol versions keep their rows: the version name is null for an +index no definition names, and `version` is `"unknown"` when the version table +cannot be read; coverage is likewise `partial`. A dynamic symbol table without +its null entry is malformed. An ELF without section headers is `unsupported`: +enumerating dynamic symbols from the dynamic segment is not +implemented. Mach-O exports are reported by `inspect_macho`. + +`max_file_bytes` defaults to 256 MiB (maximum 512 MiB; CLI `--max-file-bytes`). +Export directories in PE headers are read like those in sections. An export or +dynamic symbol table above 262144 entries, more than 262144 rows, or more than +16 MiB of retained names fails without partial success. Export rows do not establish that a symbol is reachable, safe to call +or loaded at runtime. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 31557cea7c..69bc92b73e 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -264,6 +264,13 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_byte_entropy", ), }, + { + identity: sources.artifactProviders.NATIVE_EXPORTS_PROVIDER, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_native_exports", + ), + }, { identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY, contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS, diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index bbcd1a953f..f110b005a8 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -498,7 +498,7 @@ const workflowAvailabilityFor = ({ remediation: "Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY. No active binary target is required; tools are checked at execution.", }; - if (name === "inspect_pe_resources") + if (name === "inspect_pe_resources" || name === "inspect_native_exports") return { reason: "available", remediation: null }; if (name === "inspect_elf_notes") return { reason: "available", remediation: null }; diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 3ad47a9621..88b3bb0520 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -62,6 +62,13 @@ export const BYTE_ENTROPY_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free PE and ELF export tables. */ +export const NATIVE_EXPORTS_PROVIDER = Object.freeze({ + id: "rea-native-exports", + name: "REA PE/ELF export table reader", + version: "1", +} as const); + /** Provider identity for deterministic managed-code comparison workflows. */ export const MANAGED_WORKFLOW_PROVIDER = { id: "rea-dotnet-workflows", diff --git a/src/application/binaryDiagnostics/NativeExportsPort.ts b/src/application/binaryDiagnostics/NativeExportsPort.ts new file mode 100644 index 0000000000..640d8e186e --- /dev/null +++ b/src/application/binaryDiagnostics/NativeExportsPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectNativeExportsInput, + NativeExports, +} from "../../domain/native/nativeExports.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and export-table decoding, independent of an active engine session. */ +export interface NativeExportsPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectNativeExportsInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/NativeExportsService.ts b/src/application/binaryDiagnostics/NativeExportsService.ts new file mode 100644 index 0000000000..d8d5bceb37 --- /dev/null +++ b/src/application/binaryDiagnostics/NativeExportsService.ts @@ -0,0 +1,79 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { NativeExportsPort } from "./NativeExportsPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { + inspectNativeExportsInputSchema, + nativeExportsSchema, +} from "../../domain/native/nativeExports.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_native_exports"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class NativeExportsService { + constructor(readonly provider: NativeExportsPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + /** Read one explicitly selected PE or ELF image's export table. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + const input = inspectNativeExportsInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + const result = await this.provider.inspect(input.data, options); + if (!result.ok) return result; + const report = nativeExportsSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + report.data.artifact.bytes > input.data.max_file_bytes || + report.data.coverage.exports !== report.data.exports.length + ) + return err( + new AnalysisOutputError( + OPERATION, + "Native export reader returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: value.format, + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ), + options?.signal, + ); + } +} diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 9e125c22d9..45cf8921db 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -6,6 +6,7 @@ import { createBinaryLayoutService, createElfNotesService, createPeResourcesService, + createNativeExportsService, } from "../composition/binaryDiagnostics.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -15,6 +16,7 @@ import { withCommandCancellation } from "./commandCancellation.js"; import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js"; import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js"; import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js"; +import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -130,6 +132,37 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectNativeExports, { + description: + "Inspect a PE or ELF image's export table without executing it", + args: z.object({ path: z.string().describe("Local PE or ELF image") }), + options: z.object({ + maxFileBytes: inspectNativeExportsInputSchema.shape.max_file_bytes, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const exports = createNativeExportsService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectNativeExports, + async () => + finishAnalysisCommand( + () => + exports.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + }, + { signal }, + ), + exports, + "rea-native-exports", + "inspect_native_exports", + signal, + ), + ); + }), + }); const service = createBinaryLayoutService(environment); cli.command(CLI_COMMANDS.inspectBinaryLayout, { description: diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 45b847f37a..f9b549ea1f 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -66,6 +66,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", inspectPeResources: "inspect-pe-resources", + inspectNativeExports: "inspect-native-exports", inspectElfNotes: "inspect-elf-notes", inspectAnalysisView: "inspect-analysis-view", listFridaDevices: "list-frida-devices", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index e17ed4e22c..ff7f519312 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -8,11 +8,16 @@ import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js"; import { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import { ElfNotesProvider } from "../native/elf/ElfNotesProvider.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; +import { NativeExportsProvider } from "../native/exports/NativeExportsProvider.js"; /** Portable PE inspection with no engine, installation or target acquisition. */ export const createPeResourcesService = (): PeResourcesService => new PeResourcesService(new PeResourcesProvider()); +/** Portable PE/ELF export tables with no engine, toolchain or target acquisition. */ +export const createNativeExportsService = (): NativeExportsService => + new NativeExportsService(new NativeExportsProvider()); /** Portable ELF note-table inspection with no engine or package catalog. */ export const createElfNotesService = (): ElfNotesService => new ElfNotesService(new ElfNotesProvider()); diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 9c63f3aa5a..1c7728f2a0 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -14,6 +14,10 @@ import { analysisDetailOutputSchemaOf, analysisDetailSchema, } from "../analysisDetail.js"; +import { + inspectNativeExportsInputSchema, + nativeExportsSchema, +} from "../../domain/native/nativeExports.js"; import type { ToolContract } from "../toolContractTypes.js"; import { toolContractMetadata } from "../toolEffects.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; @@ -95,6 +99,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_native_exports", + ...toolContractMetadata("inspect_native_exports"), + kind: "native-provider", + description: + "Inspect one explicit local PE or ELF image's export table without executing it or opening an engine: PE export-directory names, ordinals, addresses and forwarders, or ELF defined default/protected global, weak and unique .dynsym symbols with type, binding and symbol version. Ordinal-only, forwarded and hidden-version exports stay distinct. Portable on any supported host. Coverage is partial when table entries are unreadable and unsupported for an ELF without section headers; an empty complete inventory means the image declares no exports. Mach-O exports use inspect_macho.", + inputSchema: inspectNativeExportsInputSchema, + outputSchema: evidenceResultOf(nativeExportsSchema), + examples: [ + { + title: "List a native library's exports", + input: { path: "/artifacts/native/libexample.so" }, + }, + ], + }, { name: "inspect_binary_layout", ...toolContractMetadata("inspect_binary_layout"), diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 8c448d9085..8cd30a7be0 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -27,6 +27,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { resolve_native_call_targets: "states that it does not execute code", inspect_byte_entropy: "states that it profiles the file without execution", inspect_pe_resources: "states that it does not execute the image", + inspect_native_exports: "reads the export table without executing it", inspect_elf_notes: "states that it does not execute the image", inspect_desktop_application: "states that directory triage performs no target execution", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 8a855a2853..6c3b21d5ce 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -253,6 +253,7 @@ export const TOOL_EFFECTS: Readonly> = { }), inspect_byte_entropy: evidence, inspect_pe_resources: evidence, + inspect_native_exports: evidence, inspect_elf_notes: evidence, inspect_analysis_view: evidence, inspect_firmware_regions: effects({ diff --git a/src/domain/native/nativeExports.ts b/src/domain/native/nativeExports.ts new file mode 100644 index 0000000000..e258c919ff --- /dev/null +++ b/src/domain/native/nativeExports.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const address = z.string().regex(/^0x[0-9a-f]+$/u); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES = 536870912; +/** Fixed bound on reported export rows, so one library cannot flood a reply. */ +export const MAX_NATIVE_EXPORT_ROWS = 262144; + +/** One explicitly selected PE or ELF library or executable. */ +export const inspectNativeExportsInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a PE or ELF image", + ), + max_file_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES) + .default(268435456) + .describe("Maximum stable artifact bytes to read (default 256 MiB)"), +}); +export type InspectNativeExportsInput = z.infer< + typeof inspectNativeExportsInputSchema +>; + +/** + * One PE export-address-table slot as the export directory states it. A + * forwarder has no address in this image; an ordinal-only export has no name. + */ +const peExportSchema = z.strictObject({ + name: z.string().min(1).nullable(), + ordinal: z.number().int().min(0).max(0xffff), + address: address.nullable(), + forwarder: z.string().min(1).nullable(), +}); + +/** + * One defined, default- or protected-visibility global, weak or unique + * dynamic symbol. `version` is null when the symbol carries no version and + * "unknown" when the version table could not be read; a hidden version is not + * the default one a by-name lookup selects. + */ +const elfExportSchema = z.strictObject({ + name: z.string().min(1), + /** `st_value` as stored; on 32-bit ARM, bit 0 of a function marks Thumb code. */ + value: address, + size: z.string().regex(/^[0-9]+$/u), + type: z.enum(["function", "ifunc", "object", "tls", "other"]), + binding: z.enum(["global", "weak", "unique"]), + version: z.union([ + z.null(), + z.literal("unknown"), + z.strictObject({ + index: z.number().int().min(2).max(0x7fff), + name: z.string().min(1).nullable(), + hidden: z.boolean(), + }), + ]), +}); + +/** Parsed table issue; rows it affects are counted, not silently dropped. */ +const nativeExportIssueSchema = z.strictObject({ + code: z.enum([ + "malformed_entry", + "name_unreadable", + "version_unresolved", + "table_unavailable", + ]), + location: z.string().min(1).nullable(), + message: z.string().min(1), +}); + +const artifactSchema = z.strictObject({ + path: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + bytes: uint, +}); + +const coverageSchema = z.strictObject({ + /** complete: every table entry was read; partial: issues name the gaps. */ + status: z.enum(["complete", "partial", "unsupported"]), + reason: z.string().min(1).nullable(), + examined_entries: uint, + exports: uint, +}); + +/** Static export inventory of one PE or ELF image, read without execution. */ +export const nativeExportsSchema = z.discriminatedUnion("format", [ + z.strictObject({ + artifact: artifactSchema, + format: z.literal("pe"), + architecture: z.string().min(1), + bits: z.union([z.literal(32), z.literal(64)]), + /** Null when the image declares no export directory. */ + export_directory: z + .strictObject({ + rva: uint, + size: uint, + dll_name: z.string().nullable(), + ordinal_base: uint, + timestamp: uint, + }) + .nullable(), + exports: z.array(peExportSchema).max(MAX_NATIVE_EXPORT_ROWS), + coverage: coverageSchema, + issues: z.array(nativeExportIssueSchema), + limitations: z.array(z.string().min(1)), + }), + z.strictObject({ + artifact: artifactSchema, + format: z.literal("elf"), + architecture: z.string().min(1), + bits: z.union([z.literal(32), z.literal(64)]), + byte_order: z.enum(["little", "big"]), + exports: z.array(elfExportSchema).max(MAX_NATIVE_EXPORT_ROWS), + coverage: coverageSchema, + issues: z.array(nativeExportIssueSchema), + limitations: z.array(z.string().min(1)), + }), +]); +export type NativeExports = z.infer; +export type PeExport = z.infer; +export type ElfExport = z.infer; +export type NativeExportIssue = z.infer; diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts new file mode 100644 index 0000000000..32b934a92b --- /dev/null +++ b/src/native/exports/NativeExportParser.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from "vitest"; + +import { + elfExportImage, + peExportImage, +} from "../../../tests/fixtures/binaryDiagnostics/nativeExports.js"; +import { nativeExportsSchema } from "../../domain/native/nativeExports.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { parseNativeExports } from "./NativeExportParser.js"; + +const artifact = { path: "/fixture/lib", sha256: "a".repeat(64), bytes: 1 }; +const parse = (bytes: Buffer) => + nativeExportsSchema.parse(parseNativeExports(bytes, artifact)); + +describe("PE export directories", () => { + it.each([32, 64] as const)( + "reports named, aliased, ordinal-only and forwarded PE%d exports", + (bits) => { + const result = parse( + peExportImage({ + bits, + ordinalBase: 5, + functions: [ + { rva: 0x2000 }, + { rva: 0x2010 }, + null, + { forwarder: "OTHER.Target" }, + ], + names: [ + { name: "Alias", index: 0 }, + { name: "Forwarded", index: 3 }, + { name: "Primary", index: 0 }, + ], + }), + ); + const base = bits === 64 ? 0x180000000 : 0x10000000; + + expect(result).toMatchObject({ + format: "pe", + bits, + export_directory: { + rva: 0x1000, + size: 0x200, + dll_name: "fixture.dll", + ordinal_base: 5, + }, + coverage: { + status: "complete", + examined_entries: 4, + exports: 4, + }, + issues: [], + }); + expect(result.exports).toEqual([ + { + name: "Alias", + ordinal: 5, + address: `0x${(base + 0x2000).toString(16)}`, + forwarder: null, + }, + { + name: "Primary", + ordinal: 5, + address: `0x${(base + 0x2000).toString(16)}`, + forwarder: null, + }, + { + name: null, + ordinal: 6, + address: `0x${(base + 0x2010).toString(16)}`, + forwarder: null, + }, + { + name: "Forwarded", + ordinal: 8, + address: null, + forwarder: "OTHER.Target", + }, + ]); + }, + ); + + it("counts unreadable and out-of-range names without inventing exports", () => { + const result = parse( + peExportImage({ + functions: [{ rva: 0x2000 }, { rva: 0x2010 }], + names: [ + { name: "Unmapped", index: 0 }, + { name: "Stray", index: 9 }, + { name: "Kept", index: 1 }, + ], + nameRvaOverride: { position: 0, rva: 0x9000 }, + }), + ); + + expect(result.exports.map(({ name }) => name)).toEqual(["Kept"]); + expect(result.coverage).toMatchObject({ status: "partial", exports: 1 }); + expect(result.issues.map(({ code }) => code)).toEqual([ + "name_unreadable", + "malformed_entry", + ]); + }); + + it("reports an image without an export directory as exporting nothing", () => { + const result = parse(peExportImage({ exportDirectory: false })); + + expect(result).toMatchObject({ + export_directory: null, + exports: [], + coverage: { status: "complete", examined_entries: 0 }, + }); + expect(result.limitations[0]).toContain("declares no export directory"); + }); + + it("refuses ordinals beyond the 16-bit ordinal space", () => { + expect(() => + parseNativeExports( + peExportImage({ + functions: [{ rva: 0x2000 }], + ordinalBase: 0xffff + 1, + }), + artifact, + ), + ).toThrow("16-bit ordinals"); + }); + + it("refuses an export table above the inventory allowance", () => { + expect(() => + parseNativeExports( + peExportImage({ functions: [], declaredFunctions: 0x40001 }), + artifact, + ), + ).toThrow(BinaryResourceFailure); + }); +}); + +describe("ELF dynamic symbol exports", () => { + const symbols = [ + { name: "plain_fn" }, + { name: "versioned_fn", version: 2 }, + { name: "old_fn", version: 0x8003 }, + { name: "weak_obj", binding: 2, type: 1 }, + { name: "ifunc_sym", type: 10 }, + { name: "protected_fn", visibility: 3 }, + { name: "hidden_fn", visibility: 2 }, + { name: "undefined_fn", section: 0 }, + { name: "local_fn", binding: 0 }, + { name: "local_version_fn", version: 0 }, + ]; + + it("lists defined default and protected globals with their versions", () => { + const result = parse( + elfExportImage({ + symbols, + versionDefinitions: ["libfixture.so", "VER_1", "VER_0"], + }), + ); + + expect(result).toMatchObject({ + format: "elf", + bits: 64, + byte_order: "little", + architecture: "amd64", + coverage: { status: "complete", examined_entries: 10, exports: 6 }, + issues: [], + }); + expect( + result.format === "elf" && + result.exports.map(({ name, type, binding, version }) => ({ + name, + type, + binding, + version, + })), + ).toEqual([ + { name: "plain_fn", type: "function", binding: "global", version: null }, + { + name: "versioned_fn", + type: "function", + binding: "global", + version: { index: 2, name: "VER_1", hidden: false }, + }, + { + name: "old_fn", + type: "function", + binding: "global", + version: { index: 3, name: "VER_0", hidden: true }, + }, + { name: "weak_obj", type: "object", binding: "weak", version: null }, + { name: "ifunc_sym", type: "ifunc", binding: "global", version: null }, + { + name: "protected_fn", + type: "function", + binding: "global", + version: null, + }, + ]); + }); + + it("reads a big-endian ELF32 table", () => { + const result = parse( + elfExportImage({ + bits: 32, + little: false, + machine: 20, + symbols: [{ name: "be_fn", value: 0x10000n, size: 8n }], + }), + ); + + expect(result).toMatchObject({ + bits: 32, + byte_order: "big", + architecture: "ppc", + exports: [{ name: "be_fn", value: "0x10000", size: "8" }], + }); + }); + + it("marks versions unknown when the version table does not fit", () => { + const result = parse( + elfExportImage({ + symbols: [{ name: "a" }, { name: "b", version: 2 }], + versionDefinitions: ["lib.so", "V1"], + truncatedVersions: true, + }), + ); + + expect( + result.format === "elf" && result.exports.map(({ version }) => version), + ).toEqual(["unknown", "unknown"]); + expect(result.coverage.status).toBe("partial"); + expect(result.issues).toMatchObject([{ code: "version_unresolved" }]); + }); + + it("reports one issue per undefined version index", () => { + const result = parse( + elfExportImage({ + symbols: [ + { name: "a", version: 7 }, + { name: "b", version: 7 }, + ], + versionDefinitions: ["lib.so"], + }), + ); + + expect( + result.format === "elf" && result.exports.map(({ version }) => version), + ).toEqual([ + { index: 7, name: null, hidden: false }, + { index: 7, name: null, hidden: false }, + ]); + expect(result.issues).toHaveLength(1); + }); + + it("counts a symbol whose name is outside the string table", () => { + const result = parse( + elfExportImage({ + symbols: [{ name: "kept" }, { name: "lost", nameOffset: 0x7000 }], + }), + ); + + expect(result.exports.map(({ name }) => name)).toEqual(["kept"]); + expect(result.issues).toMatchObject([{ code: "name_unreadable" }]); + expect(result.coverage.status).toBe("partial"); + }); + + it("separates no dynamic symbols from unread section headers", () => { + expect( + parse(elfExportImage({ symbols: [], dynamicSymbols: false })), + ).toMatchObject({ exports: [], coverage: { status: "complete" } }); + expect( + parse(elfExportImage({ symbols: [], sectionHeaders: false })), + ).toMatchObject({ + exports: [], + coverage: { + status: "unsupported", + reason: expect.stringContaining("no section headers"), + }, + }); + }); +}); + +describe("export inventory review regressions", () => { + it("reads an export directory stored in the PE headers", () => { + const result = parse( + peExportImage({ + inHeaders: true, + functions: [{ rva: 0x2000 }, { forwarder: "OTHER.Target" }], + names: [ + { name: "HeaderExport", index: 0 }, + { name: "HeaderForward", index: 1 }, + ], + }), + ); + + expect(result).toMatchObject({ + export_directory: { rva: 0x200, dll_name: "fixture.dll" }, + coverage: { status: "complete", exports: 2 }, + }); + expect(result.exports.map(({ name }) => name)).toEqual([ + "HeaderExport", + "HeaderForward", + ]); + }); + + it("bounds retained name text before storing rows", () => { + // 300 rows naming one 60000-byte string retain about 18 MB of text. + const long = "n".repeat(60000); + const bytes = elfExportImage({ + symbols: Array.from({ length: 300 }, () => ({ name: long })), + }); + + expect(bytes.length).toBeLessThan(100_000); + expect(() => parseNativeExports(bytes, artifact)).toThrow( + BinaryResourceFailure, + ); + }); + + it("keeps exports when the version table lies outside the file", () => { + const result = parse( + elfExportImage({ + symbols: [{ name: "a" }, { name: "b", version: 2 }], + versionDefinitions: ["lib.so", "V1"], + versionsOutsideFile: true, + }), + ); + + expect( + result.format === "elf" && result.exports.map(({ version }) => version), + ).toEqual(["unknown", "unknown"]); + expect(result.coverage.status).toBe("partial"); + }); + + it("reports an empty version name as unresolved", () => { + const result = parse( + elfExportImage({ + symbols: [{ name: "a", version: 2 }], + versionDefinitions: ["lib.so", ""], + }), + ); + + expect( + result.format === "elf" && result.exports.map(({ version }) => version), + ).toEqual([{ index: 2, name: null, hidden: false }]); + expect(result.issues).toMatchObject([{ code: "version_unresolved" }]); + expect(result.coverage.status).toBe("partial"); + }); + + it("rejects a dynamic symbol table without its null entry", () => { + expect(() => + parseNativeExports( + elfExportImage({ symbols: [], emptyDynamicSymbols: true }), + artifact, + ), + ).toThrow(BinaryFormatFailure); + }); +}); + +it("refuses formats other than PE and ELF", () => { + const macho = Buffer.alloc(64); + macho.writeUInt32LE(0xfeedfacf, 0); + expect(() => parseNativeExports(macho, artifact)).toThrow( + BinaryFormatFailure, + ); +}); diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts new file mode 100644 index 0000000000..a6bbede256 --- /dev/null +++ b/src/native/exports/NativeExportParser.ts @@ -0,0 +1,502 @@ +import { + BinaryFormatFailure, + BinaryReader, + BinaryResourceFailure, + mappedFileOffset, +} from "../metadata/BinaryContainer.js"; +import { architectureForElfMachine } from "../metadata/ElfImage.js"; +import { readElfString, readElfSymbol } from "../metadata/ElfSymbol.js"; +import { + readNativeDirectoryFromBuffer, + type NativeSection, +} from "../metadata/NativeSectionDirectory.js"; +import { readPeImage } from "../metadata/PeImage.js"; +import { + MAX_NATIVE_EXPORT_ROWS, + type ElfExport, + type NativeExportIssue, + type NativeExports, + type PeExport, +} from "../../domain/native/nativeExports.js"; + +type Artifact = NativeExports["artifact"]; + +const SHT_STRTAB = 3; +const SHT_DYNSYM = 11; +const SHT_GNU_VERDEF = 0x6ffffffd; +const SHT_GNU_VERSYM = 0x6fffffff; +const PE_EXPORT_DIRECTORY = 0; +const PE_EXPORT_DIRECTORY_BYTES = 40; +const MAXIMUM_NAME_BYTES = 4096; +/** Text retained across all rows; names can repeat one long string table entry. */ +const MAXIMUM_RETAINED_TEXT_BYTES = 16 * 1024 * 1024; + +/** Charge every retained row and its text before it is stored. */ +const createRetention = () => { + let rows = 0; + let text = 0; + return (...strings: readonly (string | null)[]): void => { + rows++; + rowBudget(rows, "Export inventory"); + for (const value of strings) text += value?.length ?? 0; + if (text > MAXIMUM_RETAINED_TEXT_BYTES) + throw new BinaryResourceFailure( + "structure", + MAXIMUM_RETAINED_TEXT_BYTES, + `Export names exceed the ${String(MAXIMUM_RETAINED_TEXT_BYTES)}-byte retained-text allowance`, + ); + }; +}; + +const hex = (value: bigint | number) => `0x${value.toString(16)}`; + +const rowBudget = (count: number, label: string): void => { + if (count > MAX_NATIVE_EXPORT_ROWS) + throw new BinaryResourceFailure( + "structure", + MAX_NATIVE_EXPORT_ROWS, + `${label} declares ${String(count)} entries, above the ${String(MAX_NATIVE_EXPORT_ROWS)}-entry export inventory allowance`, + ); +}; + +const coverage = ( + issues: readonly NativeExportIssue[], + examined: number, + exports: number, +): NativeExports["coverage"] => + issues.length === 0 + ? { status: "complete", reason: null, examined_entries: examined, exports } + : { + status: "partial", + reason: `${String(issues.length)} export table issue(s) left entries unreported; see issues`, + examined_entries: examined, + exports, + }; + +const failureMessage = (cause: unknown): string => { + if (cause instanceof BinaryFormatFailure) return cause.message; + throw cause; +}; + +const PE_LIMITATIONS = [ + "Rows reproduce the PE export directory; they do not establish that an export is reachable, safe to call or loaded at runtime.", + "Forwarded exports resolve in another DLL, which is not opened; ordinal-only exports have no name in this image.", +]; + +/** Read the PE export directory: every used address-table slot and its names. */ +const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => { + const image = readPeImage(bytes); + const base = image.image_base ?? 0n; + const directory = image.pe_data_directories?.[PE_EXPORT_DIRECTORY]; + const result = { + artifact, + format: "pe" as const, + architecture: image.architecture, + bits: image.bits, + }; + if (directory === undefined || directory.virtual_address === 0) + return { + ...result, + export_directory: null, + exports: [], + coverage: coverage([], 0, 0), + issues: [], + limitations: [ + "The image declares no export directory, so it exports nothing by name or ordinal.", + ...PE_LIMITATIONS, + ], + }; + const reader = new BinaryReader(bytes, true); + const mappings = + image.pe_header_mapping === undefined + ? image.mappings + : [image.pe_header_mapping, ...image.mappings]; + const map = (rva: number, size: number): number => + mappedFileOffset(mappings, base + BigInt(rva), size, image.zero_fills); + const cString = (rva: number): string => { + const start = map(rva, 1); + const end = bytes + .subarray(start, start + MAXIMUM_NAME_BYTES + 1) + .indexOf(0); + if (end <= 0) + throw new BinaryFormatFailure( + "malformed", + `Export name at RVA ${hex(rva)} is empty, unterminated or longer than ${String(MAXIMUM_NAME_BYTES)} bytes`, + ); + // The whole string must stay inside one file-backed mapping. + map(rva, end + 1); + return bytes.toString("latin1", start, start + end); + }; + + const header = map(directory.virtual_address, PE_EXPORT_DIRECTORY_BYTES); + const ordinalBase = reader.u32(header + 16); + const functionCount = reader.u32(header + 20); + const nameCount = reader.u32(header + 24); + rowBudget(functionCount, "PE export address table"); + if (ordinalBase + functionCount > 0x10000) + throw new BinaryFormatFailure( + "malformed", + "PE export ordinal base and address-table size exceed 16-bit ordinals", + ); + rowBudget(nameCount, "PE export name table"); + const functions = + functionCount === 0 ? 0 : map(reader.u32(header + 28), functionCount * 4); + const names = + nameCount === 0 ? 0 : map(reader.u32(header + 32), nameCount * 4); + const ordinals = + nameCount === 0 ? 0 : map(reader.u32(header + 36), nameCount * 2); + const issues: NativeExportIssue[] = []; + let dllName: string | null = null; + try { + dllName = cString(reader.u32(header + 12)); + } catch (cause) { + issues.push({ + code: "name_unreadable", + location: hex(reader.u32(header + 12)), + message: `Export directory DLL name: ${failureMessage(cause)}`, + }); + } + + const namesByFunction = new Map(); + const namedFunctions = new Set(); + for (let index = 0; index < nameCount; index++) { + const functionIndex = reader.u16(ordinals + index * 2); + const nameRva = reader.u32(names + index * 4); + if (functionIndex >= functionCount) { + issues.push({ + code: "malformed_entry", + location: hex(nameRva), + message: `Export name ${String(index)} selects address-table slot ${String(functionIndex)}, outside its ${String(functionCount)} slots`, + }); + continue; + } + namedFunctions.add(functionIndex); + try { + const list = namesByFunction.get(functionIndex) ?? []; + list.push(cString(nameRva)); + namesByFunction.set(functionIndex, list); + } catch (cause) { + issues.push({ + code: "name_unreadable", + location: hex(nameRva), + message: failureMessage(cause), + }); + } + } + + const forwarderEnd = directory.virtual_address + directory.size; + const retain = createRetention(); + const exports: PeExport[] = []; + for (let index = 0; index < functionCount; index++) { + const rva = reader.u32(functions + index * 4); + // A zero slot is an unused ordinal, not an export. + if (rva === 0) continue; + const ordinal = ordinalBase + index; + let target: Pick; + if (rva >= directory.virtual_address && rva < forwarderEnd) { + try { + target = { address: null, forwarder: cString(rva) }; + } catch (cause) { + issues.push({ + code: "malformed_entry", + location: hex(rva), + message: `Forwarder of ordinal ${String(ordinal)}: ${failureMessage(cause)}`, + }); + continue; + } + } else target = { address: hex(base + BigInt(rva)), forwarder: null }; + const exportNames = namesByFunction.get(index); + if (exportNames !== undefined) + for (const name of exportNames) { + retain(name, target.forwarder); + exports.push({ name, ordinal, ...target }); + } + // A slot whose only name was unreadable is reported through its issue. + else if (!namedFunctions.has(index)) { + retain(target.forwarder); + exports.push({ name: null, ordinal, ...target }); + } + } + return { + ...result, + export_directory: { + rva: directory.virtual_address, + size: directory.size, + dll_name: dllName, + ordinal_base: ordinalBase, + timestamp: reader.u32(header + 4), + }, + exports, + coverage: coverage(issues, functionCount, exports.length), + issues, + limitations: PE_LIMITATIONS, + }; +}; + +const ELF_LIMITATIONS = [ + "Rows list defined default- or protected-visibility global, weak and unique dynamic symbols; local, hidden, internal and undefined symbols are not exports.", + "A hidden symbol version is not the default version that a by-name dlsym lookup selects.", + "Rows reproduce the dynamic symbol table; they do not establish that a symbol is reachable, safe to call or loaded at runtime.", +]; + +const ELF_TYPES: Readonly> = { + 1: "object", + 2: "function", + 6: "tls", + 10: "ifunc", +}; +const ELF_BINDINGS: Readonly> = { + 1: "global", + 2: "weak", + 10: "unique", +}; + +/** Version definitions by index, read from `.gnu.version_d`. */ +const readVersionDefinitions = ( + reader: BinaryReader, + sections: readonly NativeSection[], + issues: NativeExportIssue[], +): Map => { + const definitions = new Map(); + const verdef = sections.find(({ type }) => type === SHT_GNU_VERDEF); + if (verdef === undefined) return definitions; + try { + const strings = sections[verdef.link]; + if (strings?.type !== SHT_STRTAB) + throw new BinaryFormatFailure( + "malformed", + "Version definitions do not link to a string table", + ); + const names = reader.fileRange( + strings.offset, + strings.size, + "Version strings", + ); + const nameBytes = reader.bytes.subarray( + names.offset, + names.offset + names.size, + ); + const table = reader.fileRange( + verdef.offset, + verdef.size, + "Version definitions", + ); + let offset = 0; + for (let index = 0; index < verdef.info; index++) { + if (offset + 20 > table.size) + throw new BinaryFormatFailure( + "malformed", + "Version definition chain leaves its section", + ); + const at = table.offset + offset; + const versionIndex = reader.u16(at + 4) & 0x7fff; + const aux = reader.u32(at + 12); + const next = reader.u32(at + 16); + if (aux < 20 || offset + aux + 8 > table.size) + throw new BinaryFormatFailure( + "malformed", + "Version definition name entry leaves its section", + ); + definitions.set( + versionIndex, + readElfString(nameBytes, reader.u32(at + aux)), + ); + if (next === 0) break; + offset += next; + } + } catch (cause) { + issues.push({ + code: "version_unresolved", + location: hex(verdef.offset), + message: `Version definitions: ${failureMessage(cause)}`, + }); + } + return definitions; +}; + +/** Read defined exported entries of `.dynsym` with their symbol versions. */ +const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { + const directory = readNativeDirectoryFromBuffer(bytes, "elf"); + const reader = new BinaryReader(bytes, directory.little); + const result = { + artifact, + format: "elf" as const, + architecture: architectureForElfMachine(directory.machine), + bits: directory.bits, + byte_order: directory.little ? ("little" as const) : ("big" as const), + }; + const { sections } = directory; + if (sections.length === 0) + return { + ...result, + exports: [], + coverage: { + status: "unsupported", + reason: + "The ELF has no section headers; enumerating dynamic symbols from the dynamic segment is not implemented", + examined_entries: 0, + exports: 0, + }, + issues: [], + limitations: ELF_LIMITATIONS, + }; + const dynsym = sections.find(({ type }) => type === SHT_DYNSYM); + if (dynsym === undefined) + return { + ...result, + exports: [], + coverage: coverage([], 0, 0), + issues: [], + limitations: [ + "The ELF has no dynamic symbol table, so it exports no dynamic symbols.", + ...ELF_LIMITATIONS, + ], + }; + const width = directory.bits === 64 ? 24 : 16; + const strings = sections[dynsym.link]; + if ( + dynsym.entry_size !== BigInt(width) || + dynsym.size % BigInt(width) !== 0n || + // The table must hold at least its mandatory null symbol. + dynsym.size < BigInt(width) || + strings?.type !== SHT_STRTAB + ) + throw new BinaryFormatFailure( + "malformed", + "ELF dynamic symbol table entry size, length, null entry or string-table link is invalid", + ); + const count = Number(dynsym.size / BigInt(width)); + rowBudget(count, "ELF dynamic symbol table"); + const table = reader.table( + dynsym.offset, + count, + width, + "ELF dynamic symbols", + ); + const nameRange = reader.fileRange( + strings.offset, + strings.size, + "ELF dynamic strings", + ); + const names = bytes.subarray( + nameRange.offset, + nameRange.offset + nameRange.size, + ); + const issues: NativeExportIssue[] = []; + const versym = sections.find( + ({ type, link }) => type === SHT_GNU_VERSYM && link === dynsym.index, + ); + let versions: number | null = null; + if (versym !== undefined) { + try { + if (versym.size !== BigInt(count * 2)) + throw new BinaryFormatFailure( + "malformed", + `Symbol version table has ${String(versym.size)} bytes for ${String(count)} symbols`, + ); + versions = reader.table(versym.offset, count, 2, "ELF symbol versions"); + } catch (cause) { + // Keep every export; their versions become unknown. + issues.push({ + code: "version_unresolved", + location: hex(versym.offset), + message: failureMessage(cause), + }); + } + } + const definitions = readVersionDefinitions(reader, sections, issues); + const unresolvedVersions = new Set(); + const retain = createRetention(); + const exports: ElfExport[] = []; + for (let index = 1; index < count; index++) { + const at = table + index * width; + const symbol = readElfSymbol( + bytes.subarray(at, at + width), + directory.bits, + directory.little, + ); + const binding = ELF_BINDINGS[symbol.binding]; + const visibility = symbol.other & 3; + // Undefined, local and hidden/internal symbols are not exports. + if ( + binding === undefined || + symbol.section_index === 0 || + (visibility !== 0 && visibility !== 3) + ) + continue; + let version: ElfExport["version"] = null; + if (versym !== undefined && versions === null) version = "unknown"; + else if (versions !== null) { + const raw = reader.u16(versions + index * 2); + const versionIndex = raw & 0x7fff; + if (versionIndex === 0) continue; + if (versionIndex >= 2) { + const defined = definitions.get(versionIndex); + const name = defined === undefined || defined === "" ? null : defined; + if (name === null && !unresolvedVersions.has(versionIndex)) { + unresolvedVersions.add(versionIndex); + issues.push({ + code: "version_unresolved", + location: hex(symbol.value), + message: `Version index ${String(versionIndex)}, first used by symbol ${String(index)}, has no named version definition`, + }); + } + version = { + index: versionIndex, + name, + hidden: (raw & 0x8000) !== 0, + }; + } + } + let name: string; + try { + name = readElfString(names, symbol.name_offset); + } catch (cause) { + issues.push({ + code: "name_unreadable", + location: hex(symbol.value), + message: `Dynamic symbol ${String(index)}: ${failureMessage(cause)}`, + }); + continue; + } + if (name === "") { + issues.push({ + code: "name_unreadable", + location: hex(symbol.value), + message: `Defined global dynamic symbol ${String(index)} has an empty name`, + }); + continue; + } + retain(name, typeof version === "object" ? (version?.name ?? null) : null); + exports.push({ + name, + value: hex(symbol.value), + size: symbol.size.toString(), + type: ELF_TYPES[symbol.type] ?? "other", + binding, + version, + }); + } + return { + ...result, + exports, + coverage: coverage(issues, count - 1, exports.length), + issues, + limitations: ELF_LIMITATIONS, + }; +}; + +/** Static export inventory of one PE or ELF image; other formats are unsupported. */ +export const parseNativeExports = ( + bytes: Buffer, + artifact: Artifact, +): NativeExports => { + if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) + return parseElfExports(bytes, artifact); + if (bytes.subarray(0, 2).equals(Buffer.from("MZ"))) + return parsePeExports(bytes, artifact); + throw new BinaryFormatFailure( + "unsupported", + "Only PE and ELF images are supported; inspect Mach-O exports with inspect_macho", + ); +}; diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts new file mode 100644 index 0000000000..49291eee81 --- /dev/null +++ b/src/native/exports/NativeExportsProvider.ts @@ -0,0 +1,212 @@ +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + ArtifactFileCleanupFailure, + assertArtifactReadActive, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import type { NativeExportsPort } from "../../application/binaryDiagnostics/NativeExportsPort.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_EXPORTS_FILE_BYTES, + type InspectNativeExportsInput, +} from "../../domain/native/nativeExports.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { err, ok } from "../../domain/result.js"; +import { NATIVE_EXPORTS_PROVIDER } from "../../application/InvestigationProviders.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { parseNativeExports } from "./NativeExportParser.js"; + +const OPERATION = "inspect_native_exports"; + +/** Portable export-table reader over one bounded, identity-checked regular file. */ +export class NativeExportsProvider implements NativeExportsPort { + readonly identity = NATIVE_EXPORTS_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + NATIVE_EXPORTS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect(input: InspectNativeExportsInput, 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 = parseNativeExports(snapshot.bytes, { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }); + assertArtifactReadActive(options?.signal); + return ok(report); + }); + } catch (cause) { + return err(nativeExportsFailure(cause, input, options?.signal)); + } + } +} + +const fileSizeRemediation = (input: InspectNativeExportsInput): string => + input.max_file_bytes >= MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES + ? `The image exceeds the ${String(MAX_INSPECT_NATIVE_EXPORTS_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 nativeExportsFailurePrimary = ( + cause: unknown, + input: InspectNativeExportsInput, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof BinaryResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `Selected artifact ${input.path}: ${cause.message}`, + { boundary: cause.boundary, maximum: cause.maximum_bytes }, + { + cause, + ...fixedAllowanceOptions( + "The native export inventory allowance", + "count", + ), + }, + ); + 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 (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 ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + 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_EXPORTS_PROVIDER.id, OPERATION, { + cause, + diagnostics: { + path: input.path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const nativeExportsFailure = ( + cause: unknown, + input: InspectNativeExportsInput, + signal: AbortSignal | undefined, +): AnalysisError => { + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? nativeExportsFailurePrimary( + cause.fileCleanupFailure.outcome.cause, + input, + ) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError(OPERATION, { cause: signal.reason }) + : nativeExportsFailurePrimary(cause, input); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + NATIVE_EXPORTS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index 6d0766be20..fc063529c9 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -95,6 +95,16 @@ export interface BinaryContainer { readonly byte_order: "little" | "big"; /** Declared PE preferred base, when available. */ readonly image_base?: bigint; + /** + * File-backed PE headers (`SizeOfHeaders`, clipped to the file), which the + * loader maps at the image base. Kept apart from section `mappings`. + */ + readonly pe_header_mapping?: FileMapping; + /** PE optional-header data directories, indexed as declared. */ + readonly pe_data_directories?: readonly { + readonly virtual_address: number; + readonly size: number; + }[]; /** Mach-O load-command observation, when available. */ readonly macho_chained_fixups?: boolean; readonly mappings: readonly FileMapping[]; diff --git a/src/native/metadata/ElfImage.ts b/src/native/metadata/ElfImage.ts index 86a5251c80..1fba9c8277 100644 --- a/src/native/metadata/ElfImage.ts +++ b/src/native/metadata/ElfImage.ts @@ -104,7 +104,7 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => { }; /** Map ELF machine identifiers to stable architecture names. */ -const architectureForElfMachine = (machine: number): string => { +export const architectureForElfMachine = (machine: number): string => { const names = new Map([ [3, "386"], [8, "mips"], diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index b68b1f6eb7..bf36e6954c 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -82,7 +82,16 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { "malformed", "PE data directories extend beyond the optional header", ); + const dataDirectories = Array.from({ length: directories }, (_, index) => { + const entry = optional + directoriesOffset + index * 8; + return { virtual_address: reader.u32(entry), size: reader.u32(entry + 4) }; + }); const imageBase = reader.word(optional + (bits === 64 ? 24 : 28), bits); + const headerMapping: FileMapping = { + address: imageBase, + offset: 0, + size: Math.min(reader.u32(optional + 60), bytes.length), + }; const count = reader.u16(pe + 6); const start = reader.table( BigInt(optional + optionalSize), @@ -152,6 +161,8 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { return { format: "pe", image_base: imageBase, + pe_data_directories: dataDirectories, + pe_header_mapping: headerMapping, architecture: names.get(machine) ?? `pe-machine-${machine}`, bits, byte_order: "little", diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 4d81d82340..5b7feb6d82 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -14,12 +14,15 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createElfNotesService, + createNativeExportsService, createPeResourcesService, } from "../composition/binaryDiagnostics.js"; import type { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; +import type { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { registerElfNotesTool } from "./registerElfNotesTool.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; +import { registerNativeExportsTool } from "./registerNativeExportsTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { evmInterfaceModule, @@ -355,6 +358,7 @@ export const createServer = ( const il2cppMetadata = options.il2cppMetadata ?? createIl2cppMetadataService(); const peResources = createPeResourcesService(); + const nativeExports = createNativeExportsService(); const elfNotes = createElfNotesService(); const electronFuses = options.electronFuses ?? createElectronFuseService(); const webNetworkCapture = @@ -457,6 +461,7 @@ export const createServer = ( modelArtifact.close(), gpuCodeObjects.close(), peResources.close(), + nativeExports.close(), elfNotes.close(), electronFuses.close(), webNetworkCapture.close(), @@ -521,6 +526,7 @@ export const createServer = ( rustBinary, il2cppMetadata, peResources, + nativeExports, elfNotes, electronFuses, webNetworkCapture, @@ -753,6 +759,7 @@ const registerConfiguredAnalysisTools = ( rustBinary, il2cppMetadata, peResources, + nativeExports, elfNotes, webNetworkCapture, artifactResources, @@ -803,6 +810,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerNativeExportsTool( + server, + nativeExports, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerElfNotesTool( server, elfNotes, @@ -943,6 +957,7 @@ interface ServerToolContext extends ReturnType { readonly rustBinary: RustBinaryService; readonly il2cppMetadata: Il2cppMetadataService; readonly peResources: PeResourcesService; + readonly nativeExports: NativeExportsService; readonly elfNotes: ElfNotesService; readonly electronFuses: ElectronFuseService; readonly webNetworkCapture: WebNetworkCaptureService; diff --git a/src/server/registerNativeExportsTool.ts b/src/server/registerNativeExportsTool.ts new file mode 100644 index 0000000000..91e02b4b2a --- /dev/null +++ b/src/server/registerNativeExportsTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.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 { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file export-table workflow and retain its complete Evidence. */ +export const registerNativeExportsTool = ( + server: EvidenceMcpServer, + service: NativeExportsService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_native_exports"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + applicationToolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index f0bb73c6c9..b8d6b5baf5 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -218,6 +218,7 @@ const pathRequests: readonly [string, Record][] = [ { capture_path: nul, output_directory: "/tmp/exported-scripts" }, ], ["inspect_pe_resources", { path: nul }], + ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], ["inspect_binary_layout", { path: nul }], ["inspect_recorded_crash", { path: nul }], diff --git a/tests/boundary/filesystem/nativeExports.test.ts b/tests/boundary/filesystem/nativeExports.test.ts new file mode 100644 index 0000000000..e79f80ac4d --- /dev/null +++ b/tests/boundary/filesystem/nativeExports.test.ts @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createNativeExportsService } from "../../../src/composition/binaryDiagnostics.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { nativeExportsSchema } from "../../../src/domain/native/nativeExports.js"; +import { + elfExportImage, + peExportImage, +} from "../../fixtures/binaryDiagnostics/nativeExports.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("native export stable filesystem boundary", () => { + it("binds PE and ELF export inventories to the selected file", async () => { + const root = await createTestTempDirectory("rea-native-exports-"); + const service = createNativeExportsService(); + for (const [name, bytes, format] of [ + [ + "fixture.dll", + peExportImage({ + functions: [{ rva: 0x2000 }], + names: [{ name: "Exported", index: 0 }], + }), + "pe", + ], + [ + "libfixture.so", + elfExportImage({ symbols: [{ name: "exported" }] }), + "elf", + ], + ] as const) { + const path = join(root, name); + await writeFile(path, bytes); + const result = await service.inspect({ path }); + if (!result.ok) throw new Error(JSON.stringify(result.error)); + const evidence = parseEvidence(result.value); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + + expect(evidence).toMatchObject({ + operation: "inspect_native_exports", + provider: { id: "rea-native-exports" }, + confidence: "observed", + subject: { local_path: path, format, digest: { sha256 } }, + }); + expect( + nativeExportsSchema.parse(evidence.normalized_result), + ).toMatchObject({ + artifact: { path, sha256, bytes: bytes.length }, + format, + coverage: { status: "complete", exports: 1 }, + }); + } + await service.close(); + }); + + it("returns typed input, unsupported, budget and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-native-export-failures-"); + const path = join(root, "fixture.dll"); + const alias = join(root, "alias.dll"); + const macho = join(root, "fixture.dylib"); + const malformed = join(root, "malformed.dll"); + await writeFile(path, peExportImage({ functions: [] })); + const machoBytes = Buffer.alloc(64); + machoBytes.writeUInt32LE(0xfeedfacf, 0); + await writeFile(macho, machoBytes); + await writeFile(malformed, Buffer.from("MZ not a PE image")); + await symlink(path, alias); + const service = createNativeExportsService(); + const failure = async ( + input: Record, + signal?: AbortSignal, + ) => { + const result = await service.inspect( + input, + signal === undefined ? undefined : { signal }, + ); + if (result.ok) throw new Error("Expected a failure"); + return result.error; + }; + + for (const [candidate, reason] of [ + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing.dll"), "invalid_value"], + ] as const) + expect(await failure({ path: candidate })).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_native_exports", + issues: [{ path: ["path"], reason }], + }); + expect(await failure({ path: macho })).toMatchObject({ + _tag: "AnalysisUnsupportedTargetError", + reason: expect.stringContaining("inspect_macho"), + }); + expect(await failure({ path, max_file_bytes: 64 })).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + }); + expect( + await failure({ path }, AbortSignal.abort("caller stop")), + ).toMatchObject({ _tag: "AnalysisCancelledError" }); + await service.close(); + }); +}); diff --git a/tests/boundary/mcp/absoluteLocalPathSchema.test.ts b/tests/boundary/mcp/absoluteLocalPathSchema.test.ts index 536f2c3f2e..d0a1489886 100644 --- a/tests/boundary/mcp/absoluteLocalPathSchema.test.ts +++ b/tests/boundary/mcp/absoluteLocalPathSchema.test.ts @@ -78,6 +78,7 @@ const standaloneAnalysisCases = ( { name: "inspect_evm_interface", input: { encoding: "hex" } }, { name: "inspect_sqlite_database", input: {} }, { name: "inspect_pe_resources", input: {} }, + { name: "inspect_native_exports", input: {} }, { name: "inspect_electron_fuses", input: {} }, { name: "inspect_packet_capture", input: { fields: ["ip.addr"] } }, { diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index 1131b45b05..9c83d87d0d 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -20,6 +20,8 @@ import { binaryLayoutFixture, BINARY_LAYOUT_TEST_PROVIDER, } from "../../fixtures/binaryDiagnostics/layout.js"; +import { elfExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; +import { parseNativeExports } from "../../../src/native/exports/NativeExportParser.js"; it("advertises exact valid schemas and records inline layout without a binary target", async () => { const value = binaryLayoutFixture(); @@ -181,3 +183,50 @@ it("profiles real bytes with exact offsets, valid advertised schemas and read-on ).isError, ).toBe(true); }); + +it("records a portable export inventory through MCP without a binary target", async () => { + const root = await createTestTempDirectory("rea-native-exports-mcp-"); + const path = join(root, "libfixture.so"); + const bytes = elfExportImage({ + symbols: [{ name: "exported_fn" }, { name: "hidden_fn", visibility: 2 }], + }); + await writeFile(path, bytes); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "native-exports-contract", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_native_exports", + ); + expect(schemas.ajv.validate(schemas.inputSchema, { path })).toBe(true); + expect( + schemas.ajv.validate(schemas.inputSchema, { path: "relative.so" }), + ).toBe(false); + const response = await client.callTool({ + name: "inspect_native_exports", + arguments: { path }, + }); + assertRecordedMcpEvidence( + response, + "inspect_native_exports", + schemas, + session, + parseNativeExports(bytes, { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }), + "observed", + ); +}); diff --git a/tests/fixtures/advertisedMcpTool.ts b/tests/fixtures/advertisedMcpTool.ts index 55521941de..e10e603e40 100644 --- a/tests/fixtures/advertisedMcpTool.ts +++ b/tests/fixtures/advertisedMcpTool.ts @@ -35,7 +35,8 @@ export const assertRecordedMcpEvidence = ( name: | "inspect_wasm_artifact" | "inspect_binary_layout" - | "inspect_evm_interface", + | "inspect_evm_interface" + | "inspect_native_exports", schemas: Awaited>, session: BinarySession, expected: { readonly artifact: { readonly sha256: string } }, diff --git a/tests/fixtures/binaryDiagnostics/nativeExports.ts b/tests/fixtures/binaryDiagnostics/nativeExports.ts new file mode 100644 index 0000000000..7bada8dc7f --- /dev/null +++ b/tests/fixtures/binaryDiagnostics/nativeExports.ts @@ -0,0 +1,336 @@ +/** One PE export-address-table slot: code RVA, forwarder text, or unused. */ +export type PeFixtureFunction = + | { readonly rva: number } + | { readonly forwarder: string } + | null; + +const SECTION_RVA = 0x1000; +const SECTION_OFFSET = 0x400; +const SECTION_BYTES = 0x400; +/** SizeOfHeaders; header RVAs map to the same file offsets. */ +const HEADER_BYTES = 0x400; + +/** + * Minimal PE32/PE32+ image whose only section holds an export directory at + * RVA 0x1000. Name entries point at `functions` by index. + */ +export const peExportImage = (options: { + readonly bits?: 32 | 64; + readonly ordinalBase?: number; + readonly functions?: readonly PeFixtureFunction[]; + readonly names?: readonly { readonly name: string; readonly index: number }[]; + /** Replace one name's RVA, e.g. with an unmapped one. */ + readonly nameRvaOverride?: { + readonly position: number; + readonly rva: number; + }; + readonly exportDirectory?: boolean; + readonly declaredFunctions?: number; + /** Store the export directory and its tables in the PE headers. */ + readonly inHeaders?: boolean; +}): Buffer => { + const bits = options.bits ?? 64; + const bytes = Buffer.alloc(SECTION_OFFSET + SECTION_BYTES); + const wide = bits === 64; + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt32LE(128, 60); + bytes.write("PE\0\0", 128, "ascii"); + bytes.writeUInt16LE(wide ? 0x8664 : 0x14c, 132); + bytes.writeUInt16LE(1, 134); + const directoriesAt = wide ? 112 : 96; + const optionalSize = directoriesAt + 16 * 8; + bytes.writeUInt16LE(optionalSize, 148); + const optional = 152; + bytes.writeUInt16LE(wide ? 0x20b : 0x10b, optional); + if (wide) bytes.writeBigUInt64LE(0x180000000n, optional + 24); + else bytes.writeUInt32LE(0x10000000, optional + 28); + bytes.writeUInt32LE(16, optional + directoriesAt - 4); + bytes.writeUInt32LE(HEADER_BYTES, optional + 60); + const section = optional + optionalSize; + bytes.write(".edata", section, "ascii"); + bytes.writeUInt32LE(SECTION_BYTES, section + 8); + bytes.writeUInt32LE(SECTION_RVA, section + 12); + bytes.writeUInt32LE(SECTION_BYTES, section + 16); + bytes.writeUInt32LE(SECTION_OFFSET, section + 20); + bytes.writeUInt32LE(0x40000040, section + 36); + if (options.exportDirectory === false) return bytes; + + const at = (rva: number) => + rva < HEADER_BYTES ? rva : rva - SECTION_RVA + SECTION_OFFSET; + const base = options.inHeaders === true ? 0x200 : SECTION_RVA; + const functions = options.functions ?? []; + const names = options.names ?? []; + // Directory, address table, name table, ordinals, then strings; forwarders + // stay inside the 0x200-byte declared directory range. + const eat = base + 0x40; + const nameTable = base + 0x80; + const ordinalTable = base + 0xc0; + let forwarderText = base + 0x100; + let text = base + (options.inHeaders === true ? 0x180 : 0x200); + const string = (value: string, inside: boolean): number => { + const rva = inside ? forwarderText : text; + bytes.write(`${value}\0`, at(rva), "latin1"); + if (inside) forwarderText += value.length + 1; + else text += value.length + 1; + return rva; + }; + bytes.writeUInt32LE(base, optional + directoriesAt); + bytes.writeUInt32LE(0x200, optional + directoriesAt + 4); + const directory = at(base); + bytes.writeUInt32LE(0x5f5e0ff, directory + 4); + bytes.writeUInt32LE(string("fixture.dll", false), directory + 12); + bytes.writeUInt32LE(options.ordinalBase ?? 1, directory + 16); + bytes.writeUInt32LE( + options.declaredFunctions ?? functions.length, + directory + 20, + ); + bytes.writeUInt32LE(names.length, directory + 24); + bytes.writeUInt32LE(eat, directory + 28); + bytes.writeUInt32LE(nameTable, directory + 32); + bytes.writeUInt32LE(ordinalTable, directory + 36); + functions.forEach((entry, index) => { + const value = + entry === null + ? 0 + : "rva" in entry + ? entry.rva + : string(entry.forwarder, true); + bytes.writeUInt32LE(value, at(eat) + index * 4); + }); + names.forEach(({ name, index }, position) => { + const rva = + options.nameRvaOverride?.position === position + ? options.nameRvaOverride.rva + : string(name, false); + bytes.writeUInt32LE(rva, at(nameTable) + position * 4); + bytes.writeUInt16LE(index, at(ordinalTable) + position * 2); + }); + return bytes; +}; + +/** One `.dynsym` entry; `version` is its raw `.gnu.version` half-word. */ +export interface ElfFixtureSymbol { + readonly name: string; + readonly binding?: number; + readonly type?: number; + readonly visibility?: number; + readonly section?: number; + readonly value?: bigint; + readonly size?: bigint; + readonly version?: number; + /** Replace the symbol's string-table offset. */ + readonly nameOffset?: number; +} + +/** + * Minimal ELF shared object with `.dynsym`, `.dynstr`, `.gnu.version` and + * `.gnu.version_d`, laid out in order. Version definitions are indexed from 1. + */ +export const elfExportImage = (options: { + readonly bits?: 32 | 64; + readonly little?: boolean; + readonly machine?: number; + readonly symbols: readonly ElfFixtureSymbol[]; + readonly versionDefinitions?: readonly string[]; + /** Declare a `.gnu.version` table that is one entry short. */ + readonly truncatedVersions?: boolean; + /** Point `.gnu.version` past the end of the file. */ + readonly versionsOutsideFile?: boolean; + /** Declare a zero-byte `.dynsym`, without its null entry. */ + readonly emptyDynamicSymbols?: boolean; + readonly sectionHeaders?: boolean; + readonly dynamicSymbols?: boolean; +}): Buffer => { + const bits = options.bits ?? 64; + const little = options.little ?? true; + const wide = bits === 64; + const symbolWidth = wide ? 24 : 16; + const sectionWidth = wide ? 64 : 40; + const symbols = [null, ...options.symbols]; + const definitions = options.versionDefinitions ?? []; + + const dynstr: string[] = ["\0"]; + let dynstrBytes = 1; + const strings = new Map(); + const intern = (value: string): number => { + const known = strings.get(value); + if (known !== undefined) return known; + const offset = dynstrBytes; + strings.set(value, offset); + dynstr.push(`${value}\0`); + dynstrBytes += value.length + 1; + return offset; + }; + const definitionNames = definitions.map(intern); + const symbolNames = symbols.map((symbol) => + symbol === null ? 0 : (symbol.nameOffset ?? intern(symbol.name)), + ); + const shstrtab = + "\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_d\0.shstrtab\0"; + let cursor = 0x100; + const place = (size: number): number => { + const offset = cursor; + cursor = Math.ceil((cursor + size) / 16) * 16; + return offset; + }; + const dynsymAt = place(symbols.length * symbolWidth); + const dynstrAt = place(dynstrBytes); + const versymAt = place(symbols.length * 2); + const verdefAt = place(definitions.length * 28); + const shstrtabAt = place(shstrtab.length); + const sectionAt = place(6 * sectionWidth); + const bytes = Buffer.alloc(cursor); + + const u16 = (offset: number, value: number) => + little + ? bytes.writeUInt16LE(value, offset) + : bytes.writeUInt16BE(value, offset); + const u32 = (offset: number, value: number) => + little + ? bytes.writeUInt32LE(value, offset) + : bytes.writeUInt32BE(value, offset); + const word = (offset: number, value: bigint) => { + if (!wide) u32(offset, Number(value)); + else if (little) bytes.writeBigUInt64LE(value, offset); + else bytes.writeBigUInt64BE(value, offset); + }; + bytes.set([0x7f, 0x45, 0x4c, 0x46, wide ? 2 : 1, little ? 1 : 2, 1]); + u16(16, 3); + u16(18, options.machine ?? (wide ? 62 : 40)); + u32(20, 1); + u16(wide ? 52 : 40, wide ? 64 : 52); + u16(wide ? 54 : 42, wide ? 56 : 32); + u16(wide ? 58 : 46, sectionWidth); + if (options.sectionHeaders === false) return bytes; + + symbols.forEach((symbol, index) => { + if (symbol === null) return; + const at = dynsymAt + index * symbolWidth; + const info = ((symbol.binding ?? 1) << 4) | (symbol.type ?? 2); + const other = symbol.visibility ?? 0; + const value = symbol.value ?? BigInt(0x1000 + index * 16); + u32(at, symbolNames[index]!); + if (wide) { + bytes[at + 4] = info; + bytes[at + 5] = other; + u16(at + 6, symbol.section ?? 1); + word(at + 8, value); + word(at + 16, symbol.size ?? 16n); + } else { + word(at + 4, value); + word(at + 8, symbol.size ?? 16n); + bytes[at + 12] = info; + bytes[at + 13] = other; + u16(at + 14, symbol.section ?? 1); + } + }); + Buffer.from(dynstr.join(""), "latin1").copy(bytes, dynstrAt); + symbols.forEach((symbol, index) => + u16(versymAt + index * 2, symbol?.version ?? (index === 0 ? 0 : 1)), + ); + definitions.forEach((_, index) => { + const at = verdefAt + index * 28; + u16(at, 1); + u16(at + 2, index === 0 ? 1 : 0); + u16(at + 4, index + 1); + u16(at + 6, 1); + u32(at + 12, 20); + u32(at + 16, index === definitions.length - 1 ? 0 : 28); + u32(at + 20, definitionNames[index]!); + }); + bytes.write(shstrtab, shstrtabAt, "latin1"); + const sections = elfSectionRows(options, { + dynsymAt, + dynsym: symbols.length * symbolWidth, + symbolWidth, + dynstrAt, + dynstrBytes, + versymAt, + versym: symbols.length * 2, + verdefAt, + definitions: definitions.length, + shstrtabAt, + shstrtab: shstrtab.length, + end: cursor, + }); + sections.forEach(([name, type, offset, size, link, info, entsize], index) => { + const at = sectionAt + index * sectionWidth; + u32(at, name); + u32(at + 4, type); + word(at + (wide ? 24 : 16), BigInt(offset)); + word(at + (wide ? 32 : 20), BigInt(size)); + u32(at + (wide ? 40 : 24), link); + u32(at + (wide ? 44 : 28), info); + word(at + (wide ? 56 : 36), BigInt(entsize)); + }); + word(wide ? 40 : 32, BigInt(sectionAt)); + u16(wide ? 60 : 48, sections.length); + u16(wide ? 62 : 50, sections.length - 1); + return bytes; +}; + +type ElfSectionRow = readonly [ + name: number, + type: number, + offset: number, + size: number, + link: number, + info: number, + entsize: number, +]; + +/** Section header rows for the fixture layout, with the requested defects. */ +const elfSectionRows = ( + options: Parameters[0], + layout: { + readonly dynsymAt: number; + readonly dynsym: number; + readonly symbolWidth: number; + readonly dynstrAt: number; + readonly dynstrBytes: number; + readonly versymAt: number; + readonly versym: number; + readonly verdefAt: number; + readonly definitions: number; + readonly shstrtabAt: number; + readonly shstrtab: number; + readonly end: number; + }, +): readonly ElfSectionRow[] => [ + [0, 0, 0, 0, 0, 0, 0], + ...(options.dynamicSymbols === false + ? [] + : ([ + [ + 1, + 11, + layout.dynsymAt, + options.emptyDynamicSymbols === true ? 0 : layout.dynsym, + 2, + 1, + layout.symbolWidth, + ], + [9, 3, layout.dynstrAt, layout.dynstrBytes, 0, 0, 0], + [ + 17, + 0x6fffffff, + options.versionsOutsideFile === true + ? layout.end + 64 + : layout.versymAt, + layout.versym - (options.truncatedVersions === true ? 2 : 0), + 1, + 0, + 2, + ], + [ + 30, + 0x6ffffffd, + layout.verdefAt, + layout.definitions * 28, + 2, + layout.definitions, + 0, + ], + ] satisfies ElfSectionRow[])), + [45, 3, layout.shstrtabAt, layout.shstrtab, 0, 0, 0], +];