fix(ghidra): preserve decoded return flow evidence

This commit is contained in:
N0zoM1z0 2026-10-09 18:53:28 +08:00
parent c5c75c38c3
commit bcc8a773e2
8 changed files with 174 additions and 4 deletions

View File

@ -866,6 +866,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonArray operands = new JsonArray();
JsonArray references = new JsonArray();
JsonArray destinations = new JsonArray();
JsonArray flowEvidence = new JsonArray();
JsonObject flow = new JsonObject();
flow.addProperty("kind", "unavailable");
flow.addProperty("conditional", false);
@ -881,7 +882,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("mnemonic", instruction.getMnemonicString());
result.addProperty("raw_disassembly", instruction.toString());
RefType type = instruction.getFlowType();
flow.addProperty("kind", type.isCall() ? "call" : type.isJump() ? "jump" : type.isTerminal() ? "terminal" : "fallthrough");
JsonObject listingFlow = new JsonObject();
listingFlow.addProperty("source", "ghidra-listing-flow-type");
listingFlow.addProperty("value", type.getName());
flowEvidence.add(listingFlow);
boolean decodedReturn = false;
for (PcodeOp operation : instruction.getPcode()) {
if (operation.getOpcode() != PcodeOp.RETURN) continue;
decodedReturn = true;
JsonObject returnEvidence = new JsonObject();
returnEvidence.addProperty("source", "ghidra-instruction-pcode");
returnEvidence.addProperty("value", operation.getMnemonic());
flowEvidence.add(returnEvidence);
}
flow.addProperty("kind", type.isCall() ? "call" : type.isJump() ? "jump" : decodedReturn ? "return" : type.isTerminal() ? "terminal" : "fallthrough");
flow.addProperty("conditional", type.isConditional());
flow.addProperty("computed", type.isComputed());
if (!type.isComputed()) for (Address target : instruction.getFlows()) destinations.add(canonicalAddress(target));
@ -917,10 +931,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
}
flow.add("direct_destinations", destinations);
flow.add("classification_evidence", flowEvidence);
result.add("operands", operands);
result.add("flow", flow);
result.add("references", references);
result.add("limitations", GSON.toJsonTree(List.of("Operand components are Ghidra Listing decoder tokens; effective memory base/index/displacement roles and per-instruction context mode are unavailable. The mode field is the program language variant.", "Terminal flow is not classified as a return without provider evidence.")));
List<String> limitations = new ArrayList<>(List.of("Operand components are Ghidra Listing decoder tokens; effective memory base/index/displacement roles and per-instruction context mode are unavailable. The mode field is the program language variant."));
if (flow.get("kind").getAsString().equals("terminal")) limitations.add("No RETURN operation was observed in the provider's instruction p-code; terminal classification remains generic.");
result.add("limitations", GSON.toJsonTree(limitations));
return result;
}

View File

@ -185,6 +185,19 @@ 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.
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.
Other terminal or trap flows retain the provider's classification, and this
inspection does not change function extents or prove a calling convention.
The analysis profile commits this decoder behavior. Snapshots from the earlier
profile produce a profile mismatch rather than replaying the former generic
terminal result. The old snapshot's `evidence_bundle` remains valid retained
Evidence: copy that object to a separate JSON file and use `rea evidence-import`
to validate it. Run analysis without the old snapshot and save a fresh one
under the current profile. Other providers and older captures may omit the
classification evidence field.
Call resolution reports direct, resolved indirect, ambiguous, unresolved and
non-call outcomes from static call references. It does not establish runtime
execution or classify Objective-C/Swift/vtable/closure mechanisms from names.

View File

