diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index b07364adf2..348454e72d 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -40,10 +40,14 @@ import { } from "../domain/zipPackageFormat.js"; import { + parseCodeViewData, parseExecutableHeader, parsePeRecord, + peDebugDirectoryEntry, + peRvaToFileOffset, type ExecutableMetadata, } from "../domain/binaryTarget.js"; +import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js"; /** Options for resolving a local binary target and its stable identity. */ export interface BinaryTargetResolverOptions { @@ -549,10 +553,98 @@ const readPeMetadata = async ( return err(`unsupported ${windowsHeader.signature} executable in MZ image`); const optionalHeaderSize = fileHeader.readUInt16LE(20); if (optionalHeaderSize > 4096) return err("invalid PE optional header size"); + const numberOfSections = fileHeader.readUInt16LE(6); const record = Buffer.alloc(24 + optionalHeaderSize); const recordRead = await handle.read(record, 0, record.length, offset); throwIfTargetResolutionCancelled(signal); - return recordRead.bytesRead === record.length - ? parsePeRecord(record) - : err("invalid or truncated PE header"); + if (recordRead.bytesRead !== record.length) + return err("invalid or truncated PE header"); + const codeView = await readPeCodeView( + handle, + offset, + numberOfSections, + record.subarray(24), + optionalHeaderSize, + signal, + ); + return parsePeRecord(record, codeView); +}; + +const readPeCodeView = async ( + handle: FileHandle, + peOffset: number, + numberOfSections: number, + optionalHeader: Buffer, + optionalHeaderSize: number, + signal?: AbortSignal, +): Promise => { + throwIfTargetResolutionCancelled(signal); + const debugDir = peDebugDirectoryEntry(optionalHeader); + if (debugDir === undefined || numberOfSections > 96) return undefined; + const sectionTableOffset = peOffset + 24 + optionalHeaderSize; + const sectionTableSize = numberOfSections * 40; + const sectionTable = Buffer.alloc(sectionTableSize); + const sectionRead = await handle.read( + sectionTable, + 0, + sectionTableSize, + sectionTableOffset, + ); + throwIfTargetResolutionCancelled(signal); + if (sectionRead.bytesRead !== sectionTableSize) return undefined; + const debugFileOffset = peRvaToFileOffset( + debugDir.debugRva, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(debugDir.debugSize, 4096), + ); + if (debugFileOffset === undefined) return undefined; + const maxDebugSize = Math.min(debugDir.debugSize, 4096); + const debugDirectoryBuffer = Buffer.alloc(maxDebugSize); + const debugDirRead = await handle.read( + debugDirectoryBuffer, + 0, + maxDebugSize, + debugFileOffset, + ); + throwIfTargetResolutionCancelled(signal); + const numEntries = Math.floor(debugDirRead.bytesRead / 28); + for (let j = 0; j < numEntries; j++) { + const entryOffset = j * 28; + const type = debugDirectoryBuffer.readUInt32LE(entryOffset + 12); + if (type !== 2) continue; // IMAGE_DEBUG_TYPE_CODEVIEW + const sizeOfData = debugDirectoryBuffer.readUInt32LE(entryOffset + 16); + let pointerToRawData = debugDirectoryBuffer.readUInt32LE(entryOffset + 24); + if (pointerToRawData === 0) { + const addressOfRawData = debugDirectoryBuffer.readUInt32LE( + entryOffset + 20, + ); + const mapped = peRvaToFileOffset( + addressOfRawData, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(sizeOfData, 4096), + ); + if (mapped === undefined) continue; + pointerToRawData = mapped; + } + if (sizeOfData < 16) continue; + const maxCodeViewRead = Math.min(sizeOfData, 4096); + const codeViewBuffer = Buffer.alloc(maxCodeViewRead); + const codeViewRead = await handle.read( + codeViewBuffer, + 0, + maxCodeViewRead, + pointerToRawData, + ); + throwIfTargetResolutionCancelled(signal); + const parsed = parseCodeViewData( + codeViewBuffer.subarray(0, codeViewRead.bytesRead), + sizeOfData, + ); + if (parsed !== undefined) return parsed; + } + return undefined; }; diff --git a/src/domain/binaryTarget.fixture.ts b/src/domain/binaryTarget.fixture.ts index 7d8a0a7f9c..345a3f24c8 100644 --- a/src/domain/binaryTarget.fixture.ts +++ b/src/domain/binaryTarget.fixture.ts @@ -58,20 +58,67 @@ export const fat = ( return bytes; }; -/** Build a bounded PE image with optional managed metadata. */ +export interface PeCodeViewFixture { + readonly type?: "RSDS" | "NB10"; + readonly guid?: string; + readonly age?: number; + readonly pdbPath?: string; +} + +/** Build a bounded PE image with optional managed metadata or CodeView debug data. */ export const pe = ( machine: number, offset = 64, characteristics = 0x0002, managed = false, + codeView?: PeCodeViewFixture, ): Buffer => { const optionalHeaderSize = machine === 0x014c || machine === 0x01c0 ? 224 : 240; - const bytes = Buffer.alloc(Math.max(512, offset + 24 + optionalHeaderSize)); + const sectionTableOffset = offset + 24 + optionalHeaderSize; + const sectionCount = codeView !== undefined ? 1 : 0; + const rawDataOffset = 512; + + let codeViewData: Buffer | undefined; + if (codeView !== undefined) { + const type = codeView.type ?? "RSDS"; + const pdbPath = codeView.pdbPath ?? "app.pdb"; + const age = codeView.age ?? 1; + const pathBuf = Buffer.from(`${pdbPath}\0`, "utf8"); + if (type === "RSDS") { + codeViewData = Buffer.alloc(24 + pathBuf.length); + codeViewData.write("RSDS", 0, "ascii"); + const guid = codeView.guid ?? "01234567-89ab-cdef-0123-456789abcdef"; + const parts = guid.split("-"); + if (parts.length === 5) { + codeViewData.writeUInt32LE(parseInt(parts[0]!, 16), 4); + codeViewData.writeUInt16LE(parseInt(parts[1]!, 16), 8); + codeViewData.writeUInt16LE(parseInt(parts[2]!, 16), 10); + Buffer.from(parts[3]!, "hex").copy(codeViewData, 12); + Buffer.from(parts[4]!, "hex").copy(codeViewData, 14); + } + codeViewData.writeUInt32LE(age, 20); + pathBuf.copy(codeViewData, 24); + } else { + codeViewData = Buffer.alloc(16 + pathBuf.length); + codeViewData.write("NB10", 0, "ascii"); + codeViewData.writeUInt32LE(0, 4); + codeViewData.writeUInt32LE(0, 8); + codeViewData.writeUInt32LE(age, 12); + pathBuf.copy(codeViewData, 16); + } + } + + const minSize = + codeViewData !== undefined + ? rawDataOffset + 28 + codeViewData.length + 32 + : Math.max(512, offset + 24 + optionalHeaderSize); + const bytes = Buffer.alloc(minSize); bytes.write("MZ", 0, "ascii"); bytes.writeUInt32LE(offset, 0x3c); bytes.write("PE\0\0", offset, "binary"); bytes.writeUInt16LE(machine, offset + 4); + bytes.writeUInt16LE(sectionCount, offset + 6); bytes.writeUInt16LE(optionalHeaderSize, offset + 20); bytes.writeUInt16LE(characteristics, offset + 22); const optionalHeader = offset + 24; @@ -83,6 +130,29 @@ export const pe = ( bytes.writeUInt32LE(0x2000, optionalHeader + directoryOffset + 14 * 8); bytes.writeUInt32LE(72, optionalHeader + directoryOffset + 14 * 8 + 4); } + if (codeViewData !== undefined) { + const debugDirRva = 0x1000; + const debugDirSize = 28; + bytes.writeUInt32LE(debugDirRva, optionalHeader + directoryOffset + 6 * 8); + bytes.writeUInt32LE( + debugDirSize, + optionalHeader + directoryOffset + 6 * 8 + 4, + ); + + bytes.write(".rdata\0\0", sectionTableOffset, "ascii"); + bytes.writeUInt32LE(0x1000, sectionTableOffset + 8); + bytes.writeUInt32LE(0x1000, sectionTableOffset + 12); + bytes.writeUInt32LE(512, sectionTableOffset + 16); + bytes.writeUInt32LE(rawDataOffset, sectionTableOffset + 20); + bytes.writeUInt32LE(0x40000040, sectionTableOffset + 36); + + bytes.writeUInt32LE(2, rawDataOffset + 12); + bytes.writeUInt32LE(codeViewData.length, rawDataOffset + 16); + bytes.writeUInt32LE(debugDirRva + 28, rawDataOffset + 20); + bytes.writeUInt32LE(rawDataOffset + 28, rawDataOffset + 24); + + codeViewData.copy(bytes, rawDataOffset + 28); + } return bytes; }; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 94828ee6e7..4e668e4605 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -3,6 +3,7 @@ import type { ElfFamilyMetadata, HeaderArchitecture, MipsElfMetadata, + PeCodeViewRecord, RiscvElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; @@ -50,6 +51,7 @@ export type ExecutableMetadata = | "shared-library" | "non-executable"; readonly managed: boolean; + readonly codeView?: PeCodeViewRecord; }; /** @@ -257,7 +259,8 @@ const parseMz = ( ); if (windowsHeader.signature !== "PE") return err("invalid or truncated Windows executable header in MZ image"); - return parsePeRecord(bytes.subarray(offset)); + const codeView = parsePeCodeViewFromBuffer(bytes, offset); + return parsePeRecord(bytes.subarray(offset), codeView); } const parsed = parseDosMzHeader(bytes, fileSize, signatureAtOffset); return parsed.ok @@ -272,6 +275,7 @@ const parseMz = ( /** Parse a bounded PE signature, COFF record, and optional header without I/O. */ export const parsePeRecord = ( record: Buffer, + codeView: PeCodeViewRecord | undefined, ): Result => { if ( record.length < 24 || @@ -305,9 +309,176 @@ export const parsePeRecord = ( ? "application" : "shared-library", managed: managed.value, + ...(codeView !== undefined ? { codeView } : {}), }); }; +/** Parse CodeView record data (RSDS or NB10) from raw data bytes. */ +export const parseCodeViewData = ( + buffer: Buffer, + sizeOfData: number, +): PeCodeViewRecord | undefined => { + if (sizeOfData < 16 || buffer.length < 16) return undefined; + const magic = buffer.subarray(0, 4).toString("binary"); + const pathOffset = magic === "RSDS" ? 24 : magic === "NB10" ? 16 : undefined; + if ( + pathOffset === undefined || + sizeOfData <= pathOffset || + buffer.length <= pathOffset + ) + return undefined; + const end = buffer.indexOf(0, pathOffset); + if (end <= pathOffset || end >= sizeOfData) return undefined; + const pdbPath = buffer.toString("utf8", pathOffset, end); + const pdbFileName = pdbPath.split(/[/\\]/u).pop() ?? pdbPath; + if (magic === "RSDS" && sizeOfData >= 24 && buffer.length >= 24) { + const part1 = buffer.readUInt32LE(4).toString(16).padStart(8, "0"); + const part2 = buffer.readUInt16LE(8).toString(16).padStart(4, "0"); + const part3 = buffer.readUInt16LE(10).toString(16).padStart(4, "0"); + const part4 = buffer.subarray(12, 14).toString("hex"); + const part5 = buffer.subarray(14, 20).toString("hex"); + const guid = `${part1}-${part2}-${part3}-${part4}-${part5}`; + const age = buffer.readUInt32LE(20); + return { pdbPath, pdbFileName, guid, age }; + } + if (magic === "NB10") { + const age = buffer.readUInt32LE(12); + return { pdbPath, pdbFileName, guid: null, age }; + } + return undefined; +}; + +/** Locate the debug directory RVA and size from a PE optional header. */ +export const peDebugDirectoryEntry = ( + optionalHeader: Buffer, +): + | { + readonly debugRva: number; + readonly debugSize: number; + 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); + if (debugRva === 0 || debugSize < 28) return undefined; + return { + debugRva, + debugSize, + sizeOfHeaders: optionalHeader.readUInt32LE(60), + }; +}; + +/** Translate an RVA to an absolute file offset using the PE section table. */ +export const peRvaToFileOffset = ( + rva: number, + sectionTable: Buffer, + numberOfSections: number, + sizeOfHeaders: number, + size = 1, +): number | undefined => { + if (rva < sizeOfHeaders && size <= sizeOfHeaders - rva) return rva; + for (let i = 0; i < numberOfSections; i++) { + const offset = i * 40; + if (sectionTable.length < offset + 40) break; + const virtualAddress = sectionTable.readUInt32LE(offset + 12); + const sizeOfRawData = sectionTable.readUInt32LE(offset + 16); + const pointerToRawData = sectionTable.readUInt32LE(offset + 20); + if ( + pointerToRawData > 0 && + rva >= virtualAddress && + rva - virtualAddress < sizeOfRawData && + size <= sizeOfRawData - (rva - virtualAddress) + ) { + return pointerToRawData + (rva - virtualAddress); + } + } + return undefined; +}; + +/** Parse CodeView metadata from a bounded in-memory PE image buffer. */ +export const parsePeCodeViewFromBuffer = ( + fileBytes: Buffer, + peOffset: number, +): PeCodeViewRecord | undefined => { + if (fileBytes.length < peOffset + 24) return undefined; + const numberOfSections = fileBytes.readUInt16LE(peOffset + 6); + const optionalHeaderSize = fileBytes.readUInt16LE(peOffset + 20); + if ( + optionalHeaderSize > 4096 || + fileBytes.length < peOffset + 24 + optionalHeaderSize + ) + return undefined; + const optionalHeader = fileBytes.subarray( + peOffset + 24, + peOffset + 24 + optionalHeaderSize, + ); + const debugDir = peDebugDirectoryEntry(optionalHeader); + if (debugDir === undefined) return undefined; + const sectionTableOffset = peOffset + 24 + optionalHeaderSize; + const sectionTableSize = numberOfSections * 40; + if (fileBytes.length < sectionTableOffset + sectionTableSize) + return undefined; + const sectionTable = fileBytes.subarray( + sectionTableOffset, + sectionTableOffset + sectionTableSize, + ); + const debugFileOffset = peRvaToFileOffset( + debugDir.debugRva, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(debugDir.debugSize, 4096), + ); + if (debugFileOffset === undefined || fileBytes.length < debugFileOffset + 28) + return undefined; + const maxDebugBytes = Math.min( + debugDir.debugSize, + fileBytes.length - debugFileOffset, + ); + const numEntries = Math.floor(maxDebugBytes / 28); + for (let j = 0; j < numEntries; j++) { + const entryOffset = debugFileOffset + j * 28; + const type = fileBytes.readUInt32LE(entryOffset + 12); + if (type !== 2) continue; // IMAGE_DEBUG_TYPE_CODEVIEW + const sizeOfData = fileBytes.readUInt32LE(entryOffset + 16); + let pointerToRawData = fileBytes.readUInt32LE(entryOffset + 24); + if (pointerToRawData === 0) { + const addressOfRawData = fileBytes.readUInt32LE(entryOffset + 20); + const mapped = peRvaToFileOffset( + addressOfRawData, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(sizeOfData, 4096), + ); + if (mapped === undefined) continue; + pointerToRawData = mapped; + } + if ( + sizeOfData < 16 || + fileBytes.length < pointerToRawData + Math.min(sizeOfData, 24) + ) + continue; + const codeViewData = fileBytes.subarray( + pointerToRawData, + Math.min(fileBytes.length, pointerToRawData + sizeOfData), + ); + const parsed = parseCodeViewData(codeViewData, sizeOfData); + if (parsed !== undefined) return parsed; + } + return undefined; +}; + const peManagedStatus = ( optionalHeader: Buffer, architecture: BinaryArchitecture, diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 99ad8487f6..8c16612bca 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -24,6 +24,14 @@ export interface ElfFamilyMetadata { readonly flags: number; } +/** Referenced CodeView debug symbols from a PE debug directory. */ +export interface PeCodeViewRecord { + readonly pdbPath: string; + readonly pdbFileName: string; + readonly guid: string | null; + readonly age: number; +} + /** Families whose parsed target variant carries `ElfFamilyMetadata`. */ export const ELF_METADATA_ARCHITECTURES = [ "mips", @@ -170,6 +178,8 @@ export type BinaryTarget = | "non-executable"; /** Whether the PE declares a non-empty CLI header data-directory entry. */ readonly managed: boolean; + /** Referenced CodeView debug symbols from the PE debug directory, if present. */ + readonly codeView?: PeCodeViewRecord; }) | (BinaryTargetIdentity & NonExecutableMetadata & { diff --git a/src/ghidra/GhidraPeProfile.test.ts b/src/ghidra/GhidraPeProfile.test.ts new file mode 100644 index 0000000000..561aad7012 --- /dev/null +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js"; + +describe("ghidraPeCodeViewLimitations", () => { + it("returns empty limitations for non-PE targets", () => { + const elfTarget: BinaryTarget = { + path: "/test/file", + sha256: "abc", + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }; + expect(ghidraPeCodeViewLimitations(elfTarget)).toEqual([]); + }); + + it("does not claim CodeView symbols were unapplied when the session loaded a PDB", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "abc", + kind: "executable", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + format: "pe", + codeView: { + pdbPath: "C:\\symbols.pdb", + pdbFileName: "symbols.pdb", + guid: "12345678-1234-1234-1234-123456789abc", + age: 1, + }, + }; + expect(ghidraPeCodeViewLimitations(peTarget, true)).toEqual([]); + }); + + it("returns empty limitations for PE target without codeView", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "def", + kind: "executable", + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + }; + expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([]); + }); + + it("returns limitation with GUID and age for RSDS CodeView records", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "def", + kind: "executable", + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + codeView: { + pdbPath: "C:\\build\\app.pdb", + pdbFileName: "app.pdb", + guid: "01234567-89ab-cdef-0123-456789abcdef", + age: 1, + }, + }; + expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([ + 'The target references debug symbols "app.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.', + ]); + }); + + it("returns limitation without GUID for NB10 CodeView records", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "def", + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], + executableRole: "application", + managed: false, + codeView: { + pdbPath: "D:\\test\\legacy.pdb", + pdbFileName: "legacy.pdb", + guid: null, + age: 2, + }, + }; + expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([ + 'The target references debug symbols "legacy.pdb" (age 2) that were not applied; procedure names are analysis-generated.', + ]); + }); +}); diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts new file mode 100644 index 0000000000..217b3256f3 --- /dev/null +++ b/src/ghidra/GhidraPeProfile.ts @@ -0,0 +1,24 @@ +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; + +/** + * Report unapplied PE companion PDB symbols when CodeView debug information is present. + * + * Ghidra does not automatically locate or apply sidecar PDBs in REA's isolated + * runtime directories without companion admission. When the PE target header + * references debug symbols, surface this limitation so callers understand that + * procedure names remain analysis-generated. + */ +export const ghidraPeCodeViewLimitations = ( + target: BinaryTarget, + pdbLoaded = false, +): readonly string[] => { + if (target.format !== "pe" || target.codeView === undefined || pdbLoaded) { + return []; + } + const { pdbFileName, guid, age } = target.codeView; + return [ + guid !== null + ? `The target references debug symbols "${pdbFileName}" (GUID ${guid}, age ${age}) that were not applied; procedure names are analysis-generated.` + : `The target references debug symbols "${pdbFileName}" (age ${age}) that were not applied; procedure names are analysis-generated.`, + ]; +}; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index ccdfd96f77..b485f6ab31 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -822,6 +822,39 @@ describe("Ghidra result projection", () => { }); }); +describe("Ghidra PE CodeView debug symbol limitations", () => { + it("projects unapplied companion PDB limitation for PE target with CodeView record", async () => { + const ghidra = provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(ok(null)), + })); + const target = { + ...peTarget("x86_64"), + codeView: { + pdbPath: "C:\\projects\\app\\build\\test.pdb", + pdbFileName: "test.pdb", + guid: "01234567-89ab-cdef-0123-456789abcdef", + age: 1, + }, + }; + const profile = await ghidra.resolveAnalysisProfile(target); + if (!profile.ok || profile.value.profile === null) + throw new Error("expected PE profile"); + const client = ghidra.createClient(target, profile.value.profile); + const health = await client.execute("health", {}); + expect(health).toMatchObject({ + ok: true, + value: { + limitations: expect.arrayContaining([ + 'The target references debug symbols "test.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.', + ]), + }, + }); + await client.close(); + }); +}); + describe("Ghidra relationship coverage", () => { it("marks the full direct call-reference response complete while keeping static caveats", async () => { const ghidra = provider(installationHost(), () => ({ diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 25838453ea..9db1b0c5f6 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -68,6 +68,7 @@ import { isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; +import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -530,6 +531,7 @@ export const createGhidraProviderClient = (input: { // Replaced by the reported outcome once the session handshake is checked. const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; const pdbLimitations: string[] = []; + let pdbLoaded = false; // Replaced by the reported storage outcome once the handshake is checked. const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), @@ -654,12 +656,14 @@ export const createGhidraProviderClient = (input: { operation: AnalysisOperation, info: GhidraSessionInfo, ): Promise => { - if (target.format === "pe") + if (target.format === "pe") { + pdbLoaded = info.pdb?.loaded === true; pdbLimitations.splice( 0, pdbLimitations.length, ...ghidraPdbLimitations(info.pdb), ); + } const softFloatReason = ghidraMipsSoftFloatFailure( softFloat, info.mips_soft_float_storage, @@ -712,6 +716,7 @@ export const createGhidraProviderClient = (input: { ...ghidraExtensionLimitations(extensions), ...seedLimitations, ...pdbLimitations, + ...ghidraPeCodeViewLimitations(target, pdbLoaded), ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; const nativeAotOwner = diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index e560550695..eb5977a541 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -26,6 +26,7 @@ import { thinMach, } from "../../../src/domain/binaryTarget.fixture.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -424,6 +425,65 @@ describe("binary target I/O: explicit kinds and executable headers", () => { }); }); + it("extracts CodeView RSDS debug metadata from a PE target", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "symbols.exe"); + await writeFile( + path, + pe(0x8664, 64, 0x0002, false, { + type: "RSDS", + pdbPath: "C:\\build\\test.pdb", + guid: "12345678-abcd-ef01-2345-6789abcdef01", + age: 3, + }), + ); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + format: "pe", + architecture: "x86_64", + codeView: { + pdbPath: "C:\\build\\test.pdb", + pdbFileName: "test.pdb", + guid: "12345678-abcd-ef01-2345-6789abcdef01", + age: 3, + }, + }); + }); + + it("extracts CodeView NB10 debug metadata from a PE target", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "legacy.exe"); + await writeFile( + path, + pe(0x014c, 64, 0x0002, false, { + type: "NB10", + pdbPath: "D:\\old\\legacy.pdb", + age: 1, + }), + ); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "ia32", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + format: "pe", + architecture: "x86", + codeView: { + pdbPath: "D:\\old\\legacy.pdb", + pdbFileName: "legacy.pdb", + guid: null, + age: 1, + }, + }); + }); + it("refuses a truncated Mach-O header before any provider sees it", async () => { const directory = await createTestTempDirectory("rea-target-"); const path = join(directory, "truncated"); @@ -884,3 +944,43 @@ it.each([ }); }, ); + +describe("binary target I/O: PE CodeView header ranges", () => { + it.each(["RSDS", "NB10"] as const)( + "reads bounded %s header debug records and rejects unfinished names", + async (type) => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "header-symbols.exe"); + const bytes = pe(0x8664, 64, 0x0002, false, { + type, + pdbPath: "header.pdb", + }); + const optionalHeader = 64 + 24; + bytes.writeUInt32LE(512, optionalHeader + 60); + bytes.copy(bytes, 384, 512); + bytes.writeUInt32LE(384, optionalHeader + 112 + 6 * 8); + bytes.writeUInt32LE(412, 384 + 20); + bytes.writeUInt32LE(0, 384 + 24); + for (const complete of [true, false]) { + if (!complete) { + const dataSize = bytes.readUInt32LE(384 + 16); + bytes[412 + dataSize - 1] = 0x61; + } + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path, { + hostArchitecture: "x64", + }); + const buffered = parseExecutableHeader(bytes, "x64"); + if (!resolved.ok) throw resolved.error; + if (!buffered.ok) throw new Error(buffered.error); + for (const value of [resolved.value, buffered.value]) { + if (complete) + expect(value).toMatchObject({ + codeView: { pdbPath: "header.pdb" }, + }); + else expect(value).not.toHaveProperty("codeView"); + } + } + }, + ); +});