fix(ghidra): disclose missing PDB symbol coverage (#2275)

* fix(ghidra): disclose missing PDB symbol coverage

* style: format reviewed source changes

* fix(ghidra): bound PDB handshake and retain coverage uncertainty

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: morluto <17176810+morluto@users.noreply.github.com>
This commit is contained in:
Hifza Zafar 2026-10-13 19:17:27 +05:00 committed by GitHub
parent 1016c96e6b
commit 865c35e25a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 391 additions and 7 deletions

View File

@ -56,6 +56,8 @@ import ghidra.app.decompiler.DecompiledFunction;
import ghidra.app.decompiler.ClangCaseToken;
import ghidra.app.decompiler.ClangNode;
import ghidra.framework.Application;
import ghidra.app.util.bin.format.pdb.PdbParserConstants;
import ghidra.framework.options.Options;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressIterator;
import ghidra.program.model.address.AddressSet;
@ -110,6 +112,8 @@ import ghidra.program.util.SymbolicPropogator;
import ghidra.program.util.VarnodeContext;
public final class ReaGhidraBridge extends HeadlessScript {
// At most 24 KiB after worst-case JSON escaping (six bytes per UTF-16 unit).
private static final int PDB_ATTRIBUTE_UTF16_BUDGET = 4096;
private static final int MAX_HIGH_PCODE_OPS = 3000;
private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64;
private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000;
@ -454,6 +458,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
.toArray(String[]::new)
));
result.add("target", target);
result.add("pdb", pdbObservation());
JsonArray extensions = analysisExtensions.reports();
if (extensions.size() > 0) result.add("analysis_extensions", extensions);
// Written by ReaGhidraApplySeeds before auto-analysis; REA checks it
@ -469,6 +474,41 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
/** Read loader/analyzer-owned facts without searching for or applying companions. */
private JsonObject pdbObservation() {
Options info = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO);
JsonObject pdb = new JsonObject();
String[] names = {"file", "guid", "age", "signature", "version"};
String[] attributes = {
PdbParserConstants.PDB_FILE, PdbParserConstants.PDB_GUID,
PdbParserConstants.PDB_AGE, PdbParserConstants.PDB_SIGNATURE,
PdbParserConstants.PDB_VERSION
};
int remaining = PDB_ATTRIBUTE_UTF16_BUDGET;
JsonArray omitted = new JsonArray();
for (int index = 0; index < names.length; index++) {
String value = info.getString(attributes[index], null);
if (value != null && value.length() > remaining) {
omitted.add(names[index]);
pdb.add(names[index], JsonNull.INSTANCE);
} else {
pdb.addProperty(names[index], value);
if (value != null) remaining -= value.length();
}
}
if (omitted.size() > 0) {
JsonObject limit = new JsonObject();
limit.addProperty("utf16_budget", PDB_ATTRIBUTE_UTF16_BUDGET);
limit.add("omitted_attributes", omitted);
pdb.add("resource_limit", limit);
}
if (info.contains(PdbParserConstants.PDB_LOADED))
pdb.addProperty("loaded", info.getBoolean(PdbParserConstants.PDB_LOADED, false));
else
pdb.add("loaded", JsonNull.INSTANCE);
return pdb;
}
private static String bytesHex(byte[] bytes) {
return java.util.HexFormat.of().formatHex(bytes);
}

View File

@ -41,6 +41,26 @@ does not prove text is absent. Both list and search Evidence carry this
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
bytes and references.
## PE debug-symbol coverage
The post-analysis handshake reports Ghidra's PDB Program Information attributes:
the referenced file, GUID, hexadecimal age, legacy signature, version, and
`PDB Loaded` state. Absent attributes remain null. PE Evidence carries a
limitation when a PDB reference is present but Ghidra reports it as not loaded,
or its load state is unknown. A bridge without this report leaves PDB coverage
unknown. A reported loaded state is a Ghidra observation, not an independent
PDB identity or complete symbol-coverage attestation, and retains an inline
coverage caveat. Handshake PDB strings share a 4096 UTF-16-unit serialization
budget (at most 24 KiB after JSON escaping); whole attributes that do not fit
are omitted with an explicit resource limitation, rather than truncated.
Original names and types may be missing even when auto-analysis completes.
An empty name search does not establish that a function is absent; use
`list_procedures` and address-based analysis to inspect recovered functions.
REA imports a private executable copy and does not admit a caller-selected
PDB companion. This reporting does not search symbol servers, copy a sibling
PDB, or change analyzer settings.
## Loaded addresses and file offsets
`rea analyze <path> --provider ghidra --json` and MCP `binary_overview` report

