fix(native): preserve compact reference provenance (#1438)

Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
This commit is contained in:
Joseph Demarest 2026-10-10 09:39:28 -04:00 committed by GitHub
parent 2ec93ba166
commit 24d9337649
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 523 additions and 12 deletions

View File

@ -841,14 +841,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
private JsonObject instructionReference(Reference reference) {
JsonObject item = new JsonObject();
RefType type = reference.getReferenceType();
JsonObject kind = referenceKind(reference);
item.addProperty("source_address", canonicalAddress(reference.getFromAddress()));
item.addProperty("target_address", canonicalAddress(reference.getToAddress()));
item.addProperty("type", type.getName());
item.addProperty("call", type.isCall());
item.addProperty("jump", type.isJump());
item.addProperty("indirect", type.isIndirect());
item.addProperty("computed", type.isComputed());
item.addProperty("operand_index", reference.getOperandIndex());
for (String field : List.of("type", "call", "jump", "indirect", "computed",
"operand_index", "data", "read", "write", "primary", "provenance"))
item.add(field, kind.get(field));
item.addProperty("source", normalizedSource(reference.getSource()));
return item;
}

View File

@ -195,6 +195,14 @@ outside-memory and undecodable addresses have separate outcomes. Effective
memory base/index/displacement roles and per-instruction context mode remain
unavailable; `mode` is the program language variant.
Compact instruction references and call-target references preserve the source
address, operand index, DATA/READ/WRITE classification, primary status, provider
`provenance`, and reference `source` (for Ghidra: `analysis`, `default`, `imported`
or `user_defined`). Primary status reports the provider's chosen reference; it
does not establish literal addressing. Analyzer-inferred indexed targets remain
static references, not observed runtime reads or writes. Older evidence may omit
the additive fields, and null or omitted facts mean unavailable rather than false.
Ghidra distinguishes a return using decoded instruction p-code's `RETURN`
operation. `flow.classification_evidence` preserves the Listing flow type and
the observed return evidence; mnemonic spelling alone is not the classifier.

View File

@ -13,7 +13,7 @@ for (const architecture of ["x86_64", "x86"]) {
"fixtures",
`rea-ghidra-windows${x86 ? "-x86" : ""}.${dll ? "dll" : "exe"}`,
);
const bytes = Buffer.alloc(1024);
const bytes = Buffer.alloc(dll ? 1024 : 1536);
const peOffset = 0x80;
const optionalHeader = peOffset + 24;
const optionalHeaderSize = x86 ? 0xe0 : 0xf0;
@ -24,7 +24,7 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.writeUInt32LE(peOffset, 0x3c);
bytes.write("PE\0\0", peOffset, "binary");
bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4);
bytes.writeUInt16LE(1, peOffset + 6);
bytes.writeUInt16LE(dll ? 1 : 2, peOffset + 6);
bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20);
bytes.writeUInt16LE(
(x86 ? 0x102 : 0x22) | (dll ? 0x2000 : 0),
@ -33,6 +33,7 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader);
bytes.writeUInt32LE(0x200, optionalHeader + 4);
if (!dll) bytes.writeUInt32LE(0x200, optionalHeader + 8);
bytes.writeUInt32LE(dll ? 0 : 0x1000, optionalHeader + 16);
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28);
@ -41,7 +42,7 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.writeUInt32LE(0x200, optionalHeader + 36);
bytes.writeUInt16LE(6, optionalHeader + 40);
bytes.writeUInt16LE(6, optionalHeader + 48);
bytes.writeUInt32LE(0x2000, optionalHeader + 56);
bytes.writeUInt32LE(dll ? 0x2000 : 0x3000, optionalHeader + 56);
bytes.writeUInt32LE(0x200, optionalHeader + 60);
bytes.writeUInt16LE(3, optionalHeader + 68);
bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70);
@ -60,7 +61,7 @@ for (const architecture of ["x86_64", "x86"]) {
}
bytes.write(".text", sectionTable, "ascii");
bytes.writeUInt32LE(dll ? 0x180 : 0x16, sectionTable + 8);
bytes.writeUInt32LE(dll ? 0x180 : 0x50, sectionTable + 8);
bytes.writeUInt32LE(0x1000, sectionTable + 12);
bytes.writeUInt32LE(0x200, sectionTable + 16);
bytes.writeUInt32LE(0x200, sectionTable + 20);
@ -70,11 +71,48 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.set(
dll
? [0xe8, 0x0b, 0, 0, 0, 0x83, 0xc0, 1, 0xc3]
: [0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3],
: [0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3],
0x200,
);
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
if (!dll) {
const dataSection = sectionTable + 40;
bytes.write(".data", dataSection, "ascii");
bytes.writeUInt32LE(0x20, dataSection + 8);
bytes.writeUInt32LE(0x2000, dataSection + 12);
bytes.writeUInt32LE(0x200, dataSection + 16);
bytes.writeUInt32LE(0x400, dataSection + 20);
bytes.writeUInt32LE(0xc000_0040, dataSection + 36);
bytes.writeUInt32LE(0x1122_3344, 0x400);
const referenceInstructions = x86
? [
"ba00204000", // MOV EDX, address: DATA only.
"a100204000", // MOV EAX, [address]: READ.
"a304204000", // MOV [address], EAX: WRITE.
"ba10204000", // Indexed base, unknown ECX.
"85db", // TEST EBX, EBX: two candidate bases.
"7405", // JZ over the alternate base.
"ba18204000", // MOV EDX, alternate base.
"c6040a01", // MOV byte [EDX + ECX], 1.
"ffd7", // CALL EDI: unknown target.
"c3",
]
: [
"488d15d90f0000", // LEA RDX, [RIP + displacement]: DATA.
"8b05d30f0000", // MOV EAX, [RIP + displacement]: READ.
"8905d10f0000", // MOV [RIP + displacement], EAX: WRITE.
"488d15d60f0000", // Indexed base, unknown RCX.
"85db", // TEST EBX, EBX: two candidate bases.
"7407", // JZ over the alternate base.
"488d15d30f0000", // LEA RDX, alternate base.
"c6040a01", // MOV byte [RDX + RCX], 1.
"41ffd0", // CALL R8: unknown target.
"c3",
];
bytes.set(Buffer.from(referenceInstructions.join(""), "hex"), 0x220);
}
if (dll) {
// Export both functions; an entry-point-free library is discovered by exports.
bytes.writeUInt32LE(0x1040, directories);

View File

@ -0,0 +1,170 @@
import assert from "node:assert/strict";
/** Verify actual reference facts on the source-owned Windows PE fixture. */
export async function verifyGhidraCompactReferences(call, x86, cli) {
const base = x86 ? 0x400000n : 0x1_4000_0000n;
const address = (offset) => `0x${(base + BigInt(offset)).toString(16)}`;
const cases = [
["data", 0x1020, 0x2000],
["read", x86 ? 0x1025 : 0x1027, 0x2000],
["write", x86 ? 0x102a : 0x102d, 0x2004],
];
const full = await call("procedure_references", {
procedure: address(0x1020),
});
const inspected = {};
for (const [kind, offset, target] of cases) {
const instruction = await call("inspect_native_instruction", {
address: address(offset),
});
inspected[kind] = instruction;
const reference = instruction.references.find(
(item) => item.target_address === address(target),
);
assert.ok(reference, `Missing ${kind} fixture reference`);
assertReference(reference, instruction.address, full.references);
assert.equal(reference.data, true);
assert.equal(reference.read, kind === "read");
assert.equal(reference.write, kind === "write");
}
const indexed = await call("inspect_native_instruction", {
address: address(x86 ? 0x103d : 0x1045),
});
for (const reference of indexed.references)
assertReference(reference, indexed.address, full.references);
assert.ok(
indexed.references.some(
(reference) =>
reference.primary === false && reference.source === "analysis",
),
"Indexed fixture lacks a non-primary analyzer reference",
);
const { direct, targets } = await verifyDirectCall(
call,
address(0x1000),
address(0x1010),
);
const indirectAddress = address(x86 ? 0x1041 : 0x1049);
const indirect = await call("inspect_native_instruction", {
address: indirectAddress,
});
const unresolved = await call("resolve_native_call_targets", {
address: indirectAddress,
});
assert.equal(indirect.flow.kind, "call");
assert.deepEqual(indirect.references, []);
assert.equal(unresolved.status, "unresolved");
assert.deepEqual(unresolved.targets, []);
const returned = await verifyEmptyReferences(
call,
address(x86 ? 0x1043 : 0x104c),
);
if (cli !== undefined) {
for (const instruction of [inspected.write, indexed, direct, returned])
assert.deepEqual(
await cli("inspect-native-instruction", instruction.address),
instruction,
);
assert.deepEqual(
await cli("resolve-native-call-targets", direct.address),
targets,
);
assert.deepEqual(
await cli("resolve-native-call-targets", indirectAddress),
unresolved,
);
}
return {
data_read_write: true,
source_primary_provenance: true,
full_reference_parity: true,
direct_call_reference_parity: true,
indexed_reference_count: indexed.references.length,
indexed_non_primary_count: indexed.references.filter(
(reference) => reference.primary === false,
).length,
indexed_non_primary_analyzer_reference: true,
indexed_reference_kinds: indexed.references.map(
({ type, read, write, primary, source }) => ({
type,
read,
write,
primary,
source,
}),
),
targetless_indirect: true,
empty_and_unavailable: true,
cli_mcp_parity: cli !== undefined,
};
}
async function verifyDirectCall(call, entry, callee) {
const direct = await call("inspect_native_instruction", { address: entry });
const targets = await call("resolve_native_call_targets", { address: entry });
assert.equal(targets.status, "direct");
assert.equal(targets.targets[0].address, callee);
assert.deepEqual(
targets.targets[0].references,
direct.references.filter((item) => item.call),
);
const full = await call("procedure_references", { procedure: entry });
for (const reference of direct.references)
assertReference(reference, entry, full.references);
return { direct, targets };
}
async function verifyEmptyReferences(call, address) {
const returned = await call("inspect_native_instruction", { address });
assert.equal(returned.flow.kind, "return");
assert.deepEqual(returned.references, []);
assert.equal(
(await call("resolve_native_call_targets", { address })).status,
"not-call",
);
const outside = await call("inspect_native_instruction", { address: "0x0" });
assert.equal(outside.status, "outside-memory");
assert.deepEqual(outside.references, []);
assert.equal(
(await call("resolve_native_call_targets", { address: "0x0" })).status,
"unavailable",
);
return returned;
}
function assertReference(reference, source, full) {
assert.equal(reference.source_address, source);
assert.equal(reference.provenance, "ghidra-reference-manager");
assert.equal(typeof reference.primary, "boolean");
assert.equal(typeof reference.source, "string");
assert.ok(reference.source.length > 0);
const edge = full.find(
(item) =>
item.source_address === source &&
item.target_address === reference.target_address &&
item.kind.operand_index === reference.operand_index,
);
assert.ok(edge, "Compact reference lacks its matching full reference");
for (const field of [
"type",
"call",
"jump",
"indirect",
"computed",
"operand_index",
"data",
"read",
"write",
"primary",
"provenance",
])
assert.equal(
reference[field],
edge.kind[field],
`Reference ${field} differs from its full representation`,
);
}

View File

@ -2,6 +2,7 @@
import assert from "node:assert/strict";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
@ -358,6 +359,36 @@ try {
await call("inspect_native_data_type", { type: "/undefined8" });
await call("inspect_native_instruction", { address: procedure.address });
await call("resolve_native_call_targets", { address: procedure.address });
// Explicit caller-selected targets need not contain our fixture instructions.
const controlledTargets = [
"rea-ghidra-windows.exe",
"rea-ghidra-windows-x86.exe",
].map((name) => resolve("build", "fixtures", name));
if (controlledTargets.includes(target))
report.compactReferences = await verifyGhidraCompactReferences(
call,
target === controlledTargets[1],
async (command, address) => {
const result = await exec(
process.execPath,
[entry, command, target, address, "--provider", "ghidra", "--json"],
{
env: environment,
cwd: callerDirectory,
timeout: 360_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const evidence = JSON.parse(result.stdout);
assert.ok(evidence.error === undefined, `CLI ${command} failed`);
const contract = TOOL_CONTRACTS.find(
({ name }) => name === command.replaceAll("-", "_"),
);
assert.ok(contract, `Missing CLI ${command} contract`);
contract.outputSchema.parse(evidence);
return evidence.normalized_result;
},
);
const expected = (
await import(
pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js"))

View File

@ -24,6 +24,7 @@ import { windowsP0Capabilities } from "../dist/ghidra/GhidraProviderCapabilities
import { GHIDRA_SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
const verifierRun = createVerifierRun();
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
@ -247,6 +248,14 @@ try {
`Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`,
);
// The reference controls occupy the EXE fixture's additional function.
// DLL fixtures retain their independent exported-caller/return controls.
const compactReferences = dllFixture
? null
: await verifyGhidraCompactReferences(
functionOperation,
architecture === "x86",
);
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
@ -261,6 +270,7 @@ try {
transport: "authenticated-loopback-tcp",
operations: [...observed].sort((left, right) => left.localeCompare(right)),
cleanup: "complete",
compact_references: compactReferences,
native_controls: {
job_object: true,
protected_dacl: true,

View File

@ -0,0 +1,60 @@
import type { JsonValue } from "../jsonValue.js";
/** Wire-boundary controls; these do not establish real Ghidra behavior. */
export const compactReference = () => ({
source_address: "0x401000",
target_address: "0x402000",
type: "COMPUTED_CALL",
call: true,
jump: false,
indirect: false,
computed: true,
operand_index: 1,
data: false,
read: false,
write: false,
primary: false,
provenance: "ghidra-reference-manager",
source: "analysis",
});
export const compactInstruction = (references: JsonValue[] = []) => ({
address: "0x401000",
status: "decoded",
procedure: "0x401000",
architecture: "x86:LE:32:default",
mode: "default",
bytes: "ffd0",
length: 2,
mnemonic: "CALL",
raw_disassembly: "CALL EAX",
operands: [],
flow: {
kind: "call",
conditional: false,
computed: true,
direct_destinations: [],
},
references,
limitations: ["Static references do not establish runtime targets."],
});
export const compactCallTargets = (references: JsonValue[] = []) => ({
call_site: "0x401000",
procedure: "0x401000",
status: references.length === 0 ? "unresolved" : "resolved-indirect",
mechanism: "computed",
targets:
references.length === 0
? []
: [
{
address: "0x402000",
procedure: null,
status: "resolved-indirect",
basis: "provider-reference",
references,
},
],
limitations: ["Static references do not establish runtime targets."],
});

View File

@ -13,6 +13,33 @@ const reference = z.strictObject({
indirect: z.boolean(),
computed: z.boolean(),
operand_index: z.number().int(),
source_address: z.string().min(1).nullable().optional(),
data: z.boolean().nullable().optional(),
read: z.boolean().nullable().optional(),
write: z.boolean().nullable().optional(),
primary: z
.boolean()
.nullable()
.optional()
.describe(
"Provider primary-reference status, not proof of literal addressing or a runtime access. Null or omission means unavailable.",
),
provenance: z
.string()
.min(1)
.nullable()
.optional()
.describe(
"Provider subsystem reporting this reference; null or omission means unavailable.",
),
source: z
.string()
.min(1)
.nullable()
.optional()
.describe(
"Provider reference origin, such as analysis, default, imported or user_defined; null or omission means unavailable.",
),
});
/** Provider-decoded facts for one instruction; operand tokens preserve their native ordering. */
export const nativeInstructionSchema = z.strictObject({

View File

@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import {
compactReference,
compactInstruction,
compactCallTargets,
} from "../domain/native/nativeInstruction.fixture.js";
import {
parseGhidraFunctionResult,
type GhidraFunctionOperation,
@ -118,6 +123,75 @@ describe("Ghidra function-analysis result values", () => {
});
});
describe("Ghidra compact reference provenance", () => {
const operations = [
["inspect_native_instruction", compactInstruction],
["resolve_native_call_targets", compactCallTargets],
] as const;
it.each(operations)(
"preserves complete, legacy and unknown facts in %s",
(operation, output) => {
const complete = compactReference();
const legacy = Object.fromEntries(
Object.entries(complete).filter(
([key]) =>
![
"source_address",
"data",
"read",
"write",
"primary",
"provenance",
"source",
].includes(key),
),
);
const unknown = {
...legacy,
source_address: null,
data: null,
read: null,
write: null,
primary: null,
provenance: null,
source: null,
};
for (const reference of [complete, legacy, unknown]) {
const expected = output([reference]);
const parsed = parseGhidraFunctionResult(operation, expected);
if (!parsed.ok) throw parsed.error;
expect(parsed.value).toEqual(expected);
}
},
);
it.each(operations)(
"rejects malformed present provenance in %s",
(operation, output) => {
for (const [field, value] of [
["source_address", 1],
["data", "true"],
["read", 1],
["write", 0],
["primary", "false"],
["provenance", ""],
["source", false],
] as const) {
expect(
parseGhidraFunctionResult(
operation,
output([{ ...compactReference(), [field]: value }]),
),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
}
},
);
});
describe("Ghidra jump-table mapping contract", () => {
it("preserves evidenced defaults separately from cases and unresolved targets", () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());

View File

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { ok } from "../../../src/domain/result.js";
import {
compactReference,
compactInstruction,
compactCallTargets,
} from "../../../src/domain/native/nativeInstruction.fixture.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
describe("compact Ghidra reference MCP evidence", () => {
it("retains access kind, non-primary analyzer references and call-target provenance", async () => {
const base = {
...compactReference(),
call: false,
computed: false,
data: true,
};
const references = [
{ ...base, type: "DATA", primary: true, operand_index: 1 },
{ ...base, type: "READ", primary: true, read: true, operand_index: 1 },
{ ...base, type: "WRITE", primary: false, write: true, operand_index: 0 },
];
const outputs = {
inspect_native_instruction: compactInstruction(references),
resolve_native_call_targets: compactCallTargets([compactReference()]),
};
const harness = await connectGhidraMcp(
"ghidra-compact-provenance",
(operation) => {
if (
operation !== "inspect_native_instruction" &&
operation !== "resolve_native_call_targets"
)
throw new TypeError(`Unexpected operation: ${operation}`);
return Promise.resolve(ok(outputs[operation]));
},
);
try {
for (const name of [
"inspect_native_instruction",
"resolve_native_call_targets",
] as const) {
const result = await harness.mcp.callTool({
name,
arguments: { address: "0x401000" },
});
expect(result.isError).not.toBe(true);
const evidence = sessionEvidence(
harness.session,
result.structuredContent,
);
expect(evidence.normalized_result).toEqual(outputs[name]);
}
} finally {
await harness.close();
}
}, 30_000);
it("preserves empty references and unresolved targets without manufacturing provenance", async () => {
const outputs = {
inspect_native_instruction: compactInstruction(),
resolve_native_call_targets: compactCallTargets(),
};
const harness = await connectGhidraMcp(
"ghidra-compact-unknown",
(operation) => {
if (
operation !== "inspect_native_instruction" &&
operation !== "resolve_native_call_targets"
)
throw new TypeError(`Unexpected operation: ${operation}`);
return Promise.resolve(ok(outputs[operation]));
},
);
try {
for (const name of [
"inspect_native_instruction",
"resolve_native_call_targets",
] as const) {
const result = await harness.mcp.callTool({
name,
arguments: { address: "0x401000" },
});
expect(result.isError).not.toBe(true);
expect(
sessionEvidence(harness.session, result.structuredContent)
.normalized_result,
).toEqual(outputs[name]);
}
} finally {
await harness.close();
}
}, 30_000);
});