fix(pe): share header identity across native metadata readers

This commit is contained in:
morluto committed 2026-10-24 16:08:26 +08:00
1 parent 0f3a15de0d
commit abf7d300b3
7 files changed
+200 -103

No files matched your search

+7
View File
@@ -35,6 +35,13 @@ present with no consumed `locations`, and coverage is partial. Descriptors,
thunks and NUL-terminated names stay within PE's 32-bit RVA space, including
PE32+ images; earlier valid rows survive an overflowing descriptor walk.
`machine` retains the original unsigned COFF value. `architecture` decodes
known machines, including ReadyToRun OS overrides when a CLI directory is
declared; that declaration does not establish valid CLR metadata or runtime
compatibility. Unrecognized machines retain `pe-machine-<decimal>` while their
import tables remain inspectable. A recognized machine whose width contradicts
the optional header is malformed.
Each `imports[]` item reports the DLL name, descriptor RVAs, and named or
ordinal functions with ILT/IAT RVAs. `delay_imports[]` reports RVA-based
delay-load descriptors separately. `imphash` is pefile's `get_imphash()` over
+12
View File
@@ -83,6 +83,18 @@ describe("executable header parsing", () => {
},
);
it.each([0x014c, 0x8664] as const)(
"rejects optional-header width inconsistent with machine %s",
(machine) => {
const bytes = pe(machine);
bytes.writeUInt16LE(machine === 0x014c ? 0x20b : 0x10b, 88);
expect(parseExecutableHeader(bytes, "x64")).toEqual({
ok: false,
error: "PE optional-header magic does not match its architecture",
});
},
);
it("rejects an OS-override machine on a native image", () => {
const result = parseExecutableHeader(
pe(0x8664 ^ 0x7b79, 64, 0x2002),
+6 -38
View File
@@ -15,11 +15,7 @@ import {
parseMzHeaderEvidence,
type MzWindowsHeaderSignatureEvidence,
} from "./dosMz.js";
import {
decodePeMachine,
peOptionalHeaderMagic,
type PeArchitecture,
} from "./peMachine.js";
import { decodePeMachine, readPeOptionalHeader } from "./peMachine.js";
import {
classifyFatContainerIdentity,
readFatContainerHeader,
@@ -502,15 +498,14 @@ export const parsePeRecord = (
return err(
`unsupported PE architecture (COFF machine 0x${machineType.toString(16).padStart(4, "0")})`,
);
const { architecture } = machine;
const optionalHeaderSize = record.readUInt16LE(20);
if (optionalHeaderSize > 4096 || record.length < 24 + optionalHeaderSize)
return err("invalid or truncated PE optional header");
const optionalHeader = record.subarray(24, 24 + optionalHeaderSize);
const managed = peManagedStatus(optionalHeader, architecture);
if (!managed.ok) return managed;
if (machine.nativeOsOverride && !managed.value)
return err("unsupported PE architecture");
const facts = readPeOptionalHeader(optionalHeader, machineType);
if (!facts.ok) return err(facts.error.message);
if (facts.value.machine === null) return err("unsupported PE architecture");
const { architecture } = facts.value.machine;
const characteristics = record.readUInt16LE(22);
return ok({
format: "pe",
@@ -523,7 +518,7 @@ export const parsePeRecord = (
: (characteristics & 0x2000) === 0
? "application"
: "shared-library",
managed: managed.value,
managed: facts.value.managed,
...(codeView !== undefined ? { codeView } : {}),
});
};
@@ -694,33 +689,6 @@ export const parsePeCodeViewFromBuffer = (
return undefined;
};
const peManagedStatus = (
optionalHeader: Buffer,
architecture: PeArchitecture,
): Result<boolean, string> => {
if (optionalHeader.length < 2) return err("truncated PE optional header");
const magic = optionalHeader.readUInt16LE(0);
if (magic !== peOptionalHeaderMagic(architecture))
return err("PE optional-header magic does not match its architecture");
const directoryOffset = magic === 0x20b ? 112 : 96;
const directoryCountOffset = directoryOffset - 4;
if (optionalHeader.length < directoryCountOffset + 4)
return err("truncated PE optional header");
const directoryCount = optionalHeader.readUInt32LE(directoryCountOffset);
const requiredBytes = directoryOffset + directoryCount * 8;
if (
!Number.isSafeInteger(requiredBytes) ||
requiredBytes > optionalHeader.length
)
return err("truncated PE data-directory table");
if (directoryCount <= 14) return ok(false);
const cliDirectory = directoryOffset + 14 * 8;
return ok(
optionalHeader.readUInt32LE(cliDirectory) !== 0 ||
optionalHeader.readUInt32LE(cliDirectory + 4) !== 0,
);
};
const machArchitecture = (cpu: number): HeaderArchitecture | undefined => {
switch (cpu) {
case 7:
+65 -2
View File
@@ -1,4 +1,5 @@
import type { HeaderArchitecture } from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
export type PeArchitecture = Exclude<HeaderArchitecture, "hexagon">;
@@ -40,8 +41,8 @@ export const peOptionalHeaderMagic = (
const NATIVE_OS_OVERRIDES = [0x4644, 0xadc4, 0xadc5, 0x7b79, 0x1993, 0x1992];
/**
* Decode a COFF machine. Only managed images carry an OS override, so callers
* must reject `nativeOsOverride` when the image has no CLI header.
* Decode a COFF machine candidate. An OS override identifies an architecture
* only after the optional header establishes a managed declaration.
*/
export const decodePeMachine = (machine: number): PeMachine | undefined => {
const direct = architectureForMachine(machine);
@@ -54,3 +55,65 @@ export const decodePeMachine = (machine: number): PeMachine | undefined => {
}
return undefined;
};
/** Optional-header facts shared by admission and native metadata readers. */
export interface PeOptionalHeaderFacts {
readonly bits: 32 | 64;
readonly machine: PeMachine | null;
readonly managed: boolean;
readonly dataDirectories: readonly {
readonly virtual_address: number;
readonly size: number;
}[];
}
export interface PeHeaderFailure {
readonly kind: "malformed" | "unsupported";
readonly message: string;
}
/** Decode machine identity only when its optional header supplies the required proof. */
export const readPeOptionalHeader = (
optionalHeader: Buffer,
machineType: number,
): Result<PeOptionalHeaderFacts, PeHeaderFailure> => {
if (optionalHeader.length < 2)
return err({ kind: "malformed", message: "truncated PE optional header" });
const magic = optionalHeader.readUInt16LE(0);
if (magic !== 0x10b && magic !== 0x20b)
return err({
kind: "unsupported",
message: "Only PE32 and PE32+ image optional headers are supported",
});
const bits = magic === 0x20b ? 64 : 32;
const directoryOffset = bits === 64 ? 112 : 96;
if (optionalHeader.length < directoryOffset)
return err({ kind: "malformed", message: "truncated PE optional header" });
const directoryCount = optionalHeader.readUInt32LE(directoryOffset - 4);
if (directoryCount * 8 > optionalHeader.length - directoryOffset)
return err({
kind: "malformed",
message: "truncated PE data-directory table",
});
const dataDirectories = Array.from({ length: directoryCount }, (_, index) => {
const offset = directoryOffset + index * 8;
return {
virtual_address: optionalHeader.readUInt32LE(offset),
size: optionalHeader.readUInt32LE(offset + 4),
};
});
const cli = dataDirectories[14];
const managed =
cli !== undefined && (cli.virtual_address !== 0 || cli.size !== 0);
const decoded = decodePeMachine(machineType);
// An encoded machine without a CLI declaration remains an unknown CPU to
// metadata readers; admission separately rejects unsupported identities.
const machine =
decoded?.nativeOsOverride && !managed ? null : (decoded ?? null);
if (machine !== null && magic !== peOptionalHeaderMagic(machine.architecture))
return err({
kind: "malformed",
message: "PE optional-header magic does not match its architecture",
});
return ok({ bits, machine, managed, dataDirectories });
};
+42
View File
@@ -113,3 +113,45 @@ it.each(["inline", "pointer"] as const)(
expect(readGoBinaryImage(fixture.bytes).build_info).toBeNull();
},
);
it.each([
[0x014c, 0x7b79, "386", 32],
[0x8664, 0x7b79, "amd64", 64],
[0x01c0, 0x4644, "arm", 32],
[0xaa64, 0x4644, "arm64", 64],
] as const)(
"shares managed PE machine identity for %s with override %s",
(machine, override, architecture, bits) => {
const bytes = pe(machine, 64, 0x2002, true);
bytes.writeUInt16LE(machine ^ override, 68);
expect(readPeImage(bytes)).toMatchObject({
pe_machine: machine ^ override,
architecture,
bits,
});
},
);
it.each([0x014c, 0x8664] as const)(
"rejects a contradictory optional-header width for COFF machine %s",
(machine) => {
const bytes = pe(machine);
bytes.writeUInt16LE(machine === 0x014c ? 0x20b : 0x10b, 88);
expect(() => readPeImage(bytes)).toThrow(
"PE optional-header magic does not match its architecture",
);
},
);
it.each([0xffff, 0x8664 ^ 0x7b79])(
"retains raw unknown machine %s without managed override proof",
(machine) => {
const bytes = pe(0x8664);
bytes.writeUInt16LE(machine, 68);
expect(readPeImage(bytes)).toMatchObject({
pe_machine: machine,
architecture: `pe-machine-${machine}`,
bits: 64,
});
},
);
+16 -47
View File
@@ -1,3 +1,4 @@
import { readPeOptionalHeader } from "../../domain/peMachine.js";
import { readPeSectionHeader } from "./NativeSectionDirectory.js";
import {
mzWindowsHeaderLayoutLimitation,
@@ -47,20 +48,6 @@ const peHeaderOffset = (reader: BinaryReader): number => {
return pe;
};
const optionalHeaderMagic = (
reader: BinaryReader,
offset: number,
size: number,
): number => {
reader.range(offset, size, "PE optional header");
if (size < 2)
throw new BinaryFormatFailure(
"malformed",
"PE optional header is missing its complete magic field",
);
return reader.u16(offset);
};
/** Declared PE fields retained by their parsing owner for format consumers. */
export type PeImageSection = Extract<
ImageSection,
@@ -88,29 +75,14 @@ export const readPeImage = (bytes: Buffer): PeImage => {
const pe = peHeaderOffset(reader);
const optional = pe + 24;
const optionalSize = reader.u16(pe + 20);
const magic = optionalHeaderMagic(reader, optional, optionalSize);
const bits = magic === 0x10b ? 32 : magic === 0x20b ? 64 : null;
if (bits === null)
throw new BinaryFormatFailure(
"unsupported",
"Only PE32 and PE32+ image optional headers are supported",
);
if (optionalSize < (bits === 64 ? 112 : 96))
throw new BinaryFormatFailure(
"malformed",
"PE optional header is truncated",
);
const directoriesOffset = bits === 64 ? 112 : 96;
const directories = reader.u32(optional + directoriesOffset - 4);
if (directories * 8 > optionalSize - directoriesOffset)
throw new BinaryFormatFailure(
"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 machine = reader.u16(pe + 4);
const facts = readPeOptionalHeader(
reader.range(optional, optionalSize, "PE optional header"),
machine,
);
if (!facts.ok)
throw new BinaryFormatFailure(facts.error.kind, facts.error.message);
const { bits, dataDirectories } = facts.value;
const imageBase = reader.word(optional + (bits === 64 ? 24 : 28), bits);
const fileAlignment = reader.u32(optional + 36);
const headerMapping: FileMapping = {
@@ -176,15 +148,7 @@ export const readPeImage = (bytes: Buffer): PeImage => {
size: BigInt(virtualSize - size),
});
}
const machine = reader.u16(pe + 4);
const names = new Map([
[0x014c, "386"],
[0x8664, "amd64"],
[0x01c0, "arm"],
[0x01c2, "arm"],
[0x01c4, "arm"],
[0xaa64, "arm64"],
]);
const architecture = facts.value.machine?.architecture;
return {
format: "pe",
image_base: imageBase,
@@ -193,7 +157,12 @@ export const readPeImage = (bytes: Buffer): PeImage => {
pe_file_alignment: fileAlignment,
pe_data_directories: dataDirectories,
pe_header_mapping: headerMapping,
architecture: names.get(machine) ?? `pe-machine-${machine}`,
architecture:
architecture === "x86"
? "386"
: architecture === "x86_64"
? "amd64"
: (architecture ?? `pe-machine-${machine}`),
bits,
byte_order: "little",
mappings,
+52 -16
View File
@@ -14,22 +14,58 @@ import {
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest("reports PE import names through the public CLI", async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-imports-cli-");
const path = join(root, "app.exe");
await writeFile(path, peImportImage());
const result = await cli.run({
arguments: ["inspect-pe-imports", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_pe_imports").outputSchema.parse(
result.json,
);
expect(evidence.normalized_result.imports[0]?.name).toBe("KERNEL32.dll");
expect(evidence.normalized_result.imphash.md5).toBe(
"92cef6cc91a85825f3c8a0516517c66f",
);
});
cliTest(
"reports native and managed Linux AMD64 PE imports through CLI and stdio MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-imports-cli-");
const call = await cli.sharedMcpCall!();
for (const managed of [false, true]) {
const bytes = peImportImage({ bits: 64 });
const machine = managed ? 0x8664 ^ 0x7b79 : 0x8664;
bytes.writeUInt16LE(machine, 132);
if (managed) {
// Declare a file-backed CLR header beyond the fixture's import data.
// This synthetic image tests import inspection, not .NET execution.
const cliRva = 0x1700;
const cliHeader = peImportFixtureOffset(cliRva);
const cliDirectory = 152 + 112 + 14 * 8;
bytes.writeUInt32LE(cliRva, cliDirectory);
bytes.writeUInt32LE(72, cliDirectory + 4);
bytes.writeUInt32LE(72, cliHeader);
bytes.writeUInt16LE(2, cliHeader + 4);
bytes.writeUInt16LE(5, cliHeader + 6);
}
const path = join(root, managed ? "managed-linux.dll" : "native.exe");
await writeFile(path, bytes);
const result = await cli.run({
arguments: ["inspect-pe-imports", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const response = await call("inspect_pe_imports", { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
for (const value of [result.json, response.structuredContent]) {
const report =
toolContract("inspect_pe_imports").outputSchema.parse(
value,
).normalized_result;
expect(report).toMatchObject({
machine,
format: "pe32-plus",
architecture: "amd64",
bits: 64,
coverage: { status: "complete" },
});
expect(report.imports).toHaveLength(1);
expect(report.imports[0]?.name).toBe("KERNEL32.dll");
expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([
"GetProcAddress",
"ExitProcess",
]);
expect(report.imphash.md5).toBe("92cef6cc91a85825f3c8a0516517c66f");
}
}
},
);
cliTest(
"resolves a relative PE path against the working directory",