View File

@ -206,6 +206,9 @@ export const resolveGhidraAnalysisProfile = async (
...(target.availableArchitectures ?? []),
].sort(),
import_mode: "ephemeral-source-immutable",
...(target.format === "pe"
? { pdb_observations: "program-info-v1" }
: {}),
annotation_policy: "atomic-function-entry-metadata-v1",
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",

View File

@ -1541,6 +1541,113 @@ const sessionInfo = () => ({
},
});
describe("Ghidra PDB coverage", () => {
it.each([
{ name: "missing bridge report", pdb: undefined, warns: true },
{
name: "report without a PDB reference",
pdb: {
file: null,
guid: null,
age: null,
signature: null,
version: null,
loaded: null,
},
warns: false,
},
])("distinguishes $name in direct PE searches", async ({ pdb, warns }) => {
const ghidra = provider(installationHost(), () => ({
start: () =>
Promise.resolve(
ok(pdb === undefined ? sessionInfo() : { ...sessionInfo(), pdb }),
),
callTool: () => Promise.resolve(ok([])),
close: () => Promise.resolve(ok(null)),
}));
const target = executableTarget("pe", "x86_64");
const resolved = await ghidra.resolveAnalysisProfile(target);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("Expected a PE profile");
const client = ghidra.createClient(target, resolved.value.profile);
try {
const searched = await client.execute("search_procedures", {
pattern: "add_numbers",
});
if (!searched.ok) throw searched.error;
expect(searched.value.result).toEqual([]);
const warnings = searched.value.limitations.filter((limitation) =>
limitation.includes("PDB"),
);
expect(warnings).toEqual(
warns
? [
"Ghidra did not report PDB attributes; debug-symbol coverage is unknown.",
]
: [],
);
} finally {
await client.close();
}
});
const pdb = {
file: "C:\\build\\fixture.pdb",
guid: "01234567-89AB-CDEF-0123-456789ABCDEF",
age: "a",
signature: null,
version: "RSDS",
loaded: false as boolean | null,
};
it.each([false, null, true])(
"carries PDB load state %s into direct search and health Evidence",
async (loaded) => {
let starts = 0;
const ghidra = provider(installationHost(), () => ({
start: () => {
starts += 1;
return Promise.resolve(
ok({ ...sessionInfo(), pdb: { ...pdb, loaded } }),
);
},
callTool: () => Promise.resolve(ok([])),
close: () => Promise.resolve(ok(null)),
}));
const target = executableTarget("pe", "x86_64");
const resolved = await ghidra.resolveAnalysisProfile(target);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("Expected a PE profile");
const client = ghidra.createClient(target, resolved.value.profile);
// No preceding health call: a name search must establish symbol coverage itself.
const searched = await client.execute("search_procedures", {
pattern: "add_numbers",
});
expect(starts).toBe(1);
if (!searched.ok) throw searched.error;
expect(searched.value.result).toEqual([]);
const health = await client.execute("health", {});
if (!health.ok) throw health.error;
expect(health.value.result).toMatchObject({ pdb: { ...pdb, loaded } });
for (const observation of [searched.value, health.value]) {
const limitations = observation.limitations.join("\n");
if (loaded === true) {
expect(limitations).toContain("loaded PDB");
expect(limitations).toContain("not an independent PDB identity");
expect(limitations).toContain("empty name search does not prove");
} else {
expect(limitations).toContain(pdb.file);
expect(limitations).toContain(pdb.guid);
expect(limitations).toContain("age (hex) a");
expect(limitations).toContain("list_procedures");
expect(limitations).toContain(
loaded === null ? "unknown" : "not loaded",
);
}
}
await client.close();
},
);
});
const executableTarget = (
format: "mach-o" | "elf" | "pe",
architecture: Extract<BinaryTarget, { format: "pe" }>["architecture"],

View File

@ -88,7 +88,10 @@ import {
relationshipCoverageFor,
} from "./GhidraProviderCapabilities.js";
import type { GhidraSessionError } from "./GhidraSessionError.js";
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
import {
ghidraPdbLimitations,
type GhidraSessionInfo,
} from "./GhidraSessionValues.js";
import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js";
import {
ghidraExtensionSchema,
@ -526,6 +529,7 @@ export const createGhidraProviderClient = (input: {
let extensionFailure: AnalysisError | undefined;
// Replaced by the reported outcome once the session handshake is checked.
const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)];
const pdbLimitations: string[] = [];
// Replaced by the reported storage outcome once the handshake is checked.
const softFloatLimitations: string[] = [
...ghidraMipsSoftFloatLimitations(softFloat),
@ -646,10 +650,16 @@ export const createGhidraProviderClient = (input: {
...(context === undefined ? {} : { runId: context.runId }),
logger: logger.child({ layer: "ghidra-bridge" }),
});
const checkExtensions = async (
const checkSessionObservations = async (
operation: AnalysisOperation,
info: GhidraSessionInfo,
): Promise<AnalysisError | undefined> => {
if (target.format === "pe")
pdbLimitations.splice(
0,
pdbLimitations.length,
...ghidraPdbLimitations(info.pdb),
);
const softFloatReason = ghidraMipsSoftFloatFailure(
softFloat,
info.mips_soft_float_storage,
@ -701,6 +711,7 @@ export const createGhidraProviderClient = (input: {
...softFloatLimitations,
...ghidraExtensionLimitations(extensions),
...seedLimitations,
...pdbLimitations,
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
];
const nativeAotOwner =
@ -772,7 +783,7 @@ export const createGhidraProviderClient = (input: {
config.ghidraStartupTimeoutMs,
),
);
const failed = await checkExtensions(operation, started.value);
const failed = await checkSessionObservations(operation, started.value);
if (failed !== undefined) return err(failed);
return ok(
createAnalysisExecution(started.value, committedProfile.provider, {
@ -785,7 +796,12 @@ export const createGhidraProviderClient = (input: {
? parseGhidraFunctionInput(operation, parameters)
: parseGhidraInventoryInput(operation, parameters);
if (!input.ok) return input;
if (extensions.length > 0 || seeds !== undefined || softFloat) {
if (
target.format === "pe" ||
extensions.length > 0 ||
seeds !== undefined ||
softFloat
) {
const started = await client.start(options?.signal, options?.progress);
if (!started.ok)
return err(
@ -795,7 +811,7 @@ export const createGhidraProviderClient = (input: {
config.ghidraStartupTimeoutMs,
),
);
const failed = await checkExtensions(operation, started.value);
const failed = await checkSessionObservations(operation, started.value);
if (failed !== undefined) return err(failed);
}
const called = await client.callTool(operation, input.value, {

View File

@ -52,6 +52,49 @@ describe("Ghidra DOS import commitment", () => {
});
});
describe("Ghidra PDB observations", () => {
const pdb = {
file: "C:\\build\\fixture.pdb",
guid: "01234567-89AB-CDEF-0123-456789ABCDEF",
age: "a",
signature: null,
version: "RSDS",
loaded: false,
};
it("retains the referenced identity and observed load state", () => {
const parsed = parseGhidraSessionInfo({ ...session(), pdb }, expected);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.pdb).toEqual(pdb);
});
it("preserves missing attributes as unknown", () => {
const unknown = Object.fromEntries(
Object.keys(pdb).map((key) => [key, null]),
);
const parsed = parseGhidraSessionInfo(
{ ...session(), pdb: unknown },
expected,
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.pdb).toEqual(unknown);
});
it.each([
{ ...pdb, loaded: "false" },
{ ...pdb, age: 1 },
{
guid: pdb.guid,
age: pdb.age,
signature: null,
version: "RSDS",
loaded: false,
},
{ ...pdb, matched: true },
])("rejects malformed PDB reports", (report) => {
expect(
parseGhidraSessionInfo({ ...session(), pdb: report }, expected).ok,
).toBe(false);
});
});
describe("Ghidra MIPS soft-float storage report", () => {
const valid = {
policy: "o32-soft-float-prototype-v1",

View File

@ -25,6 +25,13 @@ export const GHIDRA_MUTATING_OPERATIONS: ReadonlySet<string> = new Set([
...GHIDRA_NAMING_OPERATIONS,
]);
// Shared wire budget: Java bounds the aggregate before serializing the handshake.
const PDB_ATTRIBUTE_UTF16_BUDGET = 4096;
const pdbAttributeSchema = z
.string()
.max(PDB_ATTRIBUTE_UTF16_BUDGET)
.nullable();
const capabilitySchema = z.enum(GHIDRA_SESSION_CAPABILITIES);
const sessionInfoSchema = z
@ -41,6 +48,39 @@ const sessionInfoSchema = z
read_only: z.boolean(),
analysis_complete: z.boolean(),
analysis_timed_out: z.boolean(),
/** Program Information attributes; absent load state remains unknown. */
pdb: z
.strictObject({
file: pdbAttributeSchema,
guid: pdbAttributeSchema,
/** Ghidra stores CodeView age as a hexadecimal string. */
age: pdbAttributeSchema,
signature: pdbAttributeSchema,
version: pdbAttributeSchema,
loaded: z.boolean().nullable(),
resource_limit: z
.strictObject({
utf16_budget: z.literal(PDB_ATTRIBUTE_UTF16_BUDGET),
omitted_attributes: z
.array(z.enum(["file", "guid", "age", "signature", "version"]))
.min(1)
.max(5),
})
.optional(),
})
.refine(
(pdb) =>
[
pdb.file,
pdb.guid,
pdb.age,
pdb.signature,
pdb.version,
].reduce<number>((sum, value) => sum + (value?.length ?? 0), 0) <=
PDB_ATTRIBUTE_UTF16_BUDGET,
"PDB attributes exceed the aggregate wire budget",
)
.optional(),
analysis_extensions: z.array(ghidraExtensionResultSchema).optional(),
analysis_seeds: ghidraSeedReportSchema.optional(),
mips_soft_float_storage: z
@ -68,6 +108,37 @@ const sessionInfoSchema = z
/** Authenticated metadata produced after Ghidra finishes auto-analysis. */
export type GhidraSessionInfo = z.infer<typeof sessionInfoSchema>;
/** Describe missing PDB authority without claiming all names are generated. */
export const ghidraPdbLimitations = (
pdb: GhidraSessionInfo["pdb"],
): readonly string[] => {
if (pdb === undefined)
return [
"Ghidra did not report PDB attributes; debug-symbol coverage is unknown.",
];
const limitations: string[] = [];
if (pdb.resource_limit !== undefined)
limitations.push(
`PDB attributes ${pdb.resource_limit.omitted_attributes.join(", ")} were omitted because the handshake's ${pdb.resource_limit.utf16_budget}-UTF-16-unit PDB attribute budget was exhausted. Omitted values remain unknown; they were not truncated or reported as absent.`,
);
if (pdb.loaded === true)
return [
...limitations,
"Ghidra reports a loaded PDB; this is not an independent PDB identity or complete symbol/type-coverage attestation. Original names and types may still be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions.",
];
if (
[pdb.file, pdb.guid, pdb.age, pdb.signature, pdb.version].every(
(value) => value === null,
)
)
return limitations;
const state = pdb.loaded === false ? "not loaded" : "load state unknown";
return [
...limitations,
`The target references PDB ${pdb.file ?? "(file unknown)"} (GUID ${pdb.guid ?? "unknown"}, age (hex) ${pdb.age ?? "unknown"}, signature ${pdb.signature ?? "unknown"}, version ${pdb.version ?? "unknown"}); Ghidra reports ${state}. Original names and types may be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`,
];
};
/** Require exact run, provider, and profile identity from the Java bridge. */
export const parseGhidraSessionInfo = (
value: JsonValue,

View File

@ -1,10 +1,86 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { expect, it } from "vitest";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { ok } from "../../../src/domain/result.js";
import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
it("retains missing PDB coverage in empty PE name-search Evidence and snapshot replay", async () => {
const root = await createTestTempDirectory("rea-pdb-evidence-");
const targetPath = join(root, "fixture.exe");
await writeFile(targetPath, nativeAotPeFixture());
// Synthetic post-analysis facts at the real provider boundary; no engine is simulated as verified.
const pdb = {
file: "C:\\build\\fixture.pdb",
guid: "01234567-89AB-CDEF-0123-456789ABCDEF",
age: "a",
signature: null,
version: "RSDS",
loaded: false,
};
const harness = await connectGhidraMcp(
"ghidra-pdb-coverage",
(operation) =>
Promise.resolve(
ok(
operation === "procedure_pseudo_code"
? {
value: "int FUN_140001000(void) { return 0; }",
limitations: [],
}
: [],
),
),
{
targetPath,
pdb,
},
);
try {
const reply = await harness.mcp.callTool({
name: "search_procedures",
arguments: { pattern: "add_numbers" },
});
expect(reply.isError).not.toBe(true);
const evidence = sessionEvidence(harness.session, reply.structuredContent);
expect(evidence.normalized_result).toEqual([]);
expect(evidence.analysis_profile?.parameters.pdb_observations).toBe(
"program-info-v1",
);
const limitation = evidence.limitations.find((value) =>
value.includes(pdb.file),
);
expect(limitation).toContain(pdb.guid);
expect(limitation).toContain("age (hex) a");
expect(limitation).toContain("not loaded");
const decompiled = await harness.mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "0x140001000", document: "fixture" },
});
expect(decompiled.isError).not.toBe(true);
const functionEvidence = sessionEvidence(
harness.session,
decompiled.structuredContent,
);
expect(functionEvidence.limitations).toContain(limitation);
const replay = await harness.session.execute(
"procedure_pseudo_code",
functionEvidence.parameters,
);
if (!replay.ok) throw replay.error;
expect(replay.value.limitations).toContain(limitation);
expect(replay.value.limitations).toContainEqual(
expect.stringContaining("Loaded from a local REA analysis snapshot"),
);
} finally {
await harness.close();
}
});
it("retains defined-string coverage in empty and matching search Evidence", async () => {
let output = jsonValueSchema.parse([]);
const harness = await connectGhidraMcp("ghidra-string-coverage", () =>

View File

@ -12,6 +12,7 @@ import type { GhidraOperation } from "../../../src/ghidra/GhidraClient.js";
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProviderClient.js";
import { GHIDRA_SESSION_CAPABILITIES } from "../../../src/ghidra/GhidraSessionValues.js";
import type { GhidraSessionInfo } from "../../../src/ghidra/GhidraSessionValues.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import {
@ -26,12 +27,19 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
export const connectGhidraMcp = async (
name: string,
execute?: ReturnType<GhidraProviderClientFactory>["callTool"],
observations: {
readonly targetPath?: string;
readonly pdb?: GhidraSessionInfo["pdb"];
} = {},
) => {
const calls: GhidraOperation[] = [];
const factory: GhidraProviderClientFactory = (options) => ({
start: () =>
Promise.resolve(
ok(sessionInfo(options.profileDigest, options.targetSha256)),
ok({
...sessionInfo(options.profileDigest, options.targetSha256),
...(observations.pdb === undefined ? {} : { pdb: observations.pdb }),
}),
),
callTool: (operation, input, options) => {
calls.push(operation);
@ -68,7 +76,7 @@ export const connectGhidraMcp = async (
await mcp.connect(clientTransport);
const opened = await mcp.callTool({
name: "open_binary",
arguments: { path: process.execPath },
arguments: { path: observations.targetPath ?? process.execPath },
});
if (opened.isError === true)
throw new Error(