fix(native): preserve compact reference provenance
This commit is contained in:
@@ -11,7 +11,7 @@ for (const architecture of ["x86_64", "x86"]) {
|
||||
"fixtures",
|
||||
x86 ? "rea-ghidra-windows-x86.exe" : "rea-ghidra-windows.exe",
|
||||
);
|
||||
const bytes = Buffer.alloc(1024);
|
||||
const bytes = Buffer.alloc(1536);
|
||||
const peOffset = 0x80;
|
||||
const optionalHeader = peOffset + 24;
|
||||
const optionalHeaderSize = x86 ? 0xe0 : 0xf0;
|
||||
@@ -21,12 +21,13 @@ 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(2, peOffset + 6);
|
||||
bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20);
|
||||
bytes.writeUInt16LE(x86 ? 0x102 : 0x22, peOffset + 22);
|
||||
|
||||
bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 4);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 8);
|
||||
bytes.writeUInt32LE(0x1000, optionalHeader + 16);
|
||||
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
|
||||
if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28);
|
||||
@@ -35,7 +36,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(0x3000, optionalHeader + 56);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 60);
|
||||
bytes.writeUInt16LE(3, optionalHeader + 68);
|
||||
bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70);
|
||||
@@ -54,15 +55,54 @@ for (const architecture of ["x86_64", "x86"]) {
|
||||
}
|
||||
|
||||
bytes.write(".text", sectionTable, "ascii");
|
||||
bytes.writeUInt32LE(0x16, sectionTable + 8);
|
||||
bytes.writeUInt32LE(0x50, sectionTable + 8);
|
||||
bytes.writeUInt32LE(0x1000, sectionTable + 12);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 16);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 20);
|
||||
bytes.writeUInt32LE(0x6000_0020, sectionTable + 36);
|
||||
|
||||
// entry calls the second function, returns zero; second returns 42.
|
||||
bytes.set([0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3], 0x200);
|
||||
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);
|
||||
|
||||
// Entry reaches both functions; the original callee still returns 42.
|
||||
// These source-owned bytes are inspected statically, never executed.
|
||||
bytes.set(
|
||||
[0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3],
|
||||
0x200,
|
||||
);
|
||||
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
|
||||
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);
|
||||
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, bytes);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -281,6 +282,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"))
|
||||
|
||||
@@ -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(
|
||||
@@ -232,6 +233,10 @@ try {
|
||||
`Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`,
|
||||
);
|
||||
|
||||
const compactReferences = await verifyGhidraCompactReferences(
|
||||
functionOperation,
|
||||
architecture === "x86",
|
||||
);
|
||||
report = {
|
||||
ok: true,
|
||||
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
||||
@@ -246,6 +251,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,
|
||||
|
||||
Reference in New Issue
Block a user