@ -15,6 +15,7 @@ import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs"
import { verifyGhidraLargeResults } from "./real-ghidra-large-results.mjs";
import { verifyGhidraEntryAliases } from "./real-ghidra-entry-aliases.mjs";
import { verifyGhidraNamespaceAnnotations } from "./real-ghidra-namespace-annotations.mjs";
import { verifyGhidraInstructionFlow } from "./real-ghidra-instruction-flow.mjs";
/** Probe real Ghidra location, annotation and error contracts through public adapters. */
export async function verifyGhidraBoundaries(
@ -157,6 +158,7 @@ export async function verifyGhidraBoundaries(
/Annotations require a local function entry/u,
);
const names = await call("list_names");
const instructionFlow = await verifyGhidraInstructionFlow(call, names, cli);
const leaf = names.find((item) =>
item.value.endsWith("rea_ghidra_inventory_leaf"),
);
@ -766,6 +768,7 @@ export async function verifyGhidraBoundaries(
missing_and_nonregular_source_rejected: true,
imported_source_identity_retained: true,
equivalent_instruction_address_spellings: true,
instruction_flow: instructionFlow,
qualified_annotation_name_roundtrip: true,
imported_entry_alias_selection: true,
oversized_result_retention_and_complete_export: true,

View File

@ -0,0 +1,110 @@
import assert from "node:assert/strict";
/** Verify decoded return evidence and distinct non-return flows on owned fixtures. */
export async function verifyGhidraInstructionFlow(call, names, cli) {
const leaf = names.find((item) =>
item.value.endsWith("rea_ghidra_inventory_leaf"),
);
assert.ok(leaf, "Source fixture lacks its returning leaf");
const leafInstructions = await inspectFunction(call, leaf.address);
const returned = leafInstructions.find(
(instruction) => instruction.flow.kind === "return",
);
assert.ok(returned, "Returning source leaf lacks decoded RETURN evidence");
assertReturnEvidence(returned);
assert.equal(
(await call("resolve_native_call_targets", { address: returned.address }))
.status,
"not-call",
);
assert.deepEqual(
await cli("inspect-native-instruction", returned.address),
returned,
);
const controls = [
["rea_ghidra_flow_return", "RET", "c3"],
["rea_ghidra_flow_cleanup_return", "RET", "c21000"],
["rea_ghidra_flow_shared_tail", "JMP", null],
["rea_ghidra_flow_trap", "UD2", "0f0b"],
];
let checked = 0;
for (const [symbol, mnemonic, bytes] of controls) {
const procedure = names.find((item) => item.value === symbol);
if (procedure === undefined) continue;
const before = await call("resolve_containing_procedure", {
address: procedure.address,
});
const instructions = await inspectFunction(call, procedure.address);
const instruction = instructions.find((item) => item.mnemonic === mnemonic);
assert.ok(instruction, `Source fixture lacks ${symbol}'s ${mnemonic}`);
if (bytes !== null) assert.equal(instruction.bytes, bytes);
if (mnemonic === "RET") {
assertReturnEvidence(instruction);
assert.equal(instruction.flow.kind, "return");
if (bytes === "c21000")
assert.equal(instruction.operands[0]?.raw, "0x10");
} else {
assert.notEqual(instruction.flow.kind, "return");
assert.ok(
instruction.flow.classification_evidence.some(
(item) => item.source === "ghidra-listing-flow-type",
),
);
}
assert.deepEqual(
await call("resolve_containing_procedure", {
address: procedure.address,
}),
before,
);
checked++;
}
const unavailable = await call("inspect_native_instruction", {
address: "0x0",
});
assert.equal(unavailable.status, "outside-memory");
assert.equal(unavailable.flow.kind, "unavailable");
assert.deepEqual(unavailable.flow.classification_evidence, []);
return {
return_instruction: returned.address,
decoded_pcode_return: true,
return_cli_mcp_parity: true,
explicit_x86_elf_controls: checked,
explicit_control_scope:
checked === 0
? "Not present on this host-native fixture format/architecture"
: "x86-64 ELF",
generic_unknown_preserved: true,
};
}
async function inspectFunction(call, procedure) {
const listing = await call("read_function_instructions", { procedure });
const instructions = [];
for (const line of listing.instructions) {
const delimiter = line.indexOf(": ");
assert.ok(delimiter > 0, `Malformed provider instruction listing: ${line}`);
instructions.push(
await call("inspect_native_instruction", {
address: line.slice(0, delimiter),
}),
);
}
return instructions;
}
function assertReturnEvidence(instruction) {
assert.equal(instruction.status, "decoded");
assert.ok(
instruction.flow.classification_evidence.some(
(item) =>
item.source === "ghidra-instruction-pcode" && item.value === "RETURN",
),
);
assert.ok(
!instruction.limitations.some((text) =>
text.includes("not classified as a return"),
),
);
}

View File

@ -92,8 +92,7 @@ export async function verifyDebugFunctionOperations(
if (
instruction.status !== "decoded" ||
instruction.length < 1 ||
instruction.bytes.length !== instruction.length * 2 ||
instruction.operands.length === 0
instruction.bytes.length !== instruction.length * 2
)
throw new Error("Structured native instruction facts drifted");
const directSite = outgoing.references.find(

View File

@ -59,6 +59,17 @@ export const nativeInstructionSchema = z.strictObject({
conditional: z.boolean(),
computed: z.boolean(),
direct_destinations: z.array(z.string()),
classification_evidence: z
.array(
z.strictObject({
source: z.string().min(1),
value: z.string().min(1),
}),
)
.optional()
.describe(
"Observed provider facts used to classify flow; older captures or other providers may omit them.",
),
}),
references: z.array(reference),
limitations: z.array(z.string()),

View File

@ -44,6 +44,7 @@ export const resolveGhidraAnalysisProfile = (
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
instruction_flow_evidence: "decoded-return-pcode-v1",
process_launch:
installation.platform === "win32"
? "official-headless-script-v1"

View File

@ -2,6 +2,22 @@
volatile int rea_ghidra_inventory_global = 7;
#if defined(__x86_64__) && defined(__ELF__)
// Static decoder controls: these functions are retained but never executed.
__attribute__((naked, noinline, used)) void rea_ghidra_flow_return(void) {
__asm__("ret");
}
__attribute__((naked, noinline, used)) void rea_ghidra_flow_cleanup_return(void) {
__asm__("ret $16");
}
__attribute__((naked, noinline, used)) void rea_ghidra_flow_shared_tail(void) {
__asm__("jmp rea_ghidra_flow_return");
}
__attribute__((naked, noinline, used)) void rea_ghidra_flow_trap(void) {
__asm__("ud2");
}
#endif
// This literal reproduces stack exhaustion in Java's recursive regex matcher.
#define REA_REPEAT_4(value) value value value value
#define REA_REPEAT_16(value) REA_REPEAT_4(REA_REPEAT_4(value))