mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * docs(native): describe portable PE and ELF export inventories Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void>;
|
||||
inspect(
|
||||
input: InspectNativeExportsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<NativeExports, AnalysisError>>;
|
||||
}
|
||||
@@ -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<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/** Read one explicitly selected PE or ELF image's export table. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -27,6 +27,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
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",
|
||||
|
||||
@@ -253,6 +253,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
}),
|
||||
inspect_byte_entropy: evidence,
|
||||
inspect_pe_resources: evidence,
|
||||
inspect_native_exports: evidence,
|
||||
inspect_elf_notes: evidence,
|
||||
inspect_analysis_view: evidence,
|
||||
inspect_firmware_regions: effects({
|
||||
|
||||
@@ -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<typeof nativeExportsSchema>;
|
||||
export type PeExport = z.infer<typeof peExportSchema>;
|
||||
export type ElfExport = z.infer<typeof elfExportSchema>;
|
||||
export type NativeExportIssue = z.infer<typeof nativeExportIssueSchema>;
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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<number, string[]>();
|
||||
const namedFunctions = new Set<number>();
|
||||
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<PeExport, "address" | "forwarder">;
|
||||
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<Record<number, ElfExport["type"]>> = {
|
||||
1: "object",
|
||||
2: "function",
|
||||
6: "tls",
|
||||
10: "ifunc",
|
||||
};
|
||||
const ELF_BINDINGS: Readonly<Record<number, ElfExport["binding"]>> = {
|
||||
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<number, string> => {
|
||||
const definitions = new Map<number, string>();
|
||||
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<number>();
|
||||
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",
|
||||
);
|
||||
};
|
||||
@@ -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<void> {
|
||||
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;
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof createSessionRecorders> {
|
||||
readonly rustBinary: RustBinaryService;
|
||||
readonly il2cppMetadata: Il2cppMetadataService;
|
||||
readonly peResources: PeResourcesService;
|
||||
readonly nativeExports: NativeExportsService;
|
||||
readonly elfNotes: ElfNotesService;
|
||||
readonly electronFuses: ElectronFuseService;
|
||||
readonly webNetworkCapture: WebNetworkCaptureService;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -218,6 +218,7 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
|
||||
{ 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 }],
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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"] } },
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
Vendored
+2
-1
@@ -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<ReturnType<typeof advertisedMcpToolSchemas>>,
|
||||
session: BinarySession,
|
||||
expected: { readonly artifact: { readonly sha256: string } },
|
||||
|
||||
+336
@@ -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<string, number>();
|
||||
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<typeof elfExportImage>[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],
|
||||
];
|
||||
Reference in New Issue
Block a user