450 lines
13 KiB
JavaScript
450 lines
13 KiB
JavaScript
import {
|
|
parseGhidraFunctionInput,
|
|
parseGhidraFunctionResult,
|
|
} from "../dist/ghidra/GhidraFunctionValues.js";
|
|
|
|
import {
|
|
assertDossier,
|
|
assertLocalProcedureInfo,
|
|
assertProviderText,
|
|
assertReferenceMetadata,
|
|
dossierSummary,
|
|
matchesSymbol,
|
|
requireProcedure,
|
|
} from "./verify-real-ghidra-assertions.mjs";
|
|
|
|
async function functionCall(client, operation, parameters) {
|
|
const input = parseGhidraFunctionInput(operation, parameters);
|
|
if (!input.ok) throw input.error;
|
|
const called = await client.callTool(operation, input.value);
|
|
if (!called.ok)
|
|
throw new Error(
|
|
`Ghidra ${operation} failed for ${JSON.stringify(input.value)}: ${called.error.message}`,
|
|
{ cause: called.error },
|
|
);
|
|
const parsed = parseGhidraFunctionResult(operation, called.value);
|
|
if (!parsed.ok) throw parsed.error;
|
|
return parsed.value;
|
|
}
|
|
|
|
export async function verifyDebugFunctionOperations(
|
|
client,
|
|
{ procedures, strings, entry },
|
|
) {
|
|
const leaf = requireProcedure(procedures, "rea_ghidra_inventory_leaf");
|
|
const branch = requireProcedure(procedures, "rea_ghidra_inventory_branch");
|
|
const indirect = requireProcedure(
|
|
procedures,
|
|
"rea_ghidra_inventory_indirect",
|
|
);
|
|
const main = requireProcedure(procedures, "main");
|
|
const entryString = strings.find(
|
|
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
|
|
);
|
|
if (entryString === undefined)
|
|
throw new Error("Ghidra debug string function probe is unavailable");
|
|
|
|
const [
|
|
info,
|
|
assembly,
|
|
instructionWindow,
|
|
pseudocode,
|
|
callees,
|
|
callers,
|
|
outgoing,
|
|
entryXrefs,
|
|
] = await collectEntryFunctionData(client, entry);
|
|
assertLocalProcedureInfo(info, entry.address);
|
|
assertProviderText(assembly, pseudocode, entry.address);
|
|
if (
|
|
instructionWindow.procedure?.address !== entry.address ||
|
|
instructionWindow.instructions?.items?.length !== 1 ||
|
|
instructionWindow.instructions.returned !== 1 ||
|
|
instructionWindow.instructions_scanned < 1
|
|
)
|
|
throw new Error(
|
|
"Ghidra read_function_instructions failed its bounded fast-path contract",
|
|
);
|
|
assertEntryCallGraph({ branch, indirect, main, callees, callers });
|
|
const { xrefOwners, xrefOwnerFailures } = await resolveXrefOwners(
|
|
client,
|
|
entryXrefs,
|
|
);
|
|
assertEntryXrefAttribution({
|
|
entryXrefs,
|
|
callers,
|
|
xrefOwners,
|
|
xrefOwnerFailures,
|
|
main,
|
|
});
|
|
assertReferenceMetadata(outgoing);
|
|
await assertStringXrefs(client, entryString.address);
|
|
|
|
const entryDossier = await analyzeEntryDossier(
|
|
client,
|
|
entry,
|
|
branch,
|
|
indirect,
|
|
);
|
|
const branchDossier = await analyzeBranchDossier(client, branch);
|
|
const indirectDossier = await analyzeIndirectDossier(client, indirect);
|
|
assertIndirectDossier(indirectDossier, leaf.address);
|
|
|
|
const { cancellation, timeout } = await verifyRequestCancellationAndTimeout(
|
|
client,
|
|
entry,
|
|
);
|
|
const concurrent = await verifyConcurrentRequests(client, entry, leaf);
|
|
|
|
return {
|
|
entry: dossierSummary(entryDossier),
|
|
branch: dossierSummary(branchDossier),
|
|
indirect: dossierSummary(indirectDossier),
|
|
instruction_window: {
|
|
returned: instructionWindow.instructions.returned,
|
|
truncated: instructionWindow.instructions.truncated,
|
|
},
|
|
cancellation,
|
|
timeout,
|
|
concurrency: concurrent,
|
|
};
|
|
}
|
|
|
|
async function collectEntryFunctionData(client, entry) {
|
|
return Promise.all([
|
|
functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_assembly", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "read_function_instructions", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
offset: 0,
|
|
limit: 1,
|
|
}),
|
|
functionCall(client, "procedure_pseudo_code", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_callees", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_callers", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_references", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
direction: "outgoing",
|
|
}),
|
|
functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: entry.address,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function assertEntryCallGraph({ branch, indirect, main, callees, callers }) {
|
|
for (const expected of [branch.address, indirect.address])
|
|
if (!callees.includes(expected))
|
|
throw new Error(`Ghidra call graph missed ${expected}`);
|
|
if (!callers.includes(main.address))
|
|
throw new Error("Ghidra caller or xref relation missed main");
|
|
}
|
|
|
|
async function resolveXrefOwners(client, entryXrefs) {
|
|
const xrefOwners = [];
|
|
const xrefOwnerFailures = [];
|
|
for (const address of entryXrefs) {
|
|
try {
|
|
xrefOwners.push(
|
|
await functionCall(client, "resolve_containing_procedure", {
|
|
document: null,
|
|
address,
|
|
}),
|
|
);
|
|
} catch (cause) {
|
|
xrefOwnerFailures.push({
|
|
address,
|
|
message: cause instanceof Error ? cause.message : String(cause),
|
|
});
|
|
}
|
|
}
|
|
return { xrefOwners, xrefOwnerFailures };
|
|
}
|
|
|
|
function assertEntryXrefAttribution({
|
|
entryXrefs,
|
|
callers,
|
|
xrefOwners,
|
|
xrefOwnerFailures,
|
|
main,
|
|
}) {
|
|
if (
|
|
!xrefOwners.some(
|
|
(owner) => owner.found && owner.procedure.address === main.address,
|
|
)
|
|
)
|
|
throw new Error(
|
|
`Ghidra entry xrefs were not attributable to main: ${JSON.stringify({ entryXrefs, callers, xrefOwners, xrefOwnerFailures })}`,
|
|
);
|
|
}
|
|
|
|
async function assertStringXrefs(client, entryStringAddress) {
|
|
const stringXrefs = await functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: entryStringAddress,
|
|
});
|
|
if (stringXrefs.length === 0)
|
|
throw new Error("Ghidra xrefs missed the entry string");
|
|
}
|
|
|
|
async function analyzeEntryDossier(client, entry, branch, indirect) {
|
|
const entryDossier = await functionCall(client, "analyze_function", {
|
|
procedure: entry.address,
|
|
include_assembly: true,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(entryDossier, {
|
|
address: entry.address,
|
|
expectedCallees: [branch.address, indirect.address],
|
|
expectedString: "REA_GHIDRA_INVENTORY_ENTRY",
|
|
requireAssembly: true,
|
|
});
|
|
return entryDossier;
|
|
}
|
|
|
|
async function analyzeBranchDossier(client, branch) {
|
|
const branchDossier = await functionCall(client, "analyze_function", {
|
|
procedure: branch.address,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(branchDossier, {
|
|
address: branch.address,
|
|
requireMultiBlock: true,
|
|
});
|
|
return branchDossier;
|
|
}
|
|
|
|
async function analyzeIndirectDossier(client, indirect) {
|
|
const indirectDossier = await functionCall(client, "analyze_function", {
|
|
procedure: indirect.address,
|
|
include_assembly: true,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(indirectDossier, {
|
|
address: indirect.address,
|
|
requireAssembly: true,
|
|
});
|
|
return indirectDossier;
|
|
}
|
|
|
|
function assertIndirectDossier(indirectDossier, leafAddress) {
|
|
if (
|
|
indirectDossier.callees.items.some(({ address }) => address === leafAddress)
|
|
)
|
|
throw new Error(
|
|
"Ghidra dossier falsely resolved a targetless callback as the fixture leaf",
|
|
);
|
|
if (!/\bcall\b/iu.test(indirectDossier.assembly.items.join("\n")))
|
|
throw new Error("Ghidra indirect-call fixture lost its computed call site");
|
|
}
|
|
|
|
async function verifyRequestCancellationAndTimeout(client, entry) {
|
|
const aborted = new AbortController();
|
|
aborted.abort();
|
|
const cancelled = await client.callTool(
|
|
"procedure_info",
|
|
{ document: null, procedure: entry.address },
|
|
{ signal: aborted.signal },
|
|
);
|
|
if (cancelled.ok || cancelled.error.kind !== "cancelled")
|
|
throw new Error("Ghidra established-request cancellation drifted");
|
|
|
|
const timedOut = await client.callTool(
|
|
"procedure_info",
|
|
{ document: null, procedure: entry.address },
|
|
{ timeoutMs: 0 },
|
|
);
|
|
if (timedOut.ok || timedOut.error.kind !== "timeout")
|
|
throw new Error("Ghidra established-request deadline drifted");
|
|
|
|
return {
|
|
cancellation: "cancelled-before-wire",
|
|
timeout: "expired-before-wire",
|
|
};
|
|
}
|
|
|
|
async function verifyConcurrentRequests(client, entry, leaf) {
|
|
const concurrent = await Promise.all([
|
|
functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: entry.address,
|
|
}),
|
|
functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: leaf.address,
|
|
}),
|
|
]);
|
|
if (concurrent.length !== 2)
|
|
throw new Error("Ghidra serial request queue lost a concurrent result");
|
|
return "serialized-two-results";
|
|
}
|
|
|
|
export async function verifyStrippedFunctionOperations(client, entryString) {
|
|
const stringXrefs = await functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: entryString.address,
|
|
});
|
|
if (stringXrefs.length === 0)
|
|
throw new Error("Ghidra stripped string xref probe is unavailable");
|
|
const containing = await functionCall(
|
|
client,
|
|
"resolve_containing_procedure",
|
|
{
|
|
document: null,
|
|
address: stringXrefs[0],
|
|
},
|
|
);
|
|
if (!containing.found)
|
|
throw new Error("Ghidra could not recover a stripped containing function");
|
|
const procedure = containing.procedure;
|
|
const pseudocode = await functionCall(client, "procedure_pseudo_code", {
|
|
document: null,
|
|
procedure: procedure.address,
|
|
});
|
|
if (typeof pseudocode !== "string" || pseudocode.trim().length === 0)
|
|
throw new Error("Ghidra did not decompile the stripped function");
|
|
const dossier = await functionCall(client, "analyze_function", {
|
|
procedure: procedure.address,
|
|
include_assembly: true,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(dossier, {
|
|
address: procedure.address,
|
|
expectedString: "REA_GHIDRA_INVENTORY_ENTRY",
|
|
requireAssembly: true,
|
|
});
|
|
if (symbolTail(dossier.procedure.name) === "rea_ghidra_inventory_entry")
|
|
throw new Error("Ghidra stripped dossier invented a source symbol");
|
|
return dossierSummary(dossier);
|
|
}
|
|
|
|
export async function verifyCrossFormatOperations({
|
|
client,
|
|
variant,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
}) {
|
|
const message = strings.find(
|
|
(item) => item.value === "REA_GHIDRA_CROSS_FORMAT",
|
|
);
|
|
if (message === undefined)
|
|
throw new Error(`${variant} string inventory missed the source oracle`);
|
|
const entry =
|
|
procedures.find((item) => matchesSymbol(item.value, "rea_cross_entry")) ??
|
|
names.find((item) => matchesSymbol(item.value, "rea_cross_entry"));
|
|
if (entry === undefined)
|
|
throw new Error(`${variant} did not retain the exported entry symbol`);
|
|
const entryAddress = entry.address;
|
|
const info = await functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
});
|
|
assertLocalProcedureInfo(info, entryAddress);
|
|
const [pseudocode, assembly, callees, messageXrefs] = await Promise.all([
|
|
functionCall(client, "procedure_pseudo_code", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
}),
|
|
functionCall(client, "procedure_assembly", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
}),
|
|
functionCall(client, "procedure_callees", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
}),
|
|
functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: message.address,
|
|
}),
|
|
]);
|
|
assertProviderText(assembly, pseudocode, entryAddress);
|
|
if (callees.length < 2)
|
|
throw new Error(`${variant} call graph missed direct fixture calls`);
|
|
if (messageXrefs.length === 0)
|
|
throw new Error(`${variant} xrefs missed the fixture string`);
|
|
|
|
const messageOwner = await findCrossStringOwner(client, messageXrefs);
|
|
const stringDossier = await functionCall(client, "analyze_function", {
|
|
procedure: messageOwner.procedure.address,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(stringDossier, {
|
|
address: messageOwner.procedure.address,
|
|
expectedString: "REA_GHIDRA_CROSS_FORMAT",
|
|
});
|
|
|
|
const branchAddress = await findCrossBranchAddress(client, variant, callees);
|
|
const branchDossier = await functionCall(client, "analyze_function", {
|
|
procedure: branchAddress,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(branchDossier, {
|
|
address: branchAddress,
|
|
requireMultiBlock: true,
|
|
});
|
|
return {
|
|
entry: entryAddress,
|
|
exported_symbol: entry.value,
|
|
string_owner: dossierSummary(stringDossier),
|
|
branch: dossierSummary(branchDossier),
|
|
};
|
|
}
|
|
|
|
async function findCrossStringOwner(client, messageXrefs) {
|
|
for (const address of messageXrefs) {
|
|
const candidate = await functionCall(
|
|
client,
|
|
"resolve_containing_procedure",
|
|
{
|
|
document: null,
|
|
address,
|
|
},
|
|
);
|
|
if (candidate.found) return candidate;
|
|
}
|
|
throw new Error("Cross-format did not resolve the string-owning function");
|
|
}
|
|
|
|
async function findCrossBranchAddress(client, variant, callees) {
|
|
for (const address of callees) {
|
|
const candidate = await functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: address,
|
|
});
|
|
if (candidate.basicblock_count > 1) return address;
|
|
}
|
|
throw new Error(`${variant} did not recover the multi-block callee`);
|
|
}
|