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:
Kaoru0822
2026-10-22 01:30:05 -07:00
committed by GitHub
parent 04fd41b288
commit 933af60eba
29 changed files with 2006 additions and 4 deletions
@@ -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
+49
View File
@@ -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.
+7
View File
@@ -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,
+1 -1
View File
@@ -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,
);
}
}
+33
View File
@@ -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:
+1
View File
@@ -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",
+5
View File
@@ -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"),
+1
View File
@@ -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",
+1
View File
@@ -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({
+128
View File
@@ -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,
);
});
+502
View File
@@ -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",
);
};
+212
View File
@@ -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;
};
+10
View File
@@ -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[];
+1 -1
View File
@@ -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"],
+11
View File
@@ -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",
+15
View File
@@ -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;
+31
View File
@@ -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",
);
});
+2 -1
View File
@@ -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
View File
@@ -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],
];