diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index b64955a2e..ea1b8d2d6 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -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 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; } diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 902831169..4ebbe86ed 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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. diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index 47e90e60e..0f0b09b6b 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -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, diff --git a/scripts/lib/real-ghidra-instruction-flow.mjs b/scripts/lib/real-ghidra-instruction-flow.mjs new file mode 100644 index 000000000..25bad8073 --- /dev/null +++ b/scripts/lib/real-ghidra-instruction-flow.mjs @@ -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"), + ), + ); +} diff --git a/scripts/verify-real-ghidra-function.mjs b/scripts/verify-real-ghidra-function.mjs index c8c7cf0c6..8200f3f2e 100644 --- a/scripts/verify-real-ghidra-function.mjs +++ b/scripts/verify-real-ghidra-function.mjs @@ -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( diff --git a/src/domain/native/nativeInstruction.ts b/src/domain/native/nativeInstruction.ts index b9509f036..744707ed2 100644 --- a/src/domain/native/nativeInstruction.ts +++ b/src/domain/native/nativeInstruction.ts @@ -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()), diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 57fc2f1f2..31c923be9 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -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" diff --git a/tests/conformance/ghidra/inventory.c b/tests/conformance/ghidra/inventory.c index 3655de0bb..8b57aa1ce 100644 --- a/tests/conformance/ghidra/inventory.c +++ b/tests/conformance/ghidra/inventory.c @@ -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))