diff --git a/src/domain/native/nativeExports.ts b/src/domain/native/nativeExports.ts new file mode 100644 index 0000000000..e258c919ff --- /dev/null +++ b/src/domain/native/nativeExports.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const address = z.string().regex(/^0x[0-9a-f]+$/u); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES = 536870912; +/** Fixed bound on reported export rows, so one library cannot flood a reply. */ +export const MAX_NATIVE_EXPORT_ROWS = 262144; + +/** One explicitly selected PE or ELF library or executable. */ +export const inspectNativeExportsInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a PE or ELF image", + ), + max_file_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES) + .default(268435456) + .describe("Maximum stable artifact bytes to read (default 256 MiB)"), +}); +export type InspectNativeExportsInput = z.infer< + typeof inspectNativeExportsInputSchema +>; + +/** + * One PE export-address-table slot as the export directory states it. A + * forwarder has no address in this image; an ordinal-only export has no name. + */ +const peExportSchema = z.strictObject({ + name: z.string().min(1).nullable(), + ordinal: z.number().int().min(0).max(0xffff), + address: address.nullable(), + forwarder: z.string().min(1).nullable(), +}); + +/** + * One defined, default- or protected-visibility global, weak or unique + * dynamic symbol. `version` is null when the symbol carries no version and + * "unknown" when the version table could not be read; a hidden version is not + * the default one a by-name lookup selects. + */ +const elfExportSchema = z.strictObject({ + name: z.string().min(1), + /** `st_value` as stored; on 32-bit ARM, bit 0 of a function marks Thumb code. */ + value: address, + size: z.string().regex(/^[0-9]+$/u), + type: z.enum(["function", "ifunc", "object", "tls", "other"]), + binding: z.enum(["global", "weak", "unique"]), + version: z.union([ + z.null(), + z.literal("unknown"), + z.strictObject({ + index: z.number().int().min(2).max(0x7fff), + name: z.string().min(1).nullable(), + hidden: z.boolean(), + }), + ]), +}); + +/** Parsed table issue; rows it affects are counted, not silently dropped. */ +const nativeExportIssueSchema = z.strictObject({ + code: z.enum([ + "malformed_entry", + "name_unreadable", + "version_unresolved", + "table_unavailable", + ]), + location: z.string().min(1).nullable(), + message: z.string().min(1), +}); + +const artifactSchema = z.strictObject({ + path: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + bytes: uint, +}); + +const coverageSchema = z.strictObject({ + /** complete: every table entry was read; partial: issues name the gaps. */ + status: z.enum(["complete", "partial", "unsupported"]), + reason: z.string().min(1).nullable(), + examined_entries: uint, + exports: uint, +}); + +/** Static export inventory of one PE or ELF image, read without execution. */ +export const nativeExportsSchema = z.discriminatedUnion("format", [ + z.strictObject({ + artifact: artifactSchema, + format: z.literal("pe"), + architecture: z.string().min(1), + bits: z.union([z.literal(32), z.literal(64)]), + /** Null when the image declares no export directory. */ + export_directory: z + .strictObject({ + rva: uint, + size: uint, + dll_name: z.string().nullable(), + ordinal_base: uint, + timestamp: uint, + }) + .nullable(), + exports: z.array(peExportSchema).max(MAX_NATIVE_EXPORT_ROWS), + coverage: coverageSchema, + issues: z.array(nativeExportIssueSchema), + limitations: z.array(z.string().min(1)), + }), + z.strictObject({ + artifact: artifactSchema, + format: z.literal("elf"), + architecture: z.string().min(1), + bits: z.union([z.literal(32), z.literal(64)]), + byte_order: z.enum(["little", "big"]), + exports: z.array(elfExportSchema).max(MAX_NATIVE_EXPORT_ROWS), + coverage: coverageSchema, + issues: z.array(nativeExportIssueSchema), + limitations: z.array(z.string().min(1)), + }), +]); +export type NativeExports = z.infer; +export type PeExport = z.infer; +export type ElfExport = z.infer; +export type NativeExportIssue = z.infer; diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts new file mode 100644 index 0000000000..4cc9920559 --- /dev/null +++ b/src/native/exports/NativeExportParser.test.ts @@ -0,0 +1,291 @@ +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"), + }, + }); + }); +}); + +it("refuses formats other than PE and ELF", () => { + const macho = Buffer.alloc(64); + macho.writeUInt32LE(0xfeedfacf, 0); + expect(() => parseNativeExports(macho, artifact)).toThrow( + BinaryFormatFailure, + ); +}); diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts new file mode 100644 index 0000000000..f281535a14 --- /dev/null +++ b/src/native/exports/NativeExportParser.ts @@ -0,0 +1,468 @@ +import { + BinaryFormatFailure, + BinaryReader, + BinaryResourceFailure, + mappedFileOffset, +} from "../metadata/BinaryContainer.js"; +import { readElfImage } 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; + +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 map = (rva: number, size: number): number => + mappedFileOffset( + image.mappings, + base + BigInt(rva), + size, + image.zero_fills, + ); + const cString = (rva: number): string => { + const start = map(rva, 1); + const end = bytes + .subarray(start, start + MAXIMUM_NAME_BYTES + 1) + .indexOf(0); + if (end <= 0) + throw new BinaryFormatFailure( + "malformed", + `Export name at RVA ${hex(rva)} is empty, unterminated or longer than ${String(MAXIMUM_NAME_BYTES)} bytes`, + ); + // The whole string must stay inside one file-backed mapping. + map(rva, end + 1); + return bytes.toString("latin1", start, start + end); + }; + + const header = map(directory.virtual_address, PE_EXPORT_DIRECTORY_BYTES); + const ordinalBase = reader.u32(header + 16); + const functionCount = reader.u32(header + 20); + const nameCount = reader.u32(header + 24); + rowBudget(functionCount, "PE export address table"); + if (ordinalBase + functionCount > 0x10000) + throw new BinaryFormatFailure( + "malformed", + "PE export ordinal base and address-table size exceed 16-bit ordinals", + ); + rowBudget(nameCount, "PE export name table"); + const functions = + functionCount === 0 ? 0 : map(reader.u32(header + 28), functionCount * 4); + const names = + nameCount === 0 ? 0 : map(reader.u32(header + 32), nameCount * 4); + const ordinals = + nameCount === 0 ? 0 : map(reader.u32(header + 36), nameCount * 2); + const issues: NativeExportIssue[] = []; + let dllName: string | null = null; + try { + dllName = cString(reader.u32(header + 12)); + } catch (cause) { + issues.push({ + code: "name_unreadable", + location: hex(reader.u32(header + 12)), + message: `Export directory DLL name: ${failureMessage(cause)}`, + }); + } + + const namesByFunction = new Map(); + const namedFunctions = new Set(); + for (let index = 0; index < nameCount; index++) { + const functionIndex = reader.u16(ordinals + index * 2); + const nameRva = reader.u32(names + index * 4); + if (functionIndex >= functionCount) { + issues.push({ + code: "malformed_entry", + location: hex(nameRva), + message: `Export name ${String(index)} selects address-table slot ${String(functionIndex)}, outside its ${String(functionCount)} slots`, + }); + continue; + } + namedFunctions.add(functionIndex); + try { + const list = namesByFunction.get(functionIndex) ?? []; + list.push(cString(nameRva)); + namesByFunction.set(functionIndex, list); + } catch (cause) { + issues.push({ + code: "name_unreadable", + location: hex(nameRva), + message: failureMessage(cause), + }); + } + } + + const forwarderEnd = directory.virtual_address + directory.size; + const exports: PeExport[] = []; + for (let index = 0; index < functionCount; index++) { + const rva = reader.u32(functions + index * 4); + // A zero slot is an unused ordinal, not an export. + if (rva === 0) continue; + const ordinal = ordinalBase + index; + let target: Pick; + if (rva >= directory.virtual_address && rva < forwarderEnd) { + try { + target = { address: null, forwarder: cString(rva) }; + } catch (cause) { + issues.push({ + code: "malformed_entry", + location: hex(rva), + message: `Forwarder of ordinal ${String(ordinal)}: ${failureMessage(cause)}`, + }); + continue; + } + } else target = { address: hex(base + BigInt(rva)), forwarder: null }; + const exportNames = namesByFunction.get(index); + if (exportNames !== undefined) + for (const name of exportNames) + exports.push({ name, ordinal, ...target }); + // A slot whose only name was unreadable is reported through its issue. + else if (!namedFunctions.has(index)) + exports.push({ name: null, ordinal, ...target }); + } + return { + ...result, + export_directory: { + rva: directory.virtual_address, + size: directory.size, + dll_name: dllName, + ordinal_base: ordinalBase, + timestamp: reader.u32(header + 4), + }, + exports, + coverage: coverage(issues, functionCount, exports.length), + issues, + limitations: PE_LIMITATIONS, + }; +}; + +const ELF_LIMITATIONS = [ + "Rows list defined default- or protected-visibility global, weak and unique dynamic symbols; local, hidden, internal and undefined symbols are not exports.", + "A hidden symbol version is not the default version that a by-name dlsym lookup selects.", + "Rows reproduce the dynamic symbol table; they do not establish that a symbol is reachable, safe to call or loaded at runtime.", +]; + +const ELF_TYPES: Readonly> = { + 1: "object", + 2: "function", + 6: "tls", + 10: "ifunc", +}; +const ELF_BINDINGS: Readonly> = { + 1: "global", + 2: "weak", + 10: "unique", +}; + +/** Version definitions by index, read from `.gnu.version_d`. */ +const readVersionDefinitions = ( + reader: BinaryReader, + sections: readonly NativeSection[], + issues: NativeExportIssue[], +): Map => { + const definitions = new Map(); + const verdef = sections.find(({ type }) => type === SHT_GNU_VERDEF); + if (verdef === undefined) return definitions; + try { + const strings = sections[verdef.link]; + if (strings?.type !== SHT_STRTAB) + throw new BinaryFormatFailure( + "malformed", + "Version definitions do not link to a string table", + ); + const names = reader.fileRange( + strings.offset, + strings.size, + "Version strings", + ); + const nameBytes = reader.bytes.subarray( + names.offset, + names.offset + names.size, + ); + const table = reader.fileRange( + verdef.offset, + verdef.size, + "Version definitions", + ); + let offset = 0; + for (let index = 0; index < verdef.info; index++) { + if (offset + 20 > table.size) + throw new BinaryFormatFailure( + "malformed", + "Version definition chain leaves its section", + ); + const at = table.offset + offset; + const versionIndex = reader.u16(at + 4) & 0x7fff; + const aux = reader.u32(at + 12); + const next = reader.u32(at + 16); + if (aux < 20 || offset + aux + 8 > table.size) + throw new BinaryFormatFailure( + "malformed", + "Version definition name entry leaves its section", + ); + definitions.set( + versionIndex, + readElfString(nameBytes, reader.u32(at + aux)), + ); + if (next === 0) break; + offset += next; + } + } catch (cause) { + issues.push({ + code: "version_unresolved", + location: hex(verdef.offset), + message: `Version definitions: ${failureMessage(cause)}`, + }); + } + return definitions; +}; + +/** Read defined exported entries of `.dynsym` with their symbol versions. */ +const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { + const image = readElfImage(bytes); + const directory = readNativeDirectoryFromBuffer(bytes, "elf"); + const reader = new BinaryReader(bytes, directory.little); + const result = { + artifact, + format: "elf" as const, + architecture: image.architecture, + bits: image.bits, + byte_order: image.byte_order, + }; + 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 || + strings?.type !== SHT_STRTAB + ) + throw new BinaryFormatFailure( + "malformed", + "ELF dynamic symbol table entry size, length 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) { + if (versym.size === BigInt(count * 2)) + versions = reader.table(versym.offset, count, 2, "ELF symbol versions"); + else + issues.push({ + code: "version_unresolved", + location: hex(versym.offset), + message: `Symbol version table has ${String(versym.size)} bytes for ${String(count)} symbols`, + }); + } + const definitions = readVersionDefinitions(reader, sections, issues); + const unresolvedVersions = new Set(); + 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 name = definitions.get(versionIndex) ?? null; + 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)}, is named by no version definition`, + }); + } + version = { + index: versionIndex, + name: name === "" ? null : 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; + } + exports.push({ + name, + value: hex(symbol.value), + size: symbol.size.toString(), + type: ELF_TYPES[symbol.type] ?? "other", + binding, + version, + }); + } + return { + ...result, + exports, + coverage: coverage(issues, count - 1, exports.length), + issues, + limitations: ELF_LIMITATIONS, + }; +}; + +/** Static export inventory of one PE or ELF image; other formats are unsupported. */ +export const parseNativeExports = ( + bytes: Buffer, + artifact: Artifact, +): NativeExports => { + if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) + return parseElfExports(bytes, artifact); + if (bytes.subarray(0, 2).equals(Buffer.from("MZ"))) + return parsePeExports(bytes, artifact); + throw new BinaryFormatFailure( + "unsupported", + "Only PE and ELF images are supported; inspect Mach-O exports with inspect_macho", + ); +}; diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index 6d0766be20..e00e44ddaa 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -95,6 +95,11 @@ export interface BinaryContainer { readonly byte_order: "little" | "big"; /** Declared PE preferred base, when available. */ readonly image_base?: bigint; + /** PE optional-header data directories, indexed as declared. */ + readonly pe_data_directories?: readonly { + readonly virtual_address: number; + readonly size: number; + }[]; /** Mach-O load-command observation, when available. */ readonly macho_chained_fixups?: boolean; readonly mappings: readonly FileMapping[]; diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index b68b1f6eb7..60ab3eab72 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -82,6 +82,10 @@ 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 count = reader.u16(pe + 6); const start = reader.table( @@ -152,6 +156,7 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { return { format: "pe", image_base: imageBase, + pe_data_directories: dataDirectories, architecture: names.get(machine) ?? `pe-machine-${machine}`, bits, byte_order: "little", diff --git a/tests/fixtures/binaryDiagnostics/nativeExports.ts b/tests/fixtures/binaryDiagnostics/nativeExports.ts new file mode 100644 index 0000000000..08e3118313 --- /dev/null +++ b/tests/fixtures/binaryDiagnostics/nativeExports.ts @@ -0,0 +1,268 @@ +/** 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; + +/** + * 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; +}): 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); + 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 - SECTION_RVA + SECTION_OFFSET; + 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 = SECTION_RVA + 0x40; + const nameTable = SECTION_RVA + 0x80; + const ordinalTable = SECTION_RVA + 0xc0; + let forwarderText = SECTION_RVA + 0x100; + let text = SECTION_RVA + 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(SECTION_RVA, optional + directoriesAt); + bytes.writeUInt32LE(0x200, optional + directoriesAt + 4); + const directory = at(SECTION_RVA); + 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`. Version definitions are indexed from 1 in order. + */ +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; + readonly sectionHeaders?: boolean; + readonly dynamicSymbols?: boolean; +}): Buffer => { + const bits = options.bits ?? 64; + const little = options.little ?? true; + const wide = bits === 64; + const bytes = Buffer.alloc(0x1000); + 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); + const headerSize = wide ? 64 : 52; + u16(wide ? 52 : 40, headerSize); + u16(wide ? 54 : 42, wide ? 56 : 32); + u16(wide ? 58 : 46, wide ? 64 : 40); + if (options.sectionHeaders === false) return bytes; + + const dynstr = ["\0"]; + let dynstrBytes = 1; + const intern = (value: string): number => { + const offset = dynstrBytes; + dynstr.push(`${value}\0`); + dynstrBytes += value.length + 1; + return offset; + }; + const definitions = options.versionDefinitions ?? []; + const definitionNames = definitions.map(intern); + const symbolWidth = wide ? 24 : 16; + const symbols = [null, ...options.symbols]; + const dynsymAt = 0x200; + 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 name = symbol.nameOffset ?? intern(symbol.name); + u32(at, name); + if (wide) { + bytes[at + 4] = info; + bytes[at + 5] = other; + u16(at + 6, symbol.section ?? 1); + word(at + 8, symbol.value ?? BigInt(0x1000 + index * 16)); + word(at + 16, symbol.size ?? 16n); + } else { + word(at + 4, symbol.value ?? BigInt(0x1000 + index * 16)); + word(at + 8, symbol.size ?? 16n); + bytes[at + 12] = info; + bytes[at + 13] = other; + u16(at + 14, symbol.section ?? 1); + } + }); + const dynstrAt = 0x600; + Buffer.from(dynstr.join(""), "latin1").copy(bytes, dynstrAt); + const versymAt = 0x900; + symbols.forEach((symbol, index) => + u16(versymAt + index * 2, symbol?.version ?? (index === 0 ? 0 : 1)), + ); + const verdefAt = 0xa00; + 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]!); + }); + const shstrtab = + "\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_d\0.shstrtab\0"; + const shstrtabAt = 0xc00; + bytes.write(shstrtab, shstrtabAt, "latin1"); + const sections: (readonly [ + number, + number, + number, + number, + number, + number, + number, + ])[] = [ + // name, type, offset, size, link, info, entsize + [0, 0, 0, 0, 0, 0, 0], + ...(options.dynamicSymbols === false + ? [] + : ([ + [1, 11, dynsymAt, symbols.length * symbolWidth, 2, 1, symbolWidth], + [9, 3, dynstrAt, dynstrBytes, 0, 0, 0], + [ + 17, + 0x6fffffff, + versymAt, + (symbols.length - (options.truncatedVersions === true ? 1 : 0)) * 2, + 1, + 0, + 2, + ], + [ + 30, + 0x6ffffffd, + verdefAt, + definitions.length * 28, + 2, + definitions.length, + 0, + ], + ] as const)), + [45, 3, shstrtabAt, shstrtab.length, 0, 0, 0], + ]; + const sectionAt = 0xd00; + const sectionWidth = wide ? 64 : 40; + 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; +};