fix(ghidra): retain function boundary limitations on references

This commit is contained in:
morluto committed 2026-10-15 06:20:20 +08:00
1 parent 709e99d781
commit 01a1b3fda6
9 files changed
+262 -9

No files matched your search

+14 -4
View File
@@ -1131,12 +1131,22 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("direction", direction);
result.add("references", edges);
result.addProperty("reference_kinds_available", true);
JsonArray unresolvedCalls = direction.equals("outgoing")
? callBoundaryObservation(
List<String> limitations;
JsonArray unresolvedCalls = new JsonArray();
if (direction.equals("outgoing")) {
CallBoundaryObservation calls = callBoundaryObservation(
function, currentProgram.getListing().getInstructions(function.getBody(), true), null, true, references
).unresolvedCalls() : new JsonArray();
);
unresolvedCalls = calls.unresolvedCalls();
limitations = calls.limitations();
} else {
limitations = noReturnCallLimitations(
function,
currentProgram.getListing().getInstructions(function.getBody(), true)
);
}
result.add("unresolved_calls", unresolvedCalls);
return result;
return functionObservation(result, limitations);
}
private JsonObject inspectNativeDataType(JsonObject params) throws Exception {
+2 -2
View File
@@ -153,8 +153,8 @@ overview merely because its source artifact is unchanged.
Function extents and instruction completeness refer to Ghidra's analyzed body.
An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, callee and caller
reads, and dossiers report observed terminal call sites in Evidence limitations,
Pseudocode, assembly, procedure info, instruction windows, incoming and outgoing
references, callee and caller reads, and dossiers report observed terminal call sites in Evidence limitations,
including resolved callee names, addresses, and FunctionManager flags when
available. On x86-32, the same views also report the exact Borland/Delphi-style
FS exception-registration pattern when its handler address falls outside the
+51 -1
View File
@@ -75,7 +75,57 @@ for (const architecture of ["x86_64", "x86"]) {
: [0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3],
0x200,
);
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
bytes.set(
x86 && !dll
? [0xb8, 0x2a, 0, 0, 0, 0xe8, 0x66, 0, 0, 0, 0xc3]
: [0xb8, 0x2a, 0, 0, 0, 0xc3],
0x210,
);
if (x86 && !dll) {
// A statically analyzed Borland-style SEH registration whose handler
// entry is referenced but is not part of the registering function body.
bytes.set(
[
0x55, // PUSH EBP
0x89,
0xe5, // MOV EBP, ESP
0x68,
0xc0,
0x10,
0x40,
0x00, // PUSH 0x4010c0
0x64,
0xff,
0x35,
0,
0,
0,
0, // PUSH DWORD PTR FS:[0]
0x64,
0x89,
0x25,
0,
0,
0,
0, // MOV DWORD PTR FS:[0], ESP
0x64,
0x8f,
0x05,
0,
0,
0,
0, // POP DWORD PTR FS:[0]
0x83,
0xc4,
0x04, // ADD ESP, 4
0x5d, // POP EBP
0xc3, // RET
],
0x280,
);
bytes.set([0xc3], 0x2c0);
}
if (!dll) {
const dataSection = sectionTable + 40;
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
const limitationPattern = /Borland\/Delphi-style SEH frame registration/u;
/** Verify Ghidra reports, and does not absorb, the fixture's handler region. */
export async function verifyGhidraBorlandSeh(functionObservation, x86) {
assert.equal(x86, true, "The SEH control exists only in the x86 PE fixture");
const caller = "0x401015";
const procedure = "0x401080";
const handler = "0x4010c0";
const ordinary = await functionObservation("procedure_info", {
procedure: "0x401010",
});
assert.ok(
!ordinary.limitations.some((item) => limitationPattern.test(item)),
"The ordinary return-42 control unexpectedly reports an SEH limitation",
);
const info = await functionObservation("procedure_info", { procedure });
assert.ok(info.limitations.some((item) => limitationPattern.test(item)));
const outgoing = await functionObservation("procedure_references", {
procedure,
direction: "outgoing",
});
assert.equal(outgoing.value.direction, "outgoing");
assert.ok(outgoing.limitations.some((item) => limitationPattern.test(item)));
assert.ok(
outgoing.value.references.some(
(reference) =>
reference.source_address === "0x401083" &&
reference.target_address === handler &&
reference.kind.available &&
reference.kind.data &&
!reference.kind.call,
),
"The pushed handler address must remain an observed data reference, without inventing a call",
);
const incoming = await functionObservation("procedure_references", {
procedure,
direction: "incoming",
});
assert.equal(incoming.value.direction, "incoming");
assert.ok(incoming.limitations.some((item) => limitationPattern.test(item)));
assert.ok(
incoming.value.references.some(
(reference) =>
reference.source_address === caller &&
reference.target_address === procedure,
),
"The caller-to-registration procedure reference was not observed",
);
const dossier = await functionObservation("analyze_function", {
procedure,
});
assert.ok(dossier.limitations.some((item) => limitationPattern.test(item)));
assert.ok(
dossier.value.limitations.some((item) => limitationPattern.test(item)),
"The normalized function dossier lost the SEH limitation",
);
assert.equal(dossier.value.procedure.body.available, true);
assert.ok(dossier.value.procedure.body.ranges.length > 0);
assert.ok(
!dossier.value.procedure.body.ranges.some(
(range) =>
BigInt(range.start) <= BigInt(handler) &&
BigInt(range.end) >= BigInt(handler),
),
"The handler was incorrectly claimed as part of the procedure body",
);
return {
limitation: true,
ordinary_control: true,
procedure_references_incoming_and_outgoing: true,
handler_data_reference: true,
caller_reference: true,
dossier_limitation: true,
handler_body_not_claimed: true,
};
}
+12 -1
View File
@@ -29,6 +29,7 @@ import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { resolveGhidraVerifierTimeouts } from "./lib/ghidra-verifier-timeouts.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { verifyGhidraBorlandSeh } from "./lib/real-ghidra-borland-seh.mjs";
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
import { verifyWindowsGhidraByteSearch } from "./lib/ghidra-windows-byte-search.mjs";
@@ -273,6 +274,10 @@ try {
functionOperation,
architecture === "x86",
);
const borlandSeh =
architecture === "x86" && !dllFixture
? await verifyGhidraBorlandSeh(functionObservationOperation, true)
: null;
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
@@ -289,6 +294,7 @@ try {
byte_search: byteSearch,
cleanup: "complete",
compact_references: compactReferences,
borland_seh: borlandSeh,
native_controls: {
job_object: true,
protected_dacl: true,
@@ -338,6 +344,11 @@ async function inventory(operation, parameters) {
}
async function functionOperation(operation, parameters) {
const observation = await functionObservationOperation(operation, parameters);
return observation.value;
}
async function functionObservationOperation(operation, parameters) {
const input = parseGhidraFunctionInput(operation, parameters);
if (!input.ok) throw input.error;
const called = await client.callTool(operation, input.value);
@@ -345,7 +356,7 @@ async function functionOperation(operation, parameters) {
const result = parseGhidraFunctionResult(operation, called.value);
if (!result.ok) throw result.error;
observed.add(operation);
return result.value.value;
return result.value;
}
function assertSession(session, targetSha256, profileDigest) {
+38
View File
@@ -27,6 +27,7 @@ const observationOperations: ReadonlySet<GhidraFunctionOperation> = new Set([
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
"read_function_instructions",
]);
@@ -240,6 +241,43 @@ describe("Ghidra relationship metadata validation", () => {
});
});
it("preserves handler-region limitations on procedure references", () => {
const limitation =
"Ghidra Listing shows a Borland/Delphi-style SEH frame registration at 0x401008 (push 0x4010a0 followed by fs-segment stack chaining); handler 0x4010a0 lies outside this procedure's body, so callees, strings, and cross-references may omit the handler region.";
const parsed = parseGhidraFunctionResult("procedure_references", {
value: {
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [ghidraReferenceEdge()],
reference_kinds_available: true,
unresolved_calls: [],
},
limitations: [limitation],
});
expect(parsed).toEqual({
ok: true,
value: {
value: {
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [ghidraReferenceEdge()],
reference_kinds_available: true,
unresolved_calls: [],
},
limitations: [limitation],
},
});
expect(
parseGhidraFunctionResult("procedure_references", {
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [],
reference_kinds_available: true,
unresolved_calls: [],
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it.each(["procedure_callees", "procedure_callers"] as const)(
"requires the function-observation envelope to carry terminal-call limitations for %s",
(operation) => {
+1
View File
@@ -418,6 +418,7 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
"read_function_instructions",
]);
+59 -1
View File
@@ -4,7 +4,10 @@ import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import {
ghidraFunctionDossier,
ghidraFunctionIdentity,
} from "../../../src/domain/ghidraValues.fixture.js";
import {
jsonValueSchema,
type JsonValue,
@@ -202,6 +205,61 @@ it("retains terminal-call qualifications across function views and snapshot repl
}
});
it("retains handler-region limitations on incoming and outgoing reference Evidence", async () => {
const limitation =
"Ghidra Listing shows a Borland/Delphi-style SEH frame registration at 0x40108f (push 0x4010c0 followed by fs-segment stack chaining); handler 0x4010c0 lies outside this procedure's body, so callees, strings, and cross-references may omit the handler region.";
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const harness = await connectGhidraMcp(
"ghidra-seh-reference-evidence",
(operation, input) => {
if (operation !== "procedure_references")
throw new Error(`Unexpected operation: ${operation}`);
return Promise.resolve(
ok({
value: jsonValueSchema.parse({
procedure: ghidraFunctionIdentity(),
direction: input.direction ?? "outgoing",
references: [],
reference_kinds_available: true,
unresolved_calls: [],
}),
limitations: [limitation],
}),
);
},
);
try {
for (const direction of ["incoming", "outgoing"] as const) {
const reply = await harness.mcp.callTool({
name: "procedure_references",
arguments: { procedure: dossier.procedure.address, direction },
});
expect(reply.isError).not.toBe(true);
const evidence = sessionEvidence(
harness.session,
reply.structuredContent,
);
expect(evidence.normalized_result).toMatchObject({ direction });
expect(evidence.raw_result).toMatchObject({ limitations: [limitation] });
expect(evidence.limitations).toContain(limitation);
const replay = await harness.session.execute(
"procedure_references",
evidence.parameters,
);
if (!replay.ok) throw replay.error;
expect(replay.value.limitations).toContain(limitation);
}
expect(harness.calls).toEqual([
"procedure_references",
"procedure_references",
"procedure_references",
"procedure_references",
]);
} finally {
await harness.close();
}
});
it("rejects contradictory annotation readback across the provider and MCP boundaries", async () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const annotations = {
+1
View File
@@ -274,6 +274,7 @@ const functionObservationOperations: ReadonlySet<GhidraOperation> = new Set([
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
"read_function_instructions",
]);