diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 1de48152fd..921546eb95 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -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 ( diff --git a/src/artifacts/DesktopFileInspection.ts b/src/artifacts/DesktopFileInspection.ts index cfc7f81f9d..7dbddd15b9 100644 --- a/src/artifacts/DesktopFileInspection.ts +++ b/src/artifacts/DesktopFileInspection.ts @@ -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); diff --git a/src/domain/binaryTarget.test.ts b/src/domain/binaryTarget.test.ts index 36dc4815aa..522b0b3f6d 100644 --- a/src/domain/binaryTarget.test.ts +++ b/src/domain/binaryTarget.test.ts @@ -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, + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 8a9a5f3d09..d06c47aa69 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -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 => { +): 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. */ diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index 325969b857..cfc6317f21 100644 --- a/src/domain/native/nativeAotPe.ts +++ b/src/domain/native/nativeAotPe.ts @@ -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)}`; diff --git a/src/domain/peOptionalHeader.test.ts b/src/domain/peOptionalHeader.test.ts index 295048f110..6a01892bfa 100644 --- a/src/domain/peOptionalHeader.test.ts +++ b/src/domain/peOptionalHeader.test.ts @@ -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 }], }); diff --git a/src/domain/peOptionalHeader.ts b/src/domain/peOptionalHeader.ts index 9d4446690c..b65e706ced 100644 --- a/src/domain/peOptionalHeader.ts +++ b/src/domain/peOptionalHeader.ts @@ -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 => { +): Result => { 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 => { + 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 }); +}; diff --git a/src/dotnet/ManagedArtifactInspector.test.ts b/src/dotnet/ManagedArtifactInspector.test.ts index 1c10c87718..d009bf64a5 100644 --- a/src/dotnet/ManagedArtifactInspector.test.ts +++ b/src/dotnet/ManagedArtifactInspector.test.ts @@ -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); diff --git a/src/dotnet/ManagedPeReader.ts b/src/dotnet/ManagedPeReader.ts index fe5acc1c5d..821845cf77 100644 --- a/src/dotnet/ManagedPeReader.ts +++ b/src/dotnet/ManagedPeReader.ts @@ -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" };