mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(pe): share header identity across native metadata readers
This commit is contained in:
1 parent
0f3a15de0d
commit
abf7d300b3
7 files changed
+200
-103
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user