refactor(pe): share optional-header facts across readers

This commit is contained in:
morluto
2026-10-26 15:27:35 +08:00
parent 971b48f702
commit deed39f7c4
9 changed files with 250 additions and 105 deletions
+3 -2
View File
@@ -80,7 +80,7 @@ import {
parseCodeViewData,
claimsExecutableHeader,
parseExecutableHeader,
parsePeRecord,
parsePeRecordFacts,
peDebugDirectoryEntry,
peRvaToFileOffset,
type ExecutableMetadata,
@@ -1304,7 +1304,8 @@ const readPeMetadata = async (
optionalHeaderSize,
signal,
);
return parsePeRecord(record, codeView);
const parsed = parsePeRecordFacts(record, codeView);
return parsed.ok ? ok(parsed.value.metadata) : parsed;
};
const readPeCodeView = async (
+12 -9
View File
@@ -9,7 +9,10 @@ import { setImmediate } from "node:timers/promises";
import type { DesktopFile, DesktopFact } from "../domain/desktopApplication.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js";
import { parsePeRecord, peRvaToFileOffset } from "../domain/binaryTarget.js";
import {
parsePeRecordFacts,
peRvaToFileOffset,
} from "../domain/binaryTarget.js";
import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js";
import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js";
import { ManagedReaderFailure } from "../dotnet/ManagedReaderFailure.js";
@@ -221,24 +224,24 @@ const inspectPeFile = async (
"PE optional header exceeds the supported format bound",
);
const optional = await read(peOffset + 24, optionalSize);
const parsed = parsePeRecord(Buffer.concat([coff, optional]), undefined);
const parsed = parsePeRecordFacts(Buffer.concat([coff, optional]), undefined);
if (!parsed.ok) {
row.issues.push(parsed.error);
return;
}
if (parsed.value.format !== "pe") return;
if (parsed.value.metadata.format !== "pe") return;
fact({
kind: "native-image-header",
offset: peOffset,
value: { format: "pe", machine: header.value.machine },
});
if (!parsed.value.managed) return;
const directories = optional.readUInt16LE(0) === 0x20b ? 112 : 96;
const cliRva = optional.readUInt32LE(directories + 14 * 8);
const cliSize = optional.readUInt32LE(directories + 14 * 8 + 4);
if (!parsed.value.metadata.managed) return;
const cliDirectory = parsed.value.optionalHeader.dataDirectories[14];
if (cliDirectory === undefined) return;
const { address: cliRva, size: cliSize } = cliDirectory;
fact({
kind: "pe-cli-directory",
offset: peOffset + 24 + directories + 14 * 8,
offset: peOffset + 24 + cliDirectory.offset,
value: { rva: cliRva, bytes: cliSize },
});
if (unityManagedDirectory === null) return;
@@ -249,7 +252,7 @@ const inspectPeFile = async (
rva,
sections,
sectionCount,
optional.readUInt32LE(60),
parsed.value.optionalHeader.sizeOfHeaders,
bytes,
);
const cliOffset = mapped(cliRva, 16);
+46 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseExecutableHeader } from "./binaryTarget.js";
import {
parseExecutableHeader,
peDebugDirectoryEntry,
parsePeRecordFacts,
} from "./binaryTarget.js";
import { dosMz, elf, fat, pe, thinMach } from "./binaryTarget.fixture.js";
describe("executable header parsing", () => {
@@ -377,3 +381,44 @@ const fat64 = (cpus: readonly number[], littleEndian: boolean): Buffer => {
});
return bytes;
};
it.each([
{ magic: 0x10b, directoryOffset: 96 },
{ magic: 0x20b, directoryOffset: 112 },
])(
"retains a complete debug entry when later declared directories are unavailable ($magic)",
({ magic, directoryOffset }) => {
const optional = Buffer.alloc(directoryOffset + 7 * 8);
optional.writeUInt16LE(magic);
optional.writeUInt32LE(0x400, 60);
optional.writeUInt32LE(0xffffffff, directoryOffset - 4);
optional.writeUInt32LE(0x2400, directoryOffset + 6 * 8);
optional.writeUInt32LE(28, directoryOffset + 6 * 8 + 4);
expect(peDebugDirectoryEntry(optional)).toEqual({
debugRva: 0x2400,
debugSize: 28,
sizeOfHeaders: 0x400,
});
expect(
peDebugDirectoryEntry(optional.subarray(0, optional.length - 1)),
).toBeUndefined();
optional.writeUInt32LE(6, directoryOffset - 4);
expect(peDebugDirectoryEntry(optional)).toBeUndefined();
},
);
it("keeps validated optional-header facts separate from executable metadata", () => {
const bytes = pe(0x8664);
const record = bytes.subarray(bytes.readUInt32LE(0x3c));
const parsed = parsePeRecordFacts(record, undefined);
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value.optionalHeader).toMatchObject({ magic: 0x20b, bits: 64 });
expect(parsed.value.metadata).toEqual({
format: "pe",
peMachine: 0x8664,
architecture: "x86_64",
availableArchitectures: ["x86_64"],
executableRole: "application",
managed: false,
});
});
+40 -32
View File
@@ -9,6 +9,11 @@ import type {
UniversalMachOSlice,
} from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
import {
readPeOptionalHeaderFixedFields,
readPeOptionalHeaderDirectory,
} from "./peOptionalHeader.js";
import type { PeOptionalHeaderFacts } from "./peMachine.js";
import { readPeCoffHeader } from "./peCoffHeader.js";
import { readPeSectionHeader } from "./peSectionHeader.js";
import { readElfFixedHeader } from "./elfFixedHeader.js";
@@ -468,7 +473,8 @@ const parseMz = (
if (windowsHeader.signature !== "PE")
return err("invalid or truncated Windows executable header in MZ image");
const codeView = parsePeCodeViewFromBuffer(bytes, offset);
return parsePeRecord(bytes.subarray(offset), codeView);
const parsed = parsePeRecordFacts(bytes.subarray(offset), codeView);
return parsed.ok ? ok(parsed.value.metadata) : parsed;
}
const parsed = evidence.dos;
return parsed.ok
@@ -481,10 +487,16 @@ const parseMz = (
};
/** Parse a bounded PE signature, COFF record, and optional header without I/O. */
export const parsePeRecord = (
export const parsePeRecordFacts = (
record: Buffer,
codeView: PeCodeViewRecord | undefined,
): Result<ExecutableMetadata, string> => {
): Result<
{
readonly metadata: ExecutableMetadata;
readonly optionalHeader: PeOptionalHeaderFacts;
},
string
> => {
if (
record.length < 24 ||
record.toString("binary", 0, 4) !== "PE\u0000\u0000"
@@ -510,18 +522,21 @@ export const parsePeRecord = (
if (facts.value.machine === null) return err("unsupported PE architecture");
const { architecture } = facts.value.machine;
return ok({
format: "pe",
peMachine: machineType,
architecture,
availableArchitectures: [architecture],
executableRole:
(characteristics & 0x0002) === 0
? "non-executable"
: (characteristics & 0x2000) === 0
? "application"
: "shared-library",
managed: facts.value.managed,
...(codeView !== undefined ? { codeView } : {}),
optionalHeader: facts.value,
metadata: {
format: "pe",
peMachine: machineType,
architecture,
availableArchitectures: [architecture],
executableRole:
(characteristics & 0x0002) === 0
? "non-executable"
: (characteristics & 0x2000) === 0
? "application"
: "shared-library",
managed: facts.value.managed,
...(codeView !== undefined ? { codeView } : {}),
},
});
};
@@ -570,24 +585,17 @@ export const peDebugDirectoryEntry = (
readonly sizeOfHeaders: number;
}
| undefined => {
if (optionalHeader.length < 2) return undefined;
const magic = optionalHeader.readUInt16LE(0);
if (magic !== 0x10b && magic !== 0x20b) return undefined;
const directoryOffset = magic === 0x20b ? 112 : 96;
const directoryCountOffset = directoryOffset - 4;
if (optionalHeader.length < directoryCountOffset + 4) return undefined;
const directoryCount = optionalHeader.readUInt32LE(directoryCountOffset);
if (directoryCount <= 6) return undefined;
const debugEntryOffset = directoryOffset + 6 * 8;
if (optionalHeader.length < debugEntryOffset + 8) return undefined;
const debugRva = optionalHeader.readUInt32LE(debugEntryOffset);
const debugSize = optionalHeader.readUInt32LE(debugEntryOffset + 4);
const fixed = readPeOptionalHeaderFixedFields(optionalHeader);
if (!fixed.ok) return undefined;
const observed = readPeOptionalHeaderDirectory(
optionalHeader,
fixed.value,
6,
);
if (observed.state !== "present") return undefined;
const { address: debugRva, size: debugSize } = observed.directory;
if (debugRva === 0 || debugSize < 28) return undefined;
return {
debugRva,
debugSize,
sizeOfHeaders: optionalHeader.readUInt32LE(60),
};
return { debugRva, debugSize, sizeOfHeaders: fixed.value.sizeOfHeaders };
};
/** Translate an RVA to an absolute file offset using the PE section table. */
+8 -13
View File
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { readPeOptionalHeaderFixedFields } from "../peOptionalHeader.js";
import { readPeCoffHeader } from "../peCoffHeader.js";
import { readPeSectionHeader } from "../peSectionHeader.js";
import type { NativeMetadataRecovery } from "./nativeMetadataRecovery.js";
@@ -511,12 +512,14 @@ class PeImage {
throw new Error(
"PE optional header is too short for image mapping fields",
);
const magic = this.#u16(optional);
if (magic !== 0x20b || this.#machine !== 0x8664)
const fixed = readPeOptionalHeaderFixedFields(
bytes.subarray(optional, optional + optionalSize),
);
if (!fixed.ok || fixed.value.magic !== 0x20b || this.#machine !== 0x8664)
throw new Error("Only x64 PE32+ is supported");
this.imageBase = this.#u64(optional + 24);
this.sizeOfImage = this.#u32(optional + 56);
this.#sizeOfHeaders = this.#u32(optional + 60);
this.imageBase = fixed.value.imageBase;
this.sizeOfImage = fixed.value.sizeOfImage;
this.#sizeOfHeaders = fixed.value.sizeOfHeaders;
if (count < 1 || this.sizeOfImage === 0)
throw new Error("Unsupported PE image extent");
if (this.imageBase + BigInt(this.sizeOfImage) > 1n << 64n)
@@ -658,18 +661,10 @@ class PeImage {
)
throw new Error("PE field leaves the captured file");
}
#u16(offset: number): number {
this.#range(offset, 2);
return this.#bytes.readUInt16LE(offset);
}
#u32(offset: number): number {
this.#range(offset, 4);
return this.#bytes.readUInt32LE(offset);
}
#u64(offset: number): bigint {
this.#range(offset, 8);
return this.#bytes.readBigUInt64LE(offset);
}
}
const hex = (value: bigint): string => `0x${value.toString(16)}`;
+4
View File
@@ -64,7 +64,9 @@ it.each(formats)(
imageBase: format.bits === 64 ? 0xfedcba9876540000n : 0x87650000n,
sectionAlignment: 0x1000,
fileAlignment: 0x200,
sizeOfImage: 0,
sizeOfHeaders: 0xffffffff,
directoryCountOffset: format.directoryOffset - 4,
directoryCount: 0,
dataDirectories: [],
},
@@ -170,7 +172,9 @@ it("honors a Uint8Array window and detaches facts from later source changes", ()
imageBase: 0xfedcba9876540000n,
sectionAlignment: 0x1000,
fileAlignment: 0x200,
sizeOfImage: 0,
sizeOfHeaders: 0x400,
directoryCountOffset: formats[1].directoryOffset - 4,
directoryCount: 1,
dataDirectories: [{ address: 0x12345678, size: 0x90, offset: 112 }],
});
+77 -22
View File
@@ -9,15 +9,20 @@ export interface PeDataDirectory {
}
/** Structural facts copied from one declared PE optional header. */
export interface PeOptionalHeaderStructure {
export interface PeOptionalHeaderFixedFields {
readonly magic: 0x10b | 0x20b;
readonly bits: 32 | 64;
readonly format: "pe32" | "pe32-plus";
readonly imageBase: bigint;
readonly sectionAlignment: number;
readonly fileAlignment: number;
readonly sizeOfImage: number;
readonly sizeOfHeaders: number;
readonly directoryOffset: number;
readonly directoryCountOffset: number;
}
export interface PeOptionalHeaderStructure extends PeOptionalHeaderFixedFields {
readonly directoryCount: number;
readonly dataDirectories: readonly PeDataDirectory[];
}
@@ -32,10 +37,10 @@ export interface PeOptionalHeaderFailure {
readonly message: string;
}
/** Decode structural declarations without admitting a machine, file extent, or directory address. */
export const readPeOptionalHeaderStructure = (
/** Decode mapping fields without requiring a directory count or table. */
export const readPeOptionalHeaderFixedFields = (
bytes: Uint8Array,
): Result<PeOptionalHeaderStructure, PeOptionalHeaderFailure> => {
): Result<PeOptionalHeaderFixedFields, PeOptionalHeaderFailure> => {
if (bytes.byteLength < 2)
return err({
kind: "malformed",
@@ -52,27 +57,12 @@ export const readPeOptionalHeaderStructure = (
});
const bits = magic === 0x20b ? 64 : 32;
const directoryOffset = bits === 64 ? 112 : 96;
if (bytes.byteLength < directoryOffset)
if (bytes.byteLength < 64)
return err({
kind: "malformed",
reason: "truncated-fixed-fields",
message: "truncated PE optional header",
});
const directoryCount = view.getUint32(directoryOffset - 4, true);
if (directoryCount > Math.floor((bytes.byteLength - directoryOffset) / 8))
return err({
kind: "malformed",
reason: "truncated-directory-table",
message: "truncated PE data-directory table",
});
const dataDirectories = Array.from({ length: directoryCount }, (_, index) => {
const offset = directoryOffset + index * 8;
return {
address: view.getUint32(offset, true),
size: view.getUint32(offset + 4, true),
offset,
};
});
return ok({
magic,
bits,
@@ -83,9 +73,74 @@ export const readPeOptionalHeaderStructure = (
: BigInt(view.getUint32(28, true)),
sectionAlignment: view.getUint32(32, true),
fileAlignment: view.getUint32(36, true),
sizeOfImage: view.getUint32(56, true),
sizeOfHeaders: view.getUint32(60, true),
directoryOffset,
directoryCount,
dataDirectories,
directoryCountOffset: directoryOffset - 4,
});
};
const readDirectory = (view: DataView, offset: number): PeDataDirectory => ({
address: view.getUint32(offset, true),
size: view.getUint32(offset + 4, true),
offset,
});
export type PeOptionalHeaderDirectoryObservation =
| { readonly state: "count-unavailable" }
| { readonly state: "absent"; readonly count: number }
| { readonly state: "truncated"; readonly count: number }
| {
readonly state: "present";
readonly count: number;
readonly directory: PeDataDirectory;
};
/** Observe one declared entry independently of the availability of later entries. */
export const readPeOptionalHeaderDirectory = (
bytes: Uint8Array,
fixed: PeOptionalHeaderFixedFields,
index: number,
): PeOptionalHeaderDirectoryObservation => {
if (bytes.byteLength < fixed.directoryOffset)
return { state: "count-unavailable" };
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const count = view.getUint32(fixed.directoryCountOffset, true);
if (index >= count) return { state: "absent", count };
const offset = fixed.directoryOffset + index * 8;
if (offset > bytes.byteLength - 8) return { state: "truncated", count };
return {
state: "present",
count,
directory: readDirectory(view, offset),
};
};
/** Decode the complete declared table without admitting a machine or directory address. */
export const readPeOptionalHeaderStructure = (
bytes: Uint8Array,
): Result<PeOptionalHeaderStructure, PeOptionalHeaderFailure> => {
const parsed = readPeOptionalHeaderFixedFields(bytes);
if (!parsed.ok) return parsed;
const fixed = parsed.value;
if (bytes.byteLength < fixed.directoryOffset)
return err({
kind: "malformed",
reason: "truncated-fixed-fields",
message: "truncated PE optional header",
});
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const directoryCount = view.getUint32(fixed.directoryCountOffset, true);
if (
directoryCount > Math.floor((bytes.byteLength - fixed.directoryOffset) / 8)
)
return err({
kind: "malformed",
reason: "truncated-directory-table",
message: "truncated PE data-directory table",
});
const dataDirectories = Array.from({ length: directoryCount }, (_, index) =>
readDirectory(view, fixed.directoryOffset + index * 8),
);
return ok({ ...fixed, directoryCount, dataDirectories });
};
@@ -93,6 +93,22 @@ describe("managed PE optional-header directory count", () => {
});
describe("managed CLI directory availability", () => {
it("keeps a readable CLI directory when later declared entries are truncated", () => {
const bytes = buildManagedPeFixture();
const optional = bytes.readUInt32LE(0x3c) + 24;
bytes.writeUInt32LE(0xffffffff, optional + 92);
expect(readManagedPeLayout(bytes)).toMatchObject({
cliDirectoryState: "present",
cliIssue: null,
cli: expect.objectContaining({ headerSize: 72 }),
});
const result = inspectManagedArtifactBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.metadata.status).toBe("complete");
});
it("keeps a declared but truncated CLI directory malformed", () => {
const bytes = buildManagedPeFixture();
const peOffset = bytes.readUInt32LE(0x3c);
+44 -26
View File
@@ -1,3 +1,9 @@
import {
readPeOptionalHeaderFixedFields,
readPeOptionalHeaderDirectory,
type PeOptionalHeaderFixedFields,
} from "../domain/peOptionalHeader.js";
import { readPeSectionHeader } from "../domain/peSectionHeader.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import { readPeCoffHeader } from "../domain/peCoffHeader.js";
import { decodePeMachine } from "../domain/peMachine.js";
@@ -86,8 +92,7 @@ interface CoffHeader {
interface OptionalHeader {
readonly optionalOffset: number;
readonly optionalHeader: "pe32" | "pe32-plus";
readonly directoryCountOffset: number;
readonly directoryOffset: number;
readonly fixed: PeOptionalHeaderFixedFields;
}
type CliDirectoryRead =
@@ -337,10 +342,10 @@ const readOptionalHeader = (
"PE optional header is too short",
optionalOffset,
);
const magic = bytes.readUInt16LE(optionalOffset);
const optionalHeader =
magic === 0x010b ? "pe32" : magic === 0x020b ? "pe32-plus" : undefined;
if (optionalHeader === undefined)
const parsed = readPeOptionalHeaderFixedFields(
bytes.subarray(optionalOffset, optionalOffset + coff.optionalSize),
);
if (!parsed.ok)
throw managedFailure(
"invalid-directory",
"pe.optional-header",
@@ -349,10 +354,8 @@ const readOptionalHeader = (
);
return {
optionalOffset,
optionalHeader,
directoryCountOffset:
optionalOffset + (optionalHeader === "pe32" ? 92 : 108),
directoryOffset: optionalOffset + (optionalHeader === "pe32" ? 96 : 112),
optionalHeader: parsed.value.format,
fixed: parsed.value,
};
};
@@ -370,11 +373,19 @@ const readSections = (
const sections: PeSection[] = [];
for (let index = 0; index < coff.sectionCount; index += 1) {
const offset = sectionOffset + index * 40;
const parsed = readPeSectionHeader(bytes.subarray(offset, offset + 40));
if (!parsed.ok)
throw managedFailure(
"invalid-directory",
"pe.sections",
parsed.error.message,
offset,
);
const section = {
virtualSize: bytes.readUInt32LE(offset + 8),
virtualAddress: bytes.readUInt32LE(offset + 12),
rawSize: bytes.readUInt32LE(offset + 16),
rawOffset: bytes.readUInt32LE(offset + 20),
virtualSize: parsed.value.virtual_size,
virtualAddress: parsed.value.virtual_address,
rawSize: parsed.value.raw_size,
rawOffset: parsed.value.offset,
};
if (section.rawSize > 0)
requireRange(
@@ -393,34 +404,41 @@ const readCliDirectory = (
coff: CoffHeader,
optional: OptionalHeader,
): CliDirectoryRead => {
const countOffset = optional.directoryCountOffset - optional.optionalOffset;
if (countOffset + 4 > coff.optionalSize)
const observed = readPeOptionalHeaderDirectory(
bytes.subarray(
optional.optionalOffset,
optional.optionalOffset + coff.optionalSize,
),
optional.fixed,
14,
);
const countOffset =
optional.optionalOffset + optional.fixed.directoryCountOffset;
if (observed.state === "count-unavailable")
return {
kind: "unreadable",
issue: {
code: "invalid-directory",
scope: "pe.optional-header",
detail: "PE optional header does not contain its directory count",
offset: optional.directoryCountOffset,
offset: countOffset,
},
};
const directoryCount = bytes.readUInt32LE(optional.directoryCountOffset);
if (directoryCount <= 14) return { kind: "absent" };
const availableDirectoryCount = Math.floor(
(coff.optionalSize - (optional.directoryOffset - optional.optionalOffset)) /
8,
);
if (availableDirectoryCount < 15)
if (observed.state === "absent") return { kind: "absent" };
if (observed.state === "truncated")
return {
kind: "unreadable",
issue: {
code: "invalid-directory",
scope: "pe.data-directories",
detail: "PE data-directory count exceeds the optional-header extent",
offset: optional.directoryCountOffset,
offset: countOffset,
},
};
const directory = readDirectory(bytes, optional.directoryOffset + 14 * 8);
const directory = {
rva: observed.directory.address,
size: observed.directory.size,
};
return directory.rva !== 0 || directory.size !== 0
? { kind: "present", directory }
: { kind: "absent" };