Files
reverse-engineering-anything/src/native/pe/PeLoadConfigParser.test.ts
T

340 lines
12 KiB
TypeScript

import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { describe, expect, it } from "vitest";
import {
inspectPeLoadConfigInputSchema,
peLoadConfigSchema,
type PeLoadConfigField,
} from "../../domain/native/peLoadConfig.js";
import {
parsePeLoadConfig,
PeLoadConfigLimitFailure,
} from "./PeLoadConfigParser.js";
import {
peLoadConfigImage,
peManySectionsImage,
type PeLoadConfigFixtureOptions,
} from "./PeLoadConfig.fixture.js";
const parse = (
options: PeLoadConfigFixtureOptions = {},
max_callbacks = 64,
) => {
const bytes = peLoadConfigImage(options);
return parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({
path: "/sample.exe",
max_callbacks,
}),
);
};
describe.each([32, 64] as const)("PE%s load configuration", (bits) => {
it("locates callbacks and separates flag claims", async () => {
const report = await parse({ bits });
expect(peLoadConfigSchema.safeParse(report).success).toBe(true);
expect(report.tls?.callbacks).toHaveLength(3);
// The callback array starts at file offset 0x800 and includes its null slot.
expect(report.tls?.callback_array_locations).toEqual([
{ offset: 0x800, bytes: bits === 32 ? 16 : 32 },
]);
expect(report.tls?.callbacks[0]).toMatchObject({
rva: 0x2000,
file_offset: 0x1600,
section: ".text",
});
expect(report.tls?.callbacks[2]).toMatchObject({
rva: 0x9000,
file_offset: null,
section: null,
});
expect(report.load_config?.guard_flags?.raw).toBe(0x500);
expect(report.load_config?.inferences[0]?.evidence_kind).toBe("inferred");
expect(report.load_config?.se_handler_count).toBe(bits === 32 ? 2 : null);
});
it("honors Size even when directory bytes contain newer fields", async () => {
const report = await parse({
bits,
loadConfigSize: 64,
loadConfigDirectorySize: 320,
});
expect(report.load_config?.guard_flags).toBeNull();
expect(report.load_config?.security_cookie === null).toBe(bits === 64);
});
it.each(["empty", "absent"] as const)(
"accepts %s callbacks",
async (callbacks) => {
expect((await parse({ bits, callbacks })).tls?.callbacks).toEqual([]);
},
);
it("accepts exactly the callback budget and rejects overflow", async () => {
expect(
(await parse({ bits, callbackCount: 1 }, 1)).examined_callbacks,
).toBe(1);
await expect(parse({ bits }, 1)).rejects.toBeInstanceOf(
PeLoadConfigLimitFailure,
);
});
it("reports absent directories", async () => {
expect(await parse({ bits, tls: false, loadConfig: false })).toMatchObject({
tls: null,
load_config: null,
});
});
it.each([
"tls-unmapped",
"tls-truncated",
"tls-half",
"load-config-oversize",
"load-config-truncated",
"load-config-half",
"raw-data-inverted",
"callback-unterminated",
"ambiguous-section",
] as const)("rejects %s", async (malformed) => {
await expect(parse({ bits, malformed })).rejects.toThrow();
});
});
it("reads Size bytes past a legacy 64-byte PE32 directory", async () => {
const report = await parse({ bits: 32, loadConfigDirectorySize: 64 });
expect(report.load_config?.directory.size).toBe(64);
expect(report.load_config?.size).toBeGreaterThan(64);
expect(report.load_config?.structure_location).toEqual({
offset: report.load_config?.directory.file_offset,
bytes: report.load_config?.size,
});
expect(report.load_config?.se_handler_count).toBe(2);
expect(report.load_config?.guard_flags?.raw).toBe(0x500);
});
it("preserves pointer precision and unknown flag bits", async () => {
const bytes = peLoadConfigImage();
// PE64 load config at 0x640; XFG slot at +280, GuardFlags at +144.
bytes.writeBigUInt64LE(0xfedcba9876543210n, 0x640 + 280);
bytes.writeUInt32LE(0xf0200100, 0x640 + 144);
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
);
expect(report.load_config?.guard_xfg_check_function_pointer).toEqual({
va: "0xfedcba9876543210",
rva: null,
file_offset: null,
section: null,
});
expect(report.load_config?.guard_flags).toMatchObject({
unrecognized: 0x200000,
cf_function_table_stride: 19,
});
});
it("honors cancellation before parsing", async () => {
const bytes = peLoadConfigImage();
const reason = { caller: "before PE load config parsing" };
const failure = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
AbortSignal.abort(reason),
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
expect(failure).toMatchObject({ reason: "cancelled" });
if (!(failure instanceof ArtifactReaderFailure)) throw failure;
expect(failure.cause).toBe(reason);
});
it("retains a callback in virtual-only section storage with unknown file offset", async () => {
expect(
(await parse({ zeroFillCallback: true })).tls?.callbacks[0],
).toMatchObject({ rva: 0x2200, file_offset: null, section: ".text" });
});
it("resolves callback sections in bounded time for the largest section table", async () => {
const bytes = peManySectionsImage(65535, 4096);
const started = performance.now();
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({
path: "/sample.exe",
max_callbacks: 4096,
}),
);
// A per-callback scan of every section takes several seconds here.
expect(performance.now() - started).toBeLessThan(1_000);
expect(report.tls?.callbacks).toHaveLength(4096);
expect(report.tls?.callbacks.every(({ section }) => section === ".v")).toBe(
true,
);
});
/**
* IMAGE_LOAD_CONFIG_DIRECTORY32/64 member offsets transcribed from the PE/COFF
* specification and llvm/Object/COFF.h (coff_load_configuration32/64), kept
* independent of the parser's layout table.
*/
const SPEC_FIELDS: Record<
32 | 64,
readonly (readonly [PeLoadConfigField, number, 2 | 4 | 8])[]
> = {
32: [
["security_cookie", 60, 4],
["se_handler_table", 64, 4],
["se_handler_count", 68, 4],
["guard_cf_check_function_pointer", 72, 4],
["guard_cf_dispatch_function_pointer", 76, 4],
["guard_cf_function_table", 80, 4],
["guard_cf_function_count", 84, 4],
["guard_flags", 88, 4],
["guard_address_taken_iat_entry_table", 104, 4],
["guard_address_taken_iat_entry_count", 108, 4],
["guard_long_jump_target_table", 112, 4],
["guard_long_jump_target_count", 116, 4],
["dynamic_value_reloc_table", 120, 4],
["chpe_metadata_pointer", 124, 4],
["dynamic_value_reloc_table_offset", 136, 4],
["dynamic_value_reloc_table_section", 140, 2],
["guard_eh_continuation_table", 164, 4],
["guard_eh_continuation_count", 168, 4],
["guard_xfg_check_function_pointer", 172, 4],
],
64: [
["security_cookie", 88, 8],
["se_handler_table", 96, 8],
["se_handler_count", 104, 8],
["guard_cf_check_function_pointer", 112, 8],
["guard_cf_dispatch_function_pointer", 120, 8],
["guard_cf_function_table", 128, 8],
["guard_cf_function_count", 136, 8],
["guard_flags", 144, 4],
["guard_address_taken_iat_entry_table", 160, 8],
["guard_address_taken_iat_entry_count", 168, 8],
["guard_long_jump_target_table", 176, 8],
["guard_long_jump_target_count", 184, 8],
["dynamic_value_reloc_table", 192, 8],
["chpe_metadata_pointer", 200, 8],
["dynamic_value_reloc_table_offset", 224, 4],
["dynamic_value_reloc_table_section", 228, 2],
["guard_eh_continuation_table", 264, 8],
["guard_eh_continuation_count", 272, 8],
["guard_xfg_check_function_pointer", 280, 8],
],
};
/** Load-config file offset in the synthetic image: `.data` 0x600 + RVA 0x40. */
const LOAD_CONFIG_AT = 0x640;
/** A distinct sentinel per field that fills its whole width. */
const sentinel = (index: number, width: 2 | 4 | 8): bigint =>
width === 8
? 0x0000_0102_0304_0500n + BigInt(index)
: BigInt((width === 4 ? 0x01020300 : 0x1200) + index);
/** Rewrite the struct from spec offsets alone, then declare `size`. */
const specImage = (bits: 32 | 64, size: number): Buffer => {
const known = bits === 32 ? 192 : 320;
const bytes = peLoadConfigImage({
bits,
loadConfigSize: known,
loadConfigDirectorySize: known,
});
bytes.fill(0, LOAD_CONFIG_AT, LOAD_CONFIG_AT + known);
bytes.writeUInt32LE(size, LOAD_CONFIG_AT);
SPEC_FIELDS[bits].forEach(([, offset, width], index) => {
const value = sentinel(index, width);
if (width === 8) bytes.writeBigUInt64LE(value, LOAD_CONFIG_AT + offset);
else if (width === 4)
bytes.writeUInt32LE(Number(value), LOAD_CONFIG_AT + offset);
else bytes.writeUInt16LE(Number(value), LOAD_CONFIG_AT + offset);
});
return bytes;
};
/** The report value a field takes when Size covers it. */
const expectedField = (name: PeLoadConfigField, value: bigint): unknown => {
if (name === "guard_flags")
return expect.objectContaining({ raw: Number(value) });
if (name.endsWith("_count")) return Number(value);
if (name === "dynamic_value_reloc_table" || name === "chpe_metadata_pointer")
return `0x${value.toString(16)}`;
if (
name === "dynamic_value_reloc_table_offset" ||
name === "dynamic_value_reloc_table_section"
)
return Number(value);
return expect.objectContaining({ va: `0x${value.toString(16)}`, rva: null });
};
describe.each([32, 64] as const)(
"PE%s load-config fields at specification offsets",
(bits) => {
it.each(
SPEC_FIELDS[bits].map(([name, offset, width], index) => ({
name,
index,
end: offset + width,
})),
)("exposes $name exactly when Size reaches $end", async ({ name, end }) => {
for (const size of [end - 1, end]) {
const bytes = specImage(bits, size);
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
);
const config = report.load_config;
if (config === null) throw new Error("Expected a load configuration");
expect(config.size).toBe(size);
SPEC_FIELDS[bits].forEach(([field, offset, width], fieldIndex) => {
// PE32+ SafeSEH-named slots are never a handler table.
const covered =
offset + width <= size &&
!(bits === 64 && field.startsWith("se_handler"));
expect(config[field], `${field} at Size ${String(size)}`).toEqual(
covered ? expectedField(field, sentinel(fieldIndex, width)) : null,
);
});
expect(config[name] === null).toBe(
size < end || (bits === 64 && name.startsWith("se_handler")),
);
}
});
},
);
it.each([32, 64] as const)(
"PE%s retains dynamic relocation offset and section with a zero pointer",
async (bits) => {
const pointerOffset = bits === 32 ? 120 : 192;
const offsetOffset = bits === 32 ? 136 : 224;
const sectionOffset = bits === 32 ? 140 : 228;
for (const [offset, section] of [
[0, 0],
[0x40, 1],
[0xffffffff, 0xffff],
] as const) {
const bytes = specImage(bits, bits === 32 ? 192 : 320);
bytes.fill(
0,
LOAD_CONFIG_AT + pointerOffset,
LOAD_CONFIG_AT + pointerOffset + (bits === 32 ? 4 : 8),
);
bytes.writeUInt32LE(offset, LOAD_CONFIG_AT + offsetOffset);
bytes.writeUInt16LE(section, LOAD_CONFIG_AT + sectionOffset);
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
);
expect(peLoadConfigSchema.safeParse(report).success).toBe(true);
expect(report.load_config).toMatchObject({
dynamic_value_reloc_table: "0x0",
dynamic_value_reloc_table_offset: offset,
dynamic_value_reloc_table_section: section,
});
}
},
);