Files
reverse-engineering-anything/src/native/initializers/NativeInitializersParsers.test.ts
T
46728ca41d test: remove per-test deadlines owned by project policy
Explicit deadlines at or below the project deadline only shortened it:
a literal 30-second value also disabled the coverage scaling, which timed
out wide-capacity regressions on main. Workloads above the built
deadline use the long-running tag. Deadlines derived from an operation
budget remain.

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 05:27:07 +08:00

2019 lines
72 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "node:zlib";
import { readNativeInitializers } from "./NativeInitializersProvider.js";
/** Pass-through spies on the shared mapping module, so tests can count lookups made from other modules. */
const mapping = vi.hoisted(() => ({
scans: vi.fn(),
indexes: vi.fn(),
}));
vi.mock("../metadata/BinaryContainer.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../metadata/BinaryContainer.js")>();
return {
...actual,
mappedFileOffset: (
...args: Parameters<typeof actual.mappedFileOffset>
): number => {
mapping.scans(...args);
return actual.mappedFileOffset(...args);
},
createFileMappingIndex: (
...args: Parameters<typeof actual.createFileMappingIndex>
) => {
mapping.indexes(...args);
return actual.createFileMappingIndex(...args);
},
};
});
import {
elf32RelativeInitializers,
elf64PieInitializers,
elf64AliasedInitializers,
elfRelocatableCtors,
elfUnmappedInitializerTargets,
macho32Initializers,
machoArm64FatInitializers,
machoArm64Initializers,
machoArm64LegacyRebaseInitializers,
machoSparseBindInitializers,
machoX8664ObjectInitializers,
} from "./NativeInitializers.fixture.js";
import {
MAX_NATIVE_INITIALIZER_ENTRIES,
MAX_NATIVE_INITIALIZER_NAME_BYTES,
MAX_NATIVE_INITIALIZER_NOTES,
NATIVE_INITIALIZER_LIMITATIONS,
nativeInitializersSchema,
} from "../../domain/native/nativeInitializers.js";
import { buildAddressRangeIndex } from "./addressRangeIndex.js";
import { InitializerNotes } from "./initializerNotes.js";
import { BinaryFormatFailure } from "../metadata/BinaryContainer.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
const artifact = (bytes: Buffer) => ({
path: "/artifacts/image",
sha256: "a".repeat(64),
bytes: bytes.length,
});
const read = async (
bytes: Buffer,
page: { offset?: number; limit?: number; architecture?: "arm64" } = {},
) =>
nativeInitializersSchema.parse(
await readNativeInitializers(bytes, artifact(bytes), {
path: "/artifacts/image",
offset: page.offset ?? 0,
limit: page.limit ?? 64,
...(page.architecture === undefined
? {}
: { architecture: page.architecture }),
}),
);
it.each([
{ type: 2 as const, metadataAllocated: false },
{ type: 3 as const, metadataAllocated: false },
{ type: 2 as const, metadataAllocated: true },
])(
"keeps section metadata from inventing linked target file offsets ($type / $metadataAllocated)",
async (options) => {
const report = await read(elfUnmappedInitializerTargets(options));
expect(
report.entries.map((entry) => [entry.value_va, entry.value_file_offset]),
).toEqual([
["0x0", null],
["0x10", null],
["0x401000", 0x100],
]);
expect(report.coverage).toMatchObject({
status: "complete",
examined_entries: 3,
total_entries: 3,
});
},
);
it("preserves a real mapping to file offset zero instead of treating zero as missing", async () => {
const bytes = elfUnmappedInitializerTargets({ type: 3 });
bytes.writeBigUInt64LE(0n, 64 + 16);
bytes.writeBigUInt64LE(0n, 64 + 24);
const report = await read(bytes);
expect(
report.entries.slice(0, 2).map((entry) => entry.value_file_offset),
).toEqual([0, 0x10]);
});
it("does not use metadata to back a target in a load segment's zero-fill tail", async () => {
const bytes = elfUnmappedInitializerTargets();
bytes.writeBigUInt64LE(0x2000n, 176 + 40);
bytes.writeBigUInt64LE(0x403000n, 0x140);
bytes.writeBigUInt64LE(0x403000n, 0x400 + 3 * 64 + 16);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
value_va: "0x403000",
value_file_offset: null,
});
expect(report.entries[2]).toMatchObject({
value_va: "0x401000",
value_file_offset: 0x100,
});
});
/** One ELF64 section header for {@link withExtraSections}. */
const sectionHeader = (
type: number,
offset: number,
size: number,
link: number,
entsize: number,
info = 0,
): Buffer => {
const header = Buffer.alloc(64);
header.writeUInt32LE(type, 4);
header.writeBigUInt64LE(BigInt(offset), 24);
header.writeBigUInt64LE(BigInt(size), 32);
header.writeUInt32LE(link, 40);
header.writeUInt32LE(info, 44);
header.writeBigUInt64LE(BigInt(entsize), 56);
return header;
};
/**
* The relocatable fixture with `payload` appended and a moved section-header
* table that keeps its nine headers and adds `extra`. Extra offsets are
* relative to the payload.
*/
const withExtraSections = (
payload: Buffer,
extra: (payloadAt: number) => readonly Buffer[],
): Buffer => {
const base = elfRelocatableCtors();
const headers = extra(base.length);
const table = Buffer.concat([
base.subarray(0x500, 0x500 + 9 * 64),
...headers,
]);
const bytes = Buffer.concat([base, payload, table]);
bytes.writeBigUInt64LE(BigInt(base.length + payload.length), 40);
bytes.writeUInt16LE(9 + headers.length, 60);
return bytes;
};
const row = (report: Awaited<ReturnType<typeof read>>) =>
report.entries.map((entry) => ({
kind: entry.kind,
value: entry.value_va,
section: entry.section?.name ?? null,
symbol: entry.symbol?.name ?? null,
resolution: entry.resolution,
}));
describe("ELF", () => {
it("does not treat repeated DT_NEEDED entries as duplicate tags", async () => {
const report = await read(elf64PieInitializers(3));
expect(report.status).toBe("parsed");
expect(report.coverage.status).toBe("complete");
expect(row(report)).toEqual(row(await read(elf64PieInitializers())));
expect(
report.limitations.some((line) =>
line.startsWith("Duplicate dynamic tag"),
),
).toBe(false);
});
it("lists PIE slots in declaration order through RELA, RELR and imports", async () => {
const report = await read(elf64PieInitializers());
expect(report).toMatchObject({
format: "elf",
status: "parsed",
architecture: "x86_64",
bits: 64,
elf_type: 3,
coverage: { status: "complete", examined_entries: 7, total_entries: 7 },
});
expect(row(report)).toEqual([
{
kind: "preinit_array",
value: "0x100",
section: ".text",
symbol: "ctor_a",
resolution: "raw",
},
{
kind: "init",
value: "0x100",
section: ".text",
symbol: "ctor_a",
resolution: "raw",
},
{
kind: "init_array",
value: "0x100",
section: ".text",
symbol: "ctor_a",
resolution: "relative_addend",
},
{
kind: "init_array",
value: "0x110",
section: ".text",
symbol: "ctor_b",
resolution: "relative_addend",
},
{
kind: "init_array",
value: null,
section: null,
symbol: "imp_init",
resolution: "unresolved_import",
},
{
kind: "fini",
value: "0x120",
section: ".text",
symbol: "fini_fn",
resolution: "raw",
},
{
kind: "fini_array",
value: "0x120",
section: ".text",
symbol: "fini_fn",
resolution: "relr",
},
]);
expect(report.entries[2]).toMatchObject({
slot_va: "0x200",
slot_file_offset: 0x200,
slot_bytes: 8,
value_file_offset: 0x100,
slot_section: { name: ".init_array", trust: "observed-untrusted" },
detail: "DT_INIT_ARRAY",
});
expect(
report.limitations.slice(0, NATIVE_INITIALIZER_LIMITATIONS.length),
).toEqual([...NATIVE_INITIALIZER_LIMITATIONS]);
});
it("pages entries without changing their indexes", async () => {
const report = await read(elf64PieInitializers(), { offset: 2, limit: 2 });
expect(report.coverage).toMatchObject({
status: "complete",
examined_entries: 7,
returned_entries: 2,
offset: 2,
limit: 2,
});
expect(report.entries.map((entry) => entry.index)).toEqual([2, 3]);
});
it("resolves an ELF32 REL RELATIVE slot from its in-place addend", async () => {
const report = await read(elf32RelativeInitializers());
expect(report).toMatchObject({ bits: 32, architecture: "x86" });
expect(row(report)).toEqual([
{
kind: "init_array",
value: "0x80",
section: ".text",
symbol: "ctor",
resolution: "relative_addend",
},
]);
expect(report.entries[0]?.slot_bytes).toBe(4);
});
it("reads relocatable-object sections by section-relative offset and skips sentinels", async () => {
const report = await read(elfRelocatableCtors());
expect(report.elf_type).toBe(1);
expect(row(report)).toEqual([
{
kind: "init_array",
value: "0x8",
section: ".text",
symbol: "init_b",
resolution: "symbol_addend",
},
{
kind: "ctors",
value: "0x0",
section: ".text",
symbol: "ctor",
resolution: "symbol_addend",
},
]);
expect(report.entries[1]).toMatchObject({
slot_va: "0x8",
slot_file_offset: 0x208,
});
expect(report.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("Relocatable object (ET_REL)"),
"Omitted 2 .ctors or .dtors sentinel values (0 or -1).",
]),
);
expect(report.status).toBe("parsed");
// Three .ctors slots, including both sentinels, and one .init_array slot.
expect(report.coverage).toMatchObject({
examined_entries: 4,
total_entries: 2,
});
});
});
describe("ELF slot semantics", () => {
it("keeps a 32-bit all-ones word in a 64-bit .ctors as a listed value", async () => {
const bytes = elfRelocatableCtors();
// The leading .ctors sentinel becomes 0x00000000ffffffff, which is not -1 in ELF64.
bytes.writeBigUInt64LE(0xffffffffn, 0x200);
const report = await read(bytes);
expect(row(report).filter((entry) => entry.kind === "ctors")).toEqual([
{
kind: "ctors",
value: "0xffffffff",
section: null,
symbol: null,
resolution: "raw",
},
{
kind: "ctors",
value: "0x0",
section: ".text",
symbol: "ctor",
resolution: "symbol_addend",
},
]);
expect(report.limitations).toContain(
"Omitted 1 .ctors or .dtors sentinel values (0 or -1).",
);
});
it("reports no slot address for DT_INIT and DT_FINI fields outside every load segment", async () => {
const bytes = elf64PieInitializers();
// Shrink the only PT_LOAD so PT_DYNAMIC (file offset 0x300) is no longer mapped.
bytes.writeBigUInt64LE(0x300n, 64 + 32);
bytes.writeBigUInt64LE(0x300n, 64 + 40);
const report = await read(bytes);
const direct = report.entries.filter(
(entry) => entry.kind === "init" || entry.kind === "fini",
);
expect(direct).toHaveLength(2);
for (const entry of direct)
expect(entry).toMatchObject({ slot_va: null, slot_section: null });
expect(direct.map((entry) => entry.value_va)).toEqual(["0x100", "0x120"]);
expect(report.limitations).toEqual(
expect.arrayContaining([
"DT_INIT value field is not inside a load segment.",
"DT_FINI value field is not inside a load segment.",
]),
);
});
const sectionlessElf = (directOnly: boolean): Buffer => {
const bytes = Buffer.from(elf64PieInitializers());
bytes.writeBigUInt64LE(0n, 40);
bytes.writeUInt16LE(0, 60);
bytes.writeUInt16LE(0, 62);
if (!directOnly) return bytes;
(
[
[12, 0x100],
[13, 0x120],
[6, 0x520],
[0, 0],
] as const
).forEach(([tag, value], index) => {
const at = 0x300 + index * 16;
bytes.writeBigUInt64LE(BigInt(tag), at);
bytes.writeBigUInt64LE(BigInt(value), at + 8);
});
bytes.writeBigUInt64LE(64n, 120 + 32);
bytes.writeBigUInt64LE(64n, 120 + 40);
return bytes;
};
it("keeps direct initializers complete when DT_SYMTAB has no decoded table", async () => {
const report = await read(sectionlessElf(true));
expect(report.entries.map((entry) => entry.kind)).toEqual(["init", "fini"]);
expect(report.coverage).toMatchObject({
status: "complete",
total_entries: 2,
});
expect(report.limitations.join("\n")).not.toContain("DT_SYMTAB");
});
it("marks coverage incomplete when a relocation needs the missing DT_SYMTAB", async () => {
const report = await read(sectionlessElf(false));
expect(report.coverage).toMatchObject({
status: "incomplete",
total_entries: null,
});
expect(report.limitations).toContain(
"DT_SYMTAB does not identify a decoded symbol table; dynamic relocation symbols are unresolved.",
);
});
});
describe("ELF section content admission", () => {
it("keeps symbol relocations unresolved for a conflicting symbol entry width", async () => {
const bytes = elfRelocatableCtors();
bytes.writeBigUInt64LE(16n, 0x500 + 4 * 64 + 56);
const report = await read(bytes);
expect(
report.entries.find((entry) => entry.kind === "init_array"),
).toMatchObject({
resolution: "unresolved",
value_va: null,
symbol: null,
});
expect(report.status).toBe("incomplete");
expect(report.limitations.join("\n")).toContain("entry size");
});
it.each([
{ section: 7, label: "initializer array" },
{ section: 4, label: "symbol table" },
{ section: 5, label: "symbol string table" },
{ section: 8, label: "relocation table" },
])("preserves uncertainty for a compressed $label", async ({ section }) => {
const base = elfRelocatableCtors();
const at = 0x500 + section * 64;
const offset = Number(base.readBigUInt64LE(at + 24));
const size = Number(base.readBigUInt64LE(at + 32));
const header = Buffer.alloc(24);
header.writeUInt32LE(1, 0);
header.writeBigUInt64LE(BigInt(size), 8);
header.writeBigUInt64LE(1n, 16);
const compressed = Buffer.concat([
header,
deflateSync(base.subarray(offset, offset + size)),
]);
const bytes = Buffer.concat([base, compressed]);
bytes.writeBigUInt64LE(base.readBigUInt64LE(at + 8) | 0x800n, at + 8);
bytes.writeBigUInt64LE(BigInt(base.length), at + 24);
bytes.writeBigUInt64LE(BigInt(compressed.length), at + 32);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.coverage.total_entries).toBeNull();
expect(report.limitations.join("\n")).toContain("compressed");
if (section === 7)
expect(report.entries.some((entry) => entry.kind === "init_array")).toBe(
false,
);
else {
const init = report.entries.find((entry) => entry.kind === "init_array");
expect(init).toBeDefined();
if (section === 5) expect(init?.symbol).toBeNull();
else
expect(init).toMatchObject({
resolution: "unresolved",
value_va: null,
});
}
// The uncompressed fixture remains the working comparison.
expect((await read(base)).status).toBe("parsed");
});
it("does not substitute an unrelated dynamic symbol table for DT_SYMTAB", async () => {
const bytes = elf64PieInitializers();
bytes.writeBigUInt64LE(0x777n, 0x300 + 14 * 16 + 8);
const report = await read(bytes);
expect(
report.entries.find((entry) => entry.slot_va === "0x210"),
).toMatchObject({
resolution: "unresolved",
symbol: null,
value_va: null,
});
expect(report.status).toBe("incomplete");
});
it.each(["compressed", "not a string table"])(
"retains later symbol tables when an earlier name source is %s",
async (source) => {
const bytes = elf64PieInitializers();
// The first (dynamic) symbol table links section 8; its bytes still contain imp_init.
if (source === "compressed")
bytes.writeBigUInt64LE(0x802n, 0x800 + 8 * 64 + 8);
else bytes.writeUInt32LE(1, 0x800 + 8 * 64 + 4);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(
report.entries.find((entry) => entry.kind === "init"),
).toMatchObject({
symbol: { name: "ctor_a" },
value_va: "0x100",
});
expect(
report.entries.find((entry) => entry.slot_va === "0x210"),
).toMatchObject({
resolution: "unresolved_import",
symbol: null,
});
},
);
});
describe("ELF incomplete inputs", () => {
it("reports an unsupported relocation machine as incomplete, not as raw zeros", async () => {
const bytes = elf64PieInitializers();
bytes.writeUInt16LE(243, 18);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.coverage.total_entries).toBeNull();
const slots = report.entries.filter((entry) => entry.kind === "init_array");
expect(slots.map((entry) => entry.resolution)).toEqual([
"unresolved",
"unresolved",
"unresolved_import",
]);
expect(slots.every((entry) => entry.value_va === null)).toBe(true);
expect(
report.entries.find((entry) => entry.kind === "init")?.resolution,
).toBe("raw");
});
it("bounds a huge declared DT_INIT_ARRAYSZ by the slot budget", async () => {
const bytes = elf64PieInitializers();
// DT_INIT_ARRAYSZ is the fourth dynamic entry; 2^50 slots is still a safe integer.
bytes.writeBigUInt64LE(8n << 50n, 0x300 + 3 * 16 + 8);
const started = performance.now();
const report = await read(bytes);
expect(performance.now() - started).toBeLessThan(10_000);
expect(report.status).toBe("incomplete");
expect(report.coverage).toMatchObject({
status: "incomplete",
total_entries: null,
});
expect(report.coverage.examined_entries).toBeLessThanOrEqual(
MAX_NATIVE_INITIALIZER_ENTRIES,
);
expect(report.limitations).toContain(
`DT_INIT_ARRAY declares ${String(1n << 50n)} slots. Scanning stopped at the 65536-slot budget, so later slots were not read.`,
);
});
it("stops a file-backed section-derived init_array at the slot budget", async () => {
const base = elfRelocatableCtors();
const slots = MAX_NATIVE_INITIALIZER_ENTRIES + 4096;
const bytes = Buffer.concat([base, Buffer.alloc(slots * 8)]);
// .init_array is section 7 of the relocatable fixture: sh_offset at +24, sh_size at +32.
bytes.writeBigUInt64LE(BigInt(base.length), 0x500 + 7 * 64 + 24);
bytes.writeBigUInt64LE(BigInt(slots * 8), 0x500 + 7 * 64 + 32);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.coverage).toMatchObject({
status: "incomplete",
examined_entries: MAX_NATIVE_INITIALIZER_ENTRIES,
total_entries: null,
});
expect(report.limitations).toContain(
`.init_array declares ${String(slots)} slots. Scanning stopped at the 65536-slot budget, so later slots were not read.`,
);
});
it("reports over-long section and symbol names as null instead of failing the schema", async () => {
const base = elf64PieInitializers();
const longSection = `.init_array.${"s".repeat(1100)}`;
const longSymbol = `ctor_${"y".repeat(1100)}`;
const tail = Buffer.from(`${longSection}\0${longSymbol}\0`, "utf8");
const bytes = Buffer.concat([base, tail]);
const sectionAt = base.length;
const symbolAt = base.length + longSection.length + 1;
// .shstrtab (section 11) at 0x680 and .strtab (section 10) at 0x600 grow to cover the tail.
bytes.writeBigUInt64LE(BigInt(bytes.length - 0x680), 0x800 + 11 * 64 + 32);
bytes.writeBigUInt64LE(BigInt(bytes.length - 0x600), 0x800 + 10 * 64 + 32);
// Rename .init_array (section 3) and ctor_a (first .symtab entry after the null symbol).
bytes.writeUInt32LE(sectionAt - 0x680, 0x800 + 3 * 64);
bytes.writeUInt32LE(symbolAt - 0x600, 0x598);
const report = await read(bytes);
const slot = report.entries.find((entry) => entry.kind === "init_array");
expect(slot?.slot_section).toBeNull();
expect(
report.entries.every(
(entry) =>
(entry.symbol?.name.length ?? 0) <= MAX_NATIVE_INITIALIZER_NAME_BYTES,
),
).toBe(true);
expect(
report.limitations.some((line) =>
line.includes("exceeded the 1024-byte name budget"),
),
).toBe(true);
});
it("labels an over-long section-derived array with the request name", async () => {
const base = elfRelocatableCtors();
const longSection = `.init_array.${"s".repeat(1100)}`;
const bytes = Buffer.concat([base, Buffer.from(`${longSection}\0`)]);
// .shstrtab (section 6) at 0x400 grows to cover the tail; rename .init_array (section 7).
bytes.writeBigUInt64LE(BigInt(bytes.length - 0x400), 0x500 + 6 * 64 + 32);
bytes.writeUInt32LE(base.length - 0x400, 0x500 + 7 * 64);
const report = await read(bytes);
const slot = report.entries.find((entry) => entry.kind === "init_array");
expect(slot).toMatchObject({ slot_section: null, detail: ".init_array" });
expect(report.status).toBe("incomplete");
const beyond = await read(bytes, { offset: 100 });
expect(beyond.entries).toHaveLength(0);
expect(beyond.status).toBe("incomplete");
expect(beyond.coverage.status).toBe("incomplete");
expect(
beyond.limitations.some((line) =>
line.includes("exceeded the 1024-byte name budget"),
),
).toBe(true);
expect(
report.limitations.some((line) =>
line.includes("exceeded the 1024-byte name budget"),
),
).toBe(true);
});
});
describe("ELF table budgets", () => {
it("caps symbols in total across every symbol table, not per table", async () => {
const perTable = 40_000;
// Two zeroed SHT_SYMTAB tables, each under the cap, that exceed it together.
const bytes = withExtraSections(Buffer.alloc(perTable * 24), (at) => [
sectionHeader(2, at, perTable * 24, 5, 24),
sectionHeader(2, at, perTable * 24, 5, 24),
]);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.coverage.total_entries).toBeNull();
expect(report.limitations).toContain(
"Symbol table scanning stopped at 65536 symbols across all symbol tables. Later names may be missing.",
);
});
it("reports an unreadable symbol table as incomplete rather than absent", async () => {
// An SHT_SYMTAB whose size is not a whole number of 24-byte entries.
const bytes = withExtraSections(Buffer.alloc(32), (at) => [
sectionHeader(2, at, 25, 5, 24),
]);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"A symbol table is not stored in the file or its size is not a whole number of entries, so its names were not read.",
);
});
const repeatedRelocations = (): Buffer => {
const records = 65_536;
// Seventeen SHT_RELA headers over one zeroed range targeting .text, where no slot lives.
return withExtraSections(Buffer.alloc(records * 24), (at) =>
Array.from({ length: 17 }, () =>
sectionHeader(4, at, records * 24, 4, 24, 1),
),
);
};
it("bounds section relocation records in total across repeated headers", async () => {
const report = await read(repeatedRelocations());
expect(report.status).toBe("incomplete");
expect(report.coverage.total_entries).toBeNull();
expect(
report.limitations.filter((line) =>
line.startsWith(
"Section relocation scanning stopped at 1048576 records",
),
),
).toHaveLength(1);
// Records past the budget were not applied, but earlier slot results stand.
expect(row(report)).toEqual(row(await read(elfRelocatableCtors())));
});
it("reports a relocation section reached after the record budget is spent", async () => {
// Sixteen full headers spend the budget exactly; the seventeenth is not read.
const records = 65_536;
const bytes = withExtraSections(Buffer.alloc(records * 24), (at) => [
...Array.from({ length: 16 }, () =>
sectionHeader(4, at, records * 24, 4, 24, 1),
),
sectionHeader(4, at, 24, 4, 24, 1),
]);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(
report.limitations.filter((line) =>
line.startsWith(
"Section relocation scanning stopped at 1048576 records",
),
),
).toHaveLength(1);
});
it("observes cancellation during the section relocation walk", async () => {
const bytes = repeatedRelocations();
const controller = new AbortController();
setImmediate(() => controller.abort(new Error("cancelled by test")));
await expect(
readNativeInitializers(
bytes,
artifact(bytes),
{ path: "/artifacts/image", offset: 0, limit: 64 },
controller.signal,
),
).rejects.toThrow(ArtifactReaderFailure);
});
});
describe("Mach-O", () => {
it("decodes chained pointers, init offsets and raw terminators", async () => {
const report = await read(machoArm64Initializers());
expect(report).toMatchObject({
format: "mach-o",
status: "parsed",
architecture: "arm64",
slice: null,
elf_type: null,
});
expect(row(report)).toEqual([
{
kind: "mod_init",
value: "0x100001000",
section: "__TEXT,__text",
symbol: "_init",
resolution: "chained_fixup",
},
{
kind: "init_offsets",
value: "0x100001000",
section: "__TEXT,__text",
symbol: "_init",
resolution: "offset_table",
},
{
kind: "mod_term",
value: "0x100001100",
section: "__TEXT,__text",
symbol: "_term",
resolution: "raw",
},
]);
expect(report.entries[1]).toMatchObject({
slot_bytes: 4,
slot_file_offset: 0x4010,
});
});
it("marks the report incomplete when a chained-fixup slot cannot be decoded", async () => {
const bytes = machoArm64Initializers();
// A DYLD_CHAINED_PTR_64_OFFSET bind with ordinal 5, but the image has no imports.
bytes.writeBigUInt64LE((1n << 63n) | 5n, 0x4000);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
kind: "mod_init",
resolution: "unresolved",
value_va: null,
});
expect(report.status).toBe("incomplete");
expect(report.coverage).toMatchObject({
status: "incomplete",
total_entries: null,
});
expect(report.limitations).toEqual(
expect.arrayContaining([
"Mach-O chained fixup coverage is incomplete.",
"1 pointer slot(s) could not be decoded through Mach-O fixups and are listed as unresolved.",
]),
);
});
it("reports container file offsets for a selected universal slice", async () => {
const report = await read(machoArm64FatInitializers(), {
architecture: "arm64",
});
expect(report.slice).toMatchObject({
file_offset: 0x1000,
architecture: "arm64",
});
expect(report.entries[0]).toMatchObject({
slot_file_offset: 0x5000,
value_file_offset: 0x2000,
});
});
it("rejects bytes that are neither ELF nor Mach-O", async () => {
await expect(read(Buffer.alloc(128))).rejects.toThrow(BinaryFormatFailure);
});
});
describe("ELF repeated observations", () => {
it("tallies every over-long symbol name into one counted limitation", async () => {
const rows = 200;
const strings = Buffer.from(`\0${"x".repeat(1100)}\0`, "utf8");
const symbolsAt = 1104;
const symbols = Buffer.alloc(rows * 24);
for (let row = 0; row < rows; row += 1) {
symbols.writeUInt32LE(1, row * 24);
symbols.writeUInt8(0x12, row * 24 + 4);
symbols.writeUInt16LE(1, row * 24 + 6);
}
const payload = Buffer.concat([
strings,
Buffer.alloc(symbolsAt - strings.length),
symbols,
]);
// Section 9 is the string table, section 10 a symbol table whose every row names it.
const bytes = withExtraSections(payload, (at) => [
sectionHeader(3, at, strings.length, 0, 0),
sectionHeader(2, at + symbolsAt, rows * 24, 9, 24),
]);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(
report.limitations.filter((line) =>
line.includes("exceeded the 1024-byte name budget"),
),
).toEqual([
`${String(rows)} symbol name(s) exceeded the 1024-byte name budget and were not used.`,
]);
expect(report.limitations.length).toBeLessThanOrEqual(40);
});
it("tallies relocation sections with an unexpected entry size", async () => {
const sections = 40;
const bytes = withExtraSections(Buffer.alloc(48), (at) =>
Array.from({ length: sections }, () =>
sectionHeader(4, at, 48, 4, 16, 7),
),
);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(
report.limitations.filter((line) => line.includes("entry size")),
).toEqual([
`${String(sections)} relocation section(s) had an unexpected entry size and were not applied.`,
]);
expect(report.limitations.length).toBeLessThanOrEqual(40);
});
it("keeps distinct notes to the budget and counts the rest", async () => {
const notes = new InitializerNotes();
for (let index = 0; index < 100; index += 1) {
notes.add(`note ${String(index)}`);
notes.add(`note ${String(index)}`);
notes.tally("long-symbol-name");
notes.tally("relocation-entry-size");
}
const lines = notes.list();
expect(lines.slice(0, MAX_NATIVE_INITIALIZER_NOTES)).toEqual(
Array.from(
{ length: MAX_NATIVE_INITIALIZER_NOTES },
(_, index) => `note ${String(index)}`,
),
);
expect(lines.slice(MAX_NATIVE_INITIALIZER_NOTES)).toEqual([
"100 symbol name(s) exceeded the 1024-byte name budget and were not used.",
"100 relocation section(s) had an unexpected entry size and were not applied.",
// Repeats of an unlisted message cannot be told apart once the budget is full.
`${String(2 * (100 - MAX_NATIVE_INITIALIZER_NOTES))} further observation(s), repeats included, were not listed after the 24-note budget.`,
]);
// Standing limitations, the full note list and the withheld-name line fit the schema.
expect(
NATIVE_INITIALIZER_LIMITATIONS.length + lines.length + 1,
).toBeLessThanOrEqual(40);
expect(notes.recorded).toBe(400);
});
});
describe("ELF section lookups", () => {
it("names the smallest covering range and breaks ties by header order", async () => {
const index = buildAddressRangeIndex(
[
{
start: 0n,
end: 0x100n,
value: { name: "outer", size: 0x100, order: 0 },
},
{
start: 0x10n,
end: 0x20n,
value: { name: "inner", size: 0x10, order: 1 },
},
{
start: 0x10n,
end: 0x20n,
value: { name: "twin", size: 0x10, order: 2 },
},
{
start: 0x200n,
end: 0x200n,
value: { name: "empty", size: 0, order: 3 },
},
],
(left, right) => left.size - right.size || left.order - right.order,
);
const name = (address: bigint) => index.at(address)?.value.name ?? null;
expect(
[0n, 0xfn, 0x10n, 0x1fn, 0x20n, 0xffn, 0x100n, 0x200n].map(name),
).toEqual([
"outer",
"outer",
"inner",
"inner",
"outer",
"outer",
null,
null,
]);
});
it(
"resolves a full slot budget against a near-maximal section table in one pass",
{ tags: ["long-running"] },
async () => {
const base = elf64PieInitializers();
const slots = MAX_NATIVE_INITIALIZER_ENTRIES;
const payload = Buffer.alloc(slots * 8);
for (let slot = 0; slot < slots; slot += 1)
payload.writeBigUInt64LE(0x100n, slot * 8);
// Allocated, unnamed sections that cover every slot and target address.
const extra = Buffer.alloc((0xffff - 12) * 64);
for (let at = 0; at < extra.length; at += 64) {
extra.writeUInt32LE(1, at + 4);
extra.writeBigUInt64LE(2n, at + 8);
extra.writeBigUInt64LE(0x100000n, at + 32);
}
const bytes = Buffer.concat([
base,
payload,
base.subarray(0x800, 0x800 + 12 * 64),
extra,
]);
bytes.writeBigUInt64LE(BigInt(base.length + payload.length), 40);
bytes.writeUInt16LE(0xffff, 60);
// The single PT_LOAD maps the whole file; DT_INIT_ARRAY moves to the payload.
bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 32);
bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 40);
bytes.writeBigUInt64LE(BigInt(base.length), 0x300 + 2 * 16 + 8);
bytes.writeBigUInt64LE(BigInt(slots * 8), 0x300 + 3 * 16 + 8);
const started = performance.now();
const report = await read(bytes);
// A per-lookup header walk is about 4e9 comparisons here; the index is a few hundred ms.
expect(performance.now() - started).toBeLessThan(10_000);
expect(report.coverage.examined_entries).toBe(slots);
const slot = report.entries.find((entry) => entry.kind === "init_array");
expect(slot).toMatchObject({
value_va: "0x100",
slot_section: null,
section: { name: ".text" },
symbol: { name: "ctor_a" },
});
},
);
});
describe("Mach-O symbol tables", () => {
const SYMTAB_COMMAND = 24;
/** The arm64 fixture with `extra` LC_SYMTAB commands after its own and `payload` appended. */
const withSymtabs = (
payload: Buffer,
extra: (payloadAt: number, original: Buffer) => readonly Buffer[],
): Buffer => {
const base = machoArm64Initializers();
const original = Buffer.from(base.subarray(512, 512 + SYMTAB_COMMAND));
const commands = extra(base.length, original);
const bytes = Buffer.concat([base, payload]);
Buffer.concat(commands).copy(bytes, 512 + SYMTAB_COMMAND);
bytes.writeUInt32LE(4 + commands.length, 16);
bytes.writeUInt32LE(504 + commands.length * SYMTAB_COMMAND, 20);
return bytes;
};
const symtab = (original: Buffer, symoff: number, nsyms: number): Buffer => {
const command = Buffer.from(original);
command.writeUInt32LE(symoff, 8);
command.writeUInt32LE(nsyms, 12);
return command;
};
it("caps symbols in total across every LC_SYMTAB, not per command", async () => {
const perTable = 40_000;
const bytes = withSymtabs(
Buffer.alloc((perTable + 1) * 16),
(at, original) => [
symtab(original, at, perTable),
symtab(original, at + 16, perTable),
],
);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.coverage.total_entries).toBeNull();
expect(report.limitations).toContain(
"Symbol scanning stopped at 65536 symbols across all LC_SYMTAB commands. Later names may be missing.",
);
expect(report.entries[0]?.symbol?.name).toBe("_init");
});
it("ignores a repeated LC_SYMTAB over the same ranges", async () => {
const bytes = withSymtabs(Buffer.alloc(0), (_, original) => [original]);
const report = await read(bytes);
expect(report.status).toBe("parsed");
expect(row(report)).toEqual(row(await read(machoArm64Initializers())));
expect(report.limitations).toContain(
"A repeated LC_SYMTAB names the same symbol and string ranges and was ignored.",
);
});
});
describe("ELF relocation coverage", () => {
it("ignores no-op relocation records overlapping an initializer", async () => {
const base = elfRelocatableCtors();
const records = Buffer.alloc(48);
base.copy(records, 0, 0x300, 0x318);
base.copy(records, 24, 0x300, 0x318);
records.writeBigUInt64LE(0n, 32);
const bytes = Buffer.concat([base, records]);
bytes.writeBigUInt64LE(BigInt(base.length), 0x500 + 3 * 64 + 24);
bytes.writeBigUInt64LE(48n, 0x500 + 3 * 64 + 32);
expect(row(await read(bytes))).toEqual(row(await read(base)));
});
it("keeps slots unresolved when a section relocation table that reaches them is skipped", async () => {
const bytes = elfRelocatableCtors();
// .rela.ctors (section 3) and .rela.init_array (section 8) get a 16-byte entry size.
bytes.writeBigUInt64LE(16n, 0x500 + 3 * 64 + 56);
bytes.writeBigUInt64LE(16n, 0x500 + 8 * 64 + 56);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
// No stored zero becomes a raw value, and no .ctors slot is dropped as a sentinel.
expect(
report.entries.map((entry) => [entry.kind, entry.resolution]),
).toEqual([
["init_array", "unresolved"],
["ctors", "unresolved"],
["ctors", "unresolved"],
["ctors", "unresolved"],
]);
expect(report.entries.every((entry) => entry.value_va === null)).toBe(true);
});
it.each([0, 999])(
"keeps raw object slots unresolved when skipped relocation target %s is unknown",
async (target) => {
const bytes = elfRelocatableCtors();
// This invalid target makes it unknown whether the skipped table affects .ctors.
bytes.writeUInt32LE(target, 0x500 + 3 * 64 + 44);
bytes.writeBigUInt64LE(16n, 0x500 + 3 * 64 + 56);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
const ctors = report.entries.filter((entry) => entry.kind === "ctors");
expect(ctors).toHaveLength(3);
expect(
ctors.every(
(entry) =>
entry.resolution === "unresolved" && entry.value_va === null,
),
).toBe(true);
},
);
it("does not treat a dynamic write that overlaps half a slot as leaving it untouched", async () => {
const bytes = elf64PieInitializers();
// The leading null RELA record becomes R_X86_64_32 at 0x204, the upper half of slot 0x200.
bytes.writeBigUInt64LE(0x204n, 0x480);
bytes.writeBigUInt64LE(10n, 0x488);
const report = await read(bytes);
const slots = report.entries.filter((entry) => entry.kind === "init_array");
expect(slots.map((entry) => [entry.slot_va, entry.resolution])).toEqual([
["0x200", "unresolved"],
["0x208", "relative_addend"],
["0x210", "unresolved_import"],
]);
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"1 initializer slot(s) are partly overwritten or written more than once by dynamic relocations and are listed as unresolved.",
);
});
it("does not reapply a linked image's retained link-time REL record", async () => {
const base = elf32RelativeInitializers();
const bytes = Buffer.from(base);
bytes.writeUInt16LE(2, 16);
// Move the loader RELATIVE record off the slot so only the retained record reaches it.
bytes.writeUInt32LE(0x104, 0x180);
// A non-SHF_ALLOC SHT_REL with R_386_32 against `ctor` at slot 0x100, which already holds 0x80.
bytes.writeUInt32LE(0x100, 0x190);
bytes.writeUInt32LE((1 << 8) | 1, 0x194);
const header = Buffer.alloc(40);
header.writeUInt32LE(9, 4);
header.writeUInt32LE(0x190, 16);
header.writeUInt32LE(8, 20);
header.writeUInt32LE(5, 24);
header.writeUInt32LE(2, 28);
header.writeUInt32LE(8, 36);
header.copy(bytes, 0x280 + 8 * 40);
bytes.writeUInt16LE(9, 48);
const report = await read(bytes);
expect(row(report)).toEqual([
{
kind: "init_array",
value: "0x80",
section: ".text",
symbol: "ctor",
resolution: "raw",
},
]);
expect(report.status).toBe("parsed");
});
it("maps a resolved target whose first byte ends a load segment", async () => {
const bytes = elf64PieInitializers();
// The first init_array RELATIVE addend points at the last mapped byte.
bytes.writeBigInt64LE(BigInt(bytes.length - 1), 0x498 + 16);
const report = await read(bytes);
const slot = report.entries.find((entry) => entry.kind === "init_array");
expect(slot).toMatchObject({
value_va: `0x${(bytes.length - 1).toString(16)}`,
value_file_offset: bytes.length - 1,
});
});
it("pauses during a large dynamic relocation table and observes cancellation there", async () => {
const base = elf64PieInitializers();
const words = 64 * 1024;
const relr = Buffer.alloc(words * 8);
// One address entry for the fini slot, then empty bitmaps that only advance the cursor.
relr.writeBigUInt64LE(0x220n, 0);
for (let word = 1; word < words; word += 1)
relr.writeBigUInt64LE(1n, word * 8);
const bytes = Buffer.concat([base, relr]);
bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 32);
bytes.writeBigUInt64LE(BigInt(bytes.length), 64 + 40);
// DT_RELR and DT_RELRSZ are the twelfth and thirteenth dynamic entries.
bytes.writeBigUInt64LE(BigInt(base.length), 0x300 + 11 * 16 + 8);
bytes.writeBigUInt64LE(BigInt(relr.length), 0x300 + 12 * 16 + 8);
expect(row(await read(bytes))).toEqual(row(await read(base)));
const controller = new AbortController();
let turns = 0;
const tick = (): void => {
turns += 1;
if (turns === 2) controller.abort(new Error("cancelled by test"));
else setImmediate(tick);
};
setImmediate(tick);
await expect(
readNativeInitializers(
bytes,
artifact(bytes),
{ path: "/artifacts/image", offset: 0, limit: 64 },
controller.signal,
),
).rejects.toThrow(ArtifactReaderFailure);
});
});
/**
* elf64PieInitializers whose leading RELA record is read only as a section
* record: DT_RELA starts at the second one. With `fileEnd`, the load is also
* split at the preinit_array slot (0x240), so memory from `fileEnd` up to that
* slot is zero-fill.
*/
const sectionOnlyRecord = (
offset: bigint,
info: bigint,
fileEnd?: bigint,
): Buffer => {
const bytes = elf64PieInitializers();
bytes.writeBigUInt64LE(0x498n, 0x300 + 8 * 16 + 8);
bytes.writeBigUInt64LE(72n, 0x300 + 9 * 16 + 8);
bytes.writeBigUInt64LE(offset, 0x480);
bytes.writeBigUInt64LE(info, 0x488);
if (fileEnd === undefined) return bytes;
bytes.writeUInt16LE(3, 56);
bytes.writeBigUInt64LE(fileEnd, 64 + 32);
bytes.writeBigUInt64LE(0x240n, 64 + 40);
bytes.writeUInt32LE(1, 176);
bytes.writeUInt32LE(6, 180);
for (const field of [184, 192, 200]) bytes.writeBigUInt64LE(0x240n, field);
for (const field of [208, 216])
bytes.writeBigUInt64LE(BigInt(bytes.length - 0x240), field);
bytes.writeBigUInt64LE(1n, 224);
return bytes;
};
/** R_X86_64_COPY of dynamic symbol 1, whose `st_size` is at 0x548. */
const COPY_OF_SYMBOL_1 = (1n << 32n) | 5n;
const SYMBOL_1_SIZE = 0x538 + 16;
describe("ELF COPY relocations and writes without file bytes", () => {
it.each([
["file-backed bytes", 0x700n],
["zero-fill memory", 0xc10n],
])(
"keeps a stored word when a COPY relocation writes %s elsewhere",
async (_where, destination) => {
const base = elf64PieInitializers();
const bytes = Buffer.from(base);
// The load segment gains a zero-fill tail from 0xc00, as .bss does.
bytes.writeBigUInt64LE(0xd00n, 64 + 40);
// The leading null RELA record becomes R_X86_64_COPY of dynamic symbol
// 1, which now records an 8-byte object.
bytes.writeBigUInt64LE(destination, 0x480);
bytes.writeBigUInt64LE((1n << 32n) | 5n, 0x488);
bytes.writeBigUInt64LE(8n, 0x538 + 16);
const report = await read(bytes);
expect(row(report)).toEqual(row(await read(base)));
expect(report.status).toBe("parsed");
expect(report.limitations).toEqual((await read(base)).limitations);
},
);
it("keeps a slot inside the bytes a COPY relocation writes unresolved", async () => {
const bytes = elf64PieInitializers();
// A 16-byte COPY at 0x238 runs through the preinit_array slot at 0x240.
bytes.writeBigUInt64LE(0x238n, 0x480);
bytes.writeBigUInt64LE((1n << 32n) | 5n, 0x488);
bytes.writeBigUInt64LE(16n, 0x538 + 16);
const report = await read(bytes);
expect(
report.entries.map((entry) => [entry.slot_va, entry.resolution]),
).toEqual([
["0x240", "unresolved"],
["0x308", "raw"],
["0x200", "relative_addend"],
["0x208", "relative_addend"],
["0x210", "unresolved_import"],
["0x318", "raw"],
["0x220", "relr"],
]);
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"1 initializer slot(s) are partly overwritten or written more than once by dynamic relocations and are listed as unresolved.",
);
});
it("keeps stored words unresolved when a COPY relocation's size is unreadable", async () => {
const bytes = elf64PieInitializers();
bytes.writeBigUInt64LE(0x700n, 0x480);
bytes.writeBigUInt64LE((1n << 32n) | 5n, 0x488);
bytes.writeBigUInt64LE(8n, 0x538 + 16);
// DT_SYMENT is the eighteenth dynamic entry; 16 is not an ELF64 symbol size.
bytes.writeBigUInt64LE(16n, 0x300 + 17 * 16 + 8);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
kind: "preinit_array",
value_va: null,
resolution: "unresolved",
});
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"ELF COPY relocation write size is not established",
);
});
it.each([
["an 8-byte write that runs into the slot", 1n, "unresolved", "incomplete"],
["a 4-byte write that ends at the slot", 10n, "raw", "parsed"],
] as const)(
"judges a section record without file bytes by the memory it overlaps: %s",
async (_case, kind, resolution, status) => {
// The first segment ends in four zero-fill bytes at 0x23c, where the
// record writes, just below the slot at 0x240.
const report = await read(
sectionOnlyRecord(0x23cn, (1n << 32n) | kind, 0x23cn),
);
expect(report.entries[0]).toMatchObject({
kind: "preinit_array",
slot_va: "0x240",
value_va: resolution === "raw" ? "0x100" : null,
resolution,
});
expect(report.status).toBe(status);
},
);
});
describe("ELF COPY relocations read as section records", () => {
it.each([
["ends at the slot", 16n, "raw", "parsed"],
["reaches the slot's first byte", 17n, "unresolved", "incomplete"],
["runs through the slot", 24n, "unresolved", "incomplete"],
] as const)(
"sizes a COPY into zero-fill memory by its symbol: %s",
async (_case, size, resolution, status) => {
// Zero-fill runs from the record at 0x230 up to the slot at 0x240.
const bytes = sectionOnlyRecord(0x230n, COPY_OF_SYMBOL_1, 0x230n);
bytes.writeBigUInt64LE(size, SYMBOL_1_SIZE);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
kind: "preinit_array",
slot_va: "0x240",
value_va: resolution === "raw" ? "0x100" : null,
resolution,
});
expect(report.status).toBe(status);
},
);
it.each([
["ends at the slot", 8n, "raw", "parsed"],
["runs through the slot", 16n, "unresolved", "incomplete"],
] as const)(
"sizes a COPY over file bytes by its symbol: %s",
async (_case, size, resolution, status) => {
const bytes = sectionOnlyRecord(0x238n, COPY_OF_SYMBOL_1);
bytes.writeBigUInt64LE(size, SYMBOL_1_SIZE);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
kind: "preinit_array",
slot_va: "0x240",
value_va: resolution === "raw" ? "0x100" : null,
resolution,
});
expect(report.status).toBe(status);
expect(
report.limitations.includes(
"1 initializer slot(s) are partly overwritten or relocated more than once by section relocations and are listed as unresolved.",
),
).toBe(resolution === "unresolved");
},
);
it.each([
["names symbol 0", (bytes: Buffer) => bytes.writeBigUInt64LE(5n, 0x488)],
[
"names a symbol past its table",
(bytes: Buffer) => bytes.writeBigUInt64LE((2n << 32n) | 5n, 0x488),
],
[
"links to a section that is not a symbol table",
// .rela.dyn is section 6; .dynstr is section 8.
(bytes: Buffer) => bytes.writeUInt32LE(8, 0x800 + 6 * 64 + 40),
],
])(
"keeps stored words unresolved when a section COPY %s",
async (_case, change) => {
// Neither destination is within a word of a slot: file bytes at 0x700
// and zero-fill memory at 0xc10.
for (const destination of [0x700n, 0xc10n]) {
const bytes = sectionOnlyRecord(destination, COPY_OF_SYMBOL_1);
bytes.writeBigUInt64LE(0xd00n, 64 + 40);
bytes.writeBigUInt64LE(8n, SYMBOL_1_SIZE);
change(bytes);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
kind: "preinit_array",
value_va: null,
resolution: "unresolved",
});
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"A section COPY relocation's write size is not established; slots without a relocation are unresolved.",
);
}
},
);
it("limits section COPY sizes together to the file byte count", async () => {
// One or two section-only COPY records of dynamic symbol 1 write zero-fill
// memory far above every slot of this 0xc00-byte file.
const sized = (size: bigint, records: 1 | 2) => {
const bytes = sectionOnlyRecord(0xc10n, COPY_OF_SYMBOL_1);
bytes.writeBigUInt64LE(0x4000n, 64 + 40);
bytes.writeBigUInt64LE(size, SYMBOL_1_SIZE);
if (records === 2) {
// DT_RELA starts at the third record, so the second one is read only
// as a section record too.
bytes.writeBigUInt64LE(0x4b0n, 0x300 + 8 * 16 + 8);
bytes.writeBigUInt64LE(48n, 0x300 + 9 * 16 + 8);
bytes.writeBigUInt64LE(0x2000n, 0x498);
bytes.writeBigUInt64LE(COPY_OF_SYMBOL_1, 0x4a0);
}
return read(bytes);
};
for (const [size, records] of [
[0xc00n, 1],
[0x600n, 2],
] as const) {
const within = await sized(size, records);
expect(within.entries[0]?.resolution).toBe("raw");
expect(within.status).toBe("parsed");
}
for (const [size, records] of [
[0xc01n, 1],
[0x601n, 2],
] as const) {
const beyond = await sized(size, records);
expect(beyond.entries[0]?.resolution).toBe("unresolved");
expect(beyond.status).toBe("incomplete");
expect(beyond.limitations).toContain(
"Section COPY relocation sizes exceed the file byte count allowance; slots without a relocation are unresolved.",
);
}
});
it("takes the one word of a RELATIVE record as an established extent", async () => {
// R_X86_64_RELATIVE into zero-fill memory, ending a word below the slot.
const report = await read(sectionOnlyRecord(0x230n, 8n, 0x230n));
expect(report.entries[0]).toMatchObject({
kind: "preinit_array",
slot_va: "0x240",
value_va: "0x100",
resolution: "raw",
});
expect(report.status).toBe("parsed");
});
it("does not take the word assumed for an unknown kind as proof that a slot beside zero-fill memory is untouched", async () => {
// Kind 99 has no known width. Its record sits 16 bytes below the slot.
const report = await read(
sectionOnlyRecord(0x230n, (1n << 32n) | 99n, 0x230n),
);
expect(report.entries[0]).toMatchObject({
kind: "preinit_array",
slot_va: "0x240",
value_va: null,
resolution: "unresolved",
});
expect(report.status).toBe("incomplete");
});
it("keeps the one-word assumption for a COPY kind in a relocatable object", async () => {
const bytes = elfRelocatableCtors();
// The .ctors record against `ctor`, a symbol of size 0, becomes kind 5.
bytes.writeBigUInt64LE(COPY_OF_SYMBOL_1, 0x308);
const report = await read(bytes);
expect(
report.entries.map((entry) => [entry.kind, entry.resolution]),
).toEqual([
["init_array", "symbol_addend"],
["ctors", "unresolved"],
]);
});
});
describe("Unreadable symbol names", () => {
it("reports an ELF name offset outside its string table as incomplete", async () => {
const bytes = elf64PieInitializers();
// ctor_a, the first .symtab entry after the null symbol.
bytes.writeUInt32LE(0x7fff, 0x598);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.entries[0]?.symbol).toBeNull();
expect(report.limitations).toContain(
"1 symbol name(s) could not be read because the string table is missing or the name offset is outside it.",
);
});
it("reports a Mach-O name index outside its string table as incomplete", async () => {
const bytes = machoArm64Initializers();
bytes.writeUInt32LE(0x7fff, 0x3000);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.entries[0]?.symbol).toBeNull();
expect(report.limitations).toContain(
"1 symbol name(s) could not be read because the string table is missing or the name offset is outside it.",
);
});
});
describe("Mach-O fixup sources", () => {
it("yields and cancels while expanding legacy rebase runs", async () => {
const bytes = machoArm64LegacyRebaseInitializers();
const run = Buffer.from([0x11, 0x21, 0, 0x60, 0x80, 0x10]);
const stream = Buffer.concat([
...Array.from({ length: 12 }, () => run),
Buffer.from([0]),
]);
stream.copy(bytes, 0x3300);
bytes.writeUInt32LE(stream.length, 548);
const controller = new AbortController();
setImmediate(() => controller.abort(new Error("cancel during rebase")));
await expect(
readNativeInitializers(
bytes,
artifact(bytes),
{ path: "/artifacts/image", offset: 0, limit: 64 },
controller.signal,
),
).rejects.toThrow(ArtifactReaderFailure);
});
it("keeps both initializer slots unresolved when a legacy bind crosses their boundary", async () => {
const bytes = machoArm64LegacyRebaseInitializers();
const bind = Buffer.from([
0x40,
...Buffer.from("_foo\0"),
0x51,
0x71,
0x04,
0x90,
0x00,
]);
bytes.writeBigUInt64LE(16n, 256 + 40);
bytes.writeUInt32LE(0, 548);
bytes.writeUInt32LE(0x3300, 552);
bytes.writeUInt32LE(bind.length, 556);
bind.copy(bytes, 0x3300);
const report = await read(bytes);
expect(report.entries.slice(0, 2).map((entry) => entry.resolution)).toEqual(
["unresolved", "unresolved"],
);
expect(report.status).toBe("incomplete");
});
it("does not resolve an initializer from a section offset that disagrees with its load mapping", async () => {
const bytes = machoArm64LegacyRebaseInitializers();
bytes.writeUInt32LE(0x4030, 256 + 48);
bytes.writeBigUInt64LE(0x100001100n, 0x4030);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
resolution: "unresolved",
value_va: null,
});
expect(report.status).toBe("incomplete");
});
it("labels an LC_DYLD_INFO rebase as a legacy rebase, not a chained fixup", async () => {
const report = await read(machoArm64LegacyRebaseInitializers());
expect(report.status).toBe("parsed");
expect(row(report)[0]).toEqual({
kind: "mod_init",
value: "0x100001000",
section: "__TEXT,__text",
symbol: "_init",
resolution: "dyld_info_rebase",
});
});
it("keeps a 32-bit pointer slot unresolved when undecoded bind opcodes could replace it", async () => {
expect(row(await read(macho32Initializers(0)))).toEqual([
{
kind: "mod_init",
value: "0x1100",
section: "__TEXT,__text",
symbol: "_init",
resolution: "raw",
},
]);
const report = await read(macho32Initializers(8));
expect(report.entries[0]).toMatchObject({
resolution: "unresolved",
value_va: null,
});
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"LC_DYLD_INFO bind opcodes are not decoded for this image, so a pointer slot's stored word may be replaced by a bound symbol at load time.",
);
});
it("applies object-file section relocations instead of reading the stored addend as a target", async () => {
const report = await read(machoX8664ObjectInitializers());
expect(row(report)).toEqual([
{
kind: "mod_init",
value: "0x10",
section: "__TEXT,__text",
symbol: "_startup",
resolution: "symbol_addend",
},
{
kind: "mod_init",
value: "0x0",
section: "__TEXT,__text",
symbol: "_before",
resolution: "raw",
},
]);
expect(report.status).toBe("parsed");
});
it("decodes UTF-8 Mach-O nlist names and retains invalid text as unknown", async () => {
const image = machoX8664ObjectInitializers();
const strings = Buffer.from("\0_before\0_démarre\0", "utf8");
strings.copy(image, 0x270);
image.writeUInt32LE(strings.length, 284);
const report = await read(image);
expect(report.entries[0]?.symbol?.name).toBe("_démarre");
const invalid = Buffer.from(image);
invalid[0x270 + 10] = 0xff;
const unknown = await read(invalid);
expect(unknown.entries[0]?.symbol).toBeNull();
expect(unknown.status).toBe("incomplete");
});
it("keeps both object-file slots unresolved when a relocation crosses their boundary", async () => {
const bytes = machoX8664ObjectInitializers();
bytes.writeUInt32LE(4, 0x248);
const report = await read(bytes);
expect(report.entries.map((entry) => entry.resolution)).toEqual([
"unresolved",
"unresolved",
]);
expect(report.status).toBe("incomplete");
});
it("keeps an object-file slot unresolved under a relocation it does not apply", async () => {
const bytes = machoX8664ObjectInitializers();
// The first entry becomes X86_64_RELOC_SUBTRACTOR (type 5).
bytes.writeUInt32LE(1 | (3 << 25) | (1 << 27) | (5 << 28), 0x244);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
resolution: "unresolved",
value_va: null,
});
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"1 object-file slot(s) have section relocations this reader does not apply and are listed as unresolved.",
);
});
});
describe("Initializer target semantics", () => {
it("keeps an absolute relocation against an IFUNC symbol unresolved", async () => {
const bytes = elfRelocatableCtors();
// `ctor` becomes a global STT_GNU_IFUNC, whose value is its resolver.
bytes.writeUInt8(0x1a, 0x338 + 4);
const report = await read(bytes);
const ctor = report.entries.find((entry) => entry.kind === "ctors");
expect(ctor).toMatchObject({
slot_va: "0x8",
resolution: "unresolved",
value_va: null,
});
});
it("keeps a slot a narrower section relocation partly overwrites unresolved", async () => {
const bytes = elfRelocatableCtors();
// The .ctors record becomes R_X86_64_32 at byte 4 of slot 0x8.
bytes.writeBigUInt64LE(0xcn, 0x300);
bytes.writeBigUInt64LE((1n << 32n) | 10n, 0x308);
const report = await read(bytes);
const ctors = report.entries.filter((entry) => entry.kind === "ctors");
expect(ctors.map((entry) => [entry.slot_va, entry.resolution])).toEqual([
["0x8", "unresolved"],
]);
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"1 initializer slot(s) are partly overwritten or relocated more than once by section relocations and are listed as unresolved.",
);
});
it("clears the ELF ARM Thumb bit when locating a target but keeps the callable value", async () => {
const bytes = elf32RelativeInitializers();
bytes.writeUInt16LE(40, 18);
// R_ARM_RELATIVE with in-place addend 0x81, and a Thumb `ctor` at 0x81.
bytes.writeUInt32LE(23, 0x184);
bytes.writeUInt32LE(0x81, 0x100);
bytes.writeUInt32LE(0x81, 0x1b4);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
value_va: "0x81",
value_file_offset: 0x80,
section: { name: ".text" },
symbol: { name: "ctor" },
resolution: "relative_addend",
});
});
it("clears the Mach-O ARM Thumb bit when locating and naming a target", async () => {
const bytes = macho32Initializers(0);
bytes.writeUInt32LE(12, 4);
bytes.writeUInt32LE(0x1101, 0x1000);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
value_va: "0x1101",
value_file_offset: 0x100,
section: { name: "__TEXT,__text" },
symbol: { name: "_init" },
resolution: "raw",
});
});
it("rejects an arm64_32 image that declares a 64-bit header", async () => {
const bytes = machoArm64Initializers();
bytes.writeUInt32LE(0x0200000c, 4);
await expect(read(bytes)).rejects.toThrow(BinaryFormatFailure);
});
});
describe("ELF initializer relocation identity", () => {
it("reuses one section relocation for repeated declarations of the same virtual slot", async () => {
const bytes = elf64AliasedInitializers();
bytes.writeBigUInt64LE(0x200n, 0x300 + 2 * 16 + 8);
const report = await read(bytes);
expect(report.status).toBe("parsed");
expect(
report.entries
.filter((entry) => entry.slot_va === "0x200")
.map((entry) => [entry.value_va, entry.resolution]),
).toEqual([
["0x100", "relative_addend"],
["0x100", "relative_addend"],
]);
});
it("does not attribute an i386 REL's in-place addend to an unrelocated alias", async () => {
const bytes = elf32RelativeInitializers();
bytes.writeUInt16LE(3, 44);
const alias = 116;
bytes.writeUInt32LE(1, alias);
bytes.writeUInt32LE(0x100, alias + 4);
bytes.writeUInt32LE(0x1100, alias + 8);
bytes.writeUInt32LE(4, alias + 16);
bytes.writeUInt32LE(4, alias + 20);
bytes.writeUInt32LE(6, alias + 24);
bytes.writeUInt32LE(1, alias + 28);
bytes.writeUInt32LE(0x1100, 0x124);
// Only the section declaration still names the REL table.
for (let index = 2; index <= 4; index++)
bytes.writeInt32LE(0x70000000 + index, 0x120 + index * 8);
const unrelocated = await read(bytes);
expect(unrelocated.entries[0]).toMatchObject({
slot_va: "0x1100",
value_va: "0x80",
resolution: "raw",
});
// Moving the same REL to that address uses its own stored in-place word.
bytes.writeUInt32LE(0x1100, 0x180);
expect((await read(bytes)).entries[0]).toMatchObject({
slot_va: "0x1100",
value_va: "0x80",
resolution: "relative_addend",
});
});
it.each([false, true])(
"keeps section RELA writes on their virtual-address owner (both aliases relocated: %s)",
async (both) => {
const bytes = elf64AliasedInitializers();
if (both) bytes.writeBigUInt64LE(0x1200n, 0x4b0);
const report = await read(bytes);
expect(report.status).toBe("parsed");
expect(
report.entries
.filter((entry) =>
["preinit_array", "init_array"].includes(entry.kind),
)
.map((entry) => [entry.slot_va, entry.value_va, entry.resolution]),
).toEqual([
["0x200", "0x100", "relative_addend"],
["0x1200", both ? "0x110" : "0x0", both ? "relative_addend" : "raw"],
]);
},
);
it("keeps a partial relocation on one alias from clobbering the other", async () => {
const bytes = elf64AliasedInitializers();
bytes.writeBigUInt64LE(0x1204n, 0x4b0);
bytes.writeBigUInt64LE(10n, 0x4b8); // R_X86_64_32, overlapping the alias's last four bytes.
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(
report.entries.find((entry) => entry.kind === "preinit_array"),
).toMatchObject({
value_va: "0x100",
resolution: "relative_addend",
});
expect(
report.entries.find((entry) => entry.kind === "init_array"),
).toMatchObject({
value_va: null,
resolution: "unresolved",
});
});
it("keeps relocatable section identities distinct when their source bytes alias", async () => {
const bytes = elfRelocatableCtors();
// The .init_array bytes now alias .ctors's relocated middle slot.
bytes.writeBigUInt64LE(0x208n, 0x500 + 7 * 64 + 24);
const report = await read(bytes);
expect(report.status).toBe("parsed");
expect(
report.entries.map((entry) => [
entry.kind,
entry.slot_file_offset,
entry.value_va,
entry.symbol?.name,
]),
).toEqual([
["init_array", 0x208, "0x8", "init_b"],
["ctors", 0x208, "0x0", "ctor"],
]);
});
});
describe("Initializer address mapping", () => {
it("reports no target file offset when load segments map it ambiguously", async () => {
const bytes = elf64PieInitializers();
// A third PT_LOAD maps 0x100..0x140 from file offset 0x200, conflicting with the identity load.
bytes.writeUInt16LE(3, 56);
bytes.writeUInt32LE(1, 176);
bytes.writeUInt32LE(5, 180);
bytes.writeBigUInt64LE(0x200n, 184);
bytes.writeBigUInt64LE(0x100n, 192);
bytes.writeBigUInt64LE(0x100n, 200);
bytes.writeBigUInt64LE(0x40n, 208);
bytes.writeBigUInt64LE(0x40n, 216);
bytes.writeBigUInt64LE(1n, 224);
const report = await read(bytes);
const preinit = report.entries.find(
(entry) => entry.kind === "preinit_array",
);
expect(preinit).toMatchObject({
value_va: "0x100",
value_file_offset: null,
});
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"An address has ambiguous file mappings (overlapping load segments with different file offsets, or file and zero-fill memory), so its file offset is not reported.",
);
});
/** ELF64 ET_EXEC with `loads` identical identity PT_LOADs and an `.init_array` of `slots` slots, each pointing at 0x40. */
const manyLoads = (loads: number, slots: number): Buffer => {
const arrayAt = 0x1000;
const shstr = Buffer.from("\0.init_array\0.shstrtab\0", "utf8");
const stringsAt = arrayAt + slots * 8;
const headersAt = stringsAt + 0x40;
const bytes = Buffer.alloc(headersAt + 3 * 64);
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1], 0);
bytes.writeUInt16LE(2, 16);
bytes.writeUInt16LE(62, 18);
bytes.writeUInt32LE(1, 20);
bytes.writeBigUInt64LE(64n, 32);
bytes.writeBigUInt64LE(BigInt(headersAt), 40);
bytes.writeUInt16LE(64, 52);
bytes.writeUInt16LE(56, 54);
bytes.writeUInt16LE(loads, 56);
bytes.writeUInt16LE(64, 58);
bytes.writeUInt16LE(3, 60);
bytes.writeUInt16LE(2, 62);
for (let load = 0; load < loads; load += 1) {
const at = 64 + load * 56;
bytes.writeUInt32LE(1, at);
bytes.writeUInt32LE(5, at + 4);
bytes.writeBigUInt64LE(BigInt(bytes.length), at + 32);
bytes.writeBigUInt64LE(BigInt(bytes.length), at + 40);
bytes.writeBigUInt64LE(1n, at + 48);
}
for (let slot = 0; slot < slots; slot += 1)
bytes.writeBigUInt64LE(0x40n, arrayAt + slot * 8);
shstr.copy(bytes, stringsAt);
const header = (
index: number,
name: number,
type: number,
offset: number,
size: number,
flags: number,
): void => {
const at = headersAt + index * 64;
bytes.writeUInt32LE(name, at);
bytes.writeUInt32LE(type, at + 4);
bytes.writeBigUInt64LE(BigInt(flags), at + 8);
bytes.writeBigUInt64LE(BigInt(offset), at + 16);
bytes.writeBigUInt64LE(BigInt(offset), at + 24);
bytes.writeBigUInt64LE(BigInt(size), at + 32);
bytes.writeBigUInt64LE(1n, at + 48);
};
header(1, 1, 14, arrayAt, slots * 8, 3);
header(2, 13, 3, stringsAt, shstr.length, 0);
return bytes;
};
it("builds one mapping index per image instead of scanning every load per slot", async () => {
// Calls into the mapping module made from other modules, per inspection.
const mappingCalls = async (slots: number) => {
mapping.scans.mockClear();
mapping.indexes.mockClear();
const report = await read(manyLoads(4, slots));
expect(report.coverage.examined_entries).toBe(slots);
expect(report.entries[0]).toMatchObject({
value_va: "0x40",
value_file_offset: 0x40,
});
return {
scans: mapping.scans.mock.calls.length,
indexes: mapping.indexes.mock.calls.length,
};
};
const few = await mappingCalls(8);
const many = await mappingCalls(64);
// A per-slot linear scan grows with the slot count; indexed lookups do not.
expect(many).toEqual(few);
expect(many.indexes).toBeGreaterThan(0);
});
it("reports a Mach-O bind name over the name budget as incomplete", async () => {
const bytes = machoArm64Initializers();
const name = `_${"b".repeat(1100)}`;
// Rebuild the chained-fixups payload with one DYLD_CHAINED_IMPORT bound by flat lookup.
const starts = Buffer.from(bytes.subarray(0x3200 + 28, 0x3200 + 64));
const header = Buffer.alloc(28);
header.writeUInt32LE(28, 4);
header.writeUInt32LE(28 + starts.length, 8);
header.writeUInt32LE(28 + starts.length + 4, 12);
header.writeUInt32LE(1, 16);
header.writeUInt32LE(1, 20);
const entry = Buffer.alloc(4);
entry.writeUInt32LE(0xfe, 0);
const chained = Buffer.concat([
header,
starts,
entry,
Buffer.from(`${name}\0`),
]);
chained.copy(bytes, 0x3200);
bytes.writeUInt32LE(chained.length, 508);
bytes.writeBigUInt64LE(1n << 63n, 0x4000);
const report = await read(bytes);
expect(report.entries[0]).toMatchObject({
kind: "mod_init",
resolution: "unresolved_import",
symbol: null,
});
expect(report.status).toBe("incomplete");
expect(report.limitations).toContain(
"1 symbol name(s) exceeded the 1024-byte name budget and were not used.",
);
});
});
it("keeps a slot touched by an overrun section relocation unresolved", async () => {
const bytes = elfRelocatableCtors();
bytes.writeBigUInt64LE(4n, 0x318);
bytes.writeBigUInt64LE(0x10n, 0x220);
const report = await readNativeInitializers(bytes, artifact(bytes), {
path: "/artifacts/image",
offset: 0,
limit: 64,
});
expect(report.coverage.status).toBe("incomplete");
expect(
report.entries.find((entry) => entry.kind === "init_array"),
).toMatchObject({ resolution: "unresolved", value_va: null });
});
it("keeps identical repeated dynamic writes unresolved", async () => {
const bytes = elf64PieInitializers();
bytes.writeBigUInt64LE(0x200n, 0x480);
bytes.writeBigUInt64LE(8n, 0x488);
bytes.writeBigInt64LE(0x100n, 0x490);
const report = await readNativeInitializers(bytes, artifact(bytes), {
path: "/artifacts/image",
offset: 0,
limit: 64,
});
expect(
report.entries.find((entry) => entry.slot_va === "0x200"),
).toMatchObject({ resolution: "unresolved", value_va: null });
});
it("retains unknown coverage when sparse bind lookups exhaust their allowance", async () => {
const bytes = machoSparseBindInitializers(4097);
const report = await readNativeInitializers(bytes, artifact(bytes), {
path: "/artifacts/image",
offset: 0,
limit: 1,
});
expect(report.coverage.status).toBe("incomplete");
expect(report.entries[0]).toMatchObject({
resolution: "unresolved",
value_va: null,
});
expect(report.limitations.join("\n")).toContain(
"bind lookup candidate allowance exhausted",
);
});
it("yields and observes cancellation during sparse bind analysis", async () => {
const bytes = machoSparseBindInitializers(2048);
const controller = new AbortController();
setImmediate(() => controller.abort());
await expect(
readNativeInitializers(
bytes,
artifact(bytes),
{ path: "/artifacts/image", offset: 0, limit: 1 },
controller.signal,
),
).rejects.toThrow(ArtifactReaderFailure);
});
it("keeps a linked section-fallback slot unresolved when its load mapping disagrees", async () => {
const bytes = elf64PieInitializers();
for (let at = 0x300; at < 0x430; at += 16) {
if (bytes.readBigUInt64LE(at) === 25n)
bytes.writeBigUInt64LE(0x70000000n, at);
}
bytes.writeBigUInt64LE(0x260n, 0x800 + 3 * 64 + 24);
bytes.writeBigUInt64LE(0x110n, 0x260);
const report = await readNativeInitializers(bytes, artifact(bytes), {
path: "/artifacts/image",
offset: 0,
limit: 64,
});
expect(report.coverage.status).toBe("incomplete");
expect(
report.entries.find((entry) => entry.kind === "init_array"),
).toMatchObject({
resolution: "unresolved",
value_va: null,
slot_file_offset: 0x260,
});
});