diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 9672da877..85ba4a8c9 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,5 +1,4 @@ import { constants } from "node:fs"; -import { createReadStream } from "node:fs"; import { createHash } from "node:crypto"; import { access, @@ -45,6 +44,10 @@ export interface BinaryTarget { | "source-map"; readonly architecture?: BinaryArchitecture; readonly availableArchitectures?: readonly BinaryArchitecture[]; + /** Provider-neutral PE image role observed from COFF characteristics. */ + readonly executableRole?: "application" | "shared-library" | "non-executable"; + /** Whether a PE declares a non-empty CLI header data-directory entry. */ + readonly managed?: boolean; } /** @@ -65,43 +68,42 @@ export const parseBinaryTarget = async ( const resolved = await resolveAppBundle(canonical); if (!resolved.ok) return err(resolved.error); const path = resolved.value; - if (!(await stat(path)).isFile()) - return err(new BinaryTargetError(path, "target is not a regular file")); - if ( - targetKind === "database" || - (targetKind === undefined && path.toLowerCase().endsWith(".hop")) - ) - return ok({ - path, - sourcePath: canonical, - sha256: await sha256File(path), - kind: "database", - format: "analysis-database", - }); const handle = await open(path, "r"); - let detected: Result; try { + if (!(await handle.stat()).isFile()) + return err(new BinaryTargetError(path, "target is not a regular file")); + if ( + targetKind === "database" || + (targetKind === undefined && path.toLowerCase().endsWith(".hop")) + ) + return ok({ + path, + sourcePath: canonical, + sha256: await sha256Handle(handle), + kind: "database", + format: "analysis-database", + }); const artifactFormat = await detectArtifactFormat(path, handle); if (artifactFormat !== undefined) return ok({ path, sourcePath: canonical, - sha256: await sha256File(path), + sha256: await sha256Handle(handle), kind: isArchiveFormat(artifactFormat) ? "archive" : "artifact", format: artifactFormat, }); - detected = await readExecutableMetadata(handle, hostArchitecture); + const detected = await readExecutableMetadata(handle, hostArchitecture); + if (!detected.ok) return err(new BinaryTargetError(path, detected.error)); + return ok({ + path, + sourcePath: canonical, + sha256: await sha256Handle(handle), + kind: "executable", + ...detected.value, + }); } finally { await handle.close(); } - if (!detected.ok) return err(new BinaryTargetError(path, detected.error)); - return ok({ - path, - sourcePath: canonical, - sha256: await sha256File(path), - kind: "executable", - ...detected.value, - }); } catch (cause: unknown) { return err( new BinaryTargetError(candidate, "path is not readable", { cause }), @@ -167,9 +169,16 @@ const namedArtifactFormat = ( const isArchiveFormat = (format: BinaryTarget["format"]): boolean => ["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(format); -const sha256File = async (path: string): Promise => { +const sha256Handle = async (handle: FileHandle): Promise => { const hash = createHash("sha256"); - for await (const chunk of createReadStream(path)) hash.update(chunk); + const chunk = Buffer.allocUnsafe(64 * 1024); + let position = 0; + while (true) { + const observed = await handle.read(chunk, 0, chunk.length, position); + if (observed.bytesRead === 0) break; + hash.update(chunk.subarray(0, observed.bytesRead)); + position += observed.bytesRead; + } return hash.digest("hex"); }; @@ -262,7 +271,11 @@ const decodeXml = (value: string): string => type ExecutableMetadata = Pick< BinaryTarget, - "format" | "architecture" | "availableArchitectures" + | "format" + | "architecture" + | "availableArchitectures" + | "executableRole" + | "managed" >; const readExecutableMetadata = async ( @@ -274,13 +287,7 @@ const readExecutableMetadata = async ( const bytes = prefix.subarray(0, prefixRead.bytesRead); if (bytes.length >= 64 && bytes[0] === 0x4d && bytes[1] === 0x5a) { const offset = bytes.readUInt32LE(0x3c); - if (offset > bytes.length - 6) { - const record = Buffer.alloc(6); - const recordRead = await handle.read(record, 0, record.length, offset); - return recordRead.bytesRead === record.length - ? parsePeRecord(record) - : err("invalid or truncated PE header"); - } + return readPeMetadata(handle, offset); } if (bytes.length >= 8) { const magic = bytes.readUInt32BE(0); @@ -301,6 +308,28 @@ const readExecutableMetadata = async ( return parseExecutableHeader(bytes, hostArchitecture); }; +const readPeMetadata = async ( + handle: FileHandle, + offset: number, +): Promise> => { + const fileHeader = Buffer.alloc(24); + const fileHeaderRead = await handle.read( + fileHeader, + 0, + fileHeader.length, + offset, + ); + if (fileHeaderRead.bytesRead !== fileHeader.length) + return err("invalid or truncated PE header"); + const optionalHeaderSize = fileHeader.readUInt16LE(20); + if (optionalHeaderSize > 4096) return err("invalid PE optional header size"); + const record = Buffer.alloc(24 + optionalHeaderSize); + const recordRead = await handle.read(record, 0, record.length, offset); + return recordRead.bytesRead === record.length + ? parsePeRecord(record) + : err("invalid or truncated PE header"); +}; + /** * Parse supported executable headers without I/O. The caller supplies the host * architecture used for deterministic FAT Mach-O slice selection. @@ -399,22 +428,68 @@ const parseElf = (bytes: Buffer): Result => { const parsePe = (bytes: Buffer): Result => { if (bytes.length < 64) return err("truncated PE DOS header"); const offset = bytes.readUInt32LE(0x3c); - if (offset > bytes.length - 6) return err("invalid or truncated PE header"); - return parsePeRecord(bytes.subarray(offset, offset + 6)); + if (offset > bytes.length - 24) return err("invalid or truncated PE header"); + return parsePeRecord(bytes.subarray(offset)); }; const parsePeRecord = (record: Buffer): Result => { - if (record.length < 6 || record.toString("binary", 0, 4) !== "PE\u0000\u0000") + if ( + record.length < 24 || + record.toString("binary", 0, 4) !== "PE\u0000\u0000" + ) return err("invalid or truncated PE header"); const architecture = peArchitecture(record.readUInt16LE(4)); if (architecture === undefined) return err("unsupported PE architecture"); + 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; + const characteristics = record.readUInt16LE(22); return ok({ format: "pe", architecture, availableArchitectures: [architecture], + executableRole: + (characteristics & 0x0002) === 0 + ? "non-executable" + : (characteristics & 0x2000) === 0 + ? "application" + : "shared-library", + managed: managed.value, }); }; +const peManagedStatus = ( + optionalHeader: Buffer, + architecture: BinaryArchitecture, +): Result => { + if (optionalHeader.length < 2) return err("truncated PE optional header"); + const magic = optionalHeader.readUInt16LE(0); + const expectedMagic = + architecture === "x86_64" || architecture === "arm64" ? 0x20b : 0x10b; + if (magic !== expectedMagic) + 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): BinaryArchitecture | undefined => { switch (cpu) { case 7: diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts index a1ae0d675..aaa1dd1ee 100644 --- a/tests/binaryTarget.test.ts +++ b/tests/binaryTarget.test.ts @@ -35,6 +35,30 @@ describe("executable header parsing", () => { expect(result.ok && result.value).toMatchObject({ format, architecture }); }); + it("distinguishes native PE applications, DLLs, and managed assemblies", () => { + const application = parseExecutableHeader(pe(0x8664), "x64"); + const library = parseExecutableHeader(pe(0x8664, 64, 0x2002), "x64"); + const managed = parseExecutableHeader(pe(0x8664, 64, 0x0002, true), "x64"); + const nonExecutable = parseExecutableHeader(pe(0x8664, 64, 0), "x64"); + + expect(application.ok && application.value).toMatchObject({ + executableRole: "application", + managed: false, + }); + expect(library.ok && library.value).toMatchObject({ + executableRole: "shared-library", + managed: false, + }); + expect(managed.ok && managed.value).toMatchObject({ + executableRole: "application", + managed: true, + }); + expect(nonExecutable.ok && nonExecutable.value).toMatchObject({ + executableRole: "non-executable", + managed: false, + }); + }); + it("selects the host architecture from a FAT table", () => { const bytes = fat([0x01000007, 0x0100000c]); const arm = parseExecutableHeader(bytes, "arm64"); @@ -63,6 +87,17 @@ describe("executable header parsing", () => { ])("rejects malformed, truncated, or unsupported metadata", (bytes) => { expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false); }); + + it("rejects malformed PE optional-header commitments", () => { + const wrongMagic = pe(0x8664); + wrongMagic.writeUInt16LE(0x10b, 64 + 24); + const truncatedDirectories = pe(0x8664); + truncatedDirectories.writeUInt16LE(112, 64 + 20); + truncatedDirectories.writeUInt32LE(16, 64 + 24 + 108); + + expect(parseExecutableHeader(wrongMagic, "x64").ok).toBe(false); + expect(parseExecutableHeader(truncatedDirectories, "x64").ok).toBe(false); + }); }); describe("binary target I/O", () => { @@ -218,11 +253,29 @@ const fat = (cpus: readonly number[], declared = cpus.length): Buffer => { cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20)); return bytes; }; -const pe = (machine: number, offset = 64): Buffer => { - const bytes = Buffer.alloc(Math.max(128, offset + 6)); +const pe = ( + machine: number, + offset = 64, + characteristics = 0x0002, + managed = false, +): Buffer => { + const optionalHeaderSize = + machine === 0x014c || machine === 0x01c0 ? 224 : 240; + const bytes = Buffer.alloc(Math.max(512, offset + 24 + optionalHeaderSize)); bytes.write("MZ", 0, "ascii"); bytes.writeUInt32LE(offset, 0x3c); bytes.write("PE\0\0", offset, "binary"); bytes.writeUInt16LE(machine, offset + 4); + bytes.writeUInt16LE(optionalHeaderSize, offset + 20); + bytes.writeUInt16LE(characteristics, offset + 22); + const optionalHeader = offset + 24; + const pe32 = machine === 0x014c || machine === 0x01c0; + bytes.writeUInt16LE(pe32 ? 0x10b : 0x20b, optionalHeader); + const directoryOffset = pe32 ? 96 : 112; + bytes.writeUInt32LE(16, optionalHeader + directoryOffset - 4); + if (managed) { + bytes.writeUInt32LE(0x2000, optionalHeader + directoryOffset + 14 * 8); + bytes.writeUInt32LE(72, optionalHeader + directoryOffset + 14 * 8 + 4); + } return bytes; };