Compare commits

...
Author SHA1 Message Date
Kaoru0822 b7e0b28083 Merge remote-tracking branch 'origin/main' into fix/8277-call-path-computed-target-uncertainty 2026-10-31 15:58:04 +00:00
Kaoru0822andClaude Opus 5.5 2a312e999f fix(call-path): keep computed-call targets out of complete callee coverage
Ghidra reported complete callee coverage when a computed call had one
ReferenceManager target, so trace_call_path, build_call_path and the
function comparisons turned the static candidate into a definite negative.
The bridge now keeps those targets as observed callees and marks callee
coverage partial, naming the computed call sites. IDA callee reads cannot
classify their call sites, so their callee coverage is partial too. The
Ghidra call-coverage profile revision refuses older snapshots.

A source-owned x86-64 bridge probe in the ghidra-bridge-sources job checks
the slot, register and direct-call controls against real Ghidra.

Fixes #8277

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:57:52 +00:00
20 changed files with 329 additions and 47 deletions

No files matched your search

@@ -95,3 +95,31 @@ jobs:
decode cortex ARM:LE:32:Cortex mrs/4/msp bx/2/lr
decode v8m ARM:LE:32:v8-m mrs/4/msplim bx/2/lr
decode xtensa Xtensa:LE:32:default entry/3/a1 retw.n/2
# A call through a writable slot keeps Ghidra's recorded target as an
# observed callee while callee coverage stays partial; a direct call is
# the complete control. The source-owned x86-64 bytes are never executed.
- name: Keep computed-call targets non-exhaustive in callee coverage
shell: bash
run: |
java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64"
export JAVA_HOME="${!java_home_variable}"
probe_root="${RUNNER_TEMP}/rea-computed-call"
mkdir -p "${probe_root}/project"
{
printf '\270\001\000\000\000\303\314\314' # 0x1000: mov eax, 1; ret
printf '\270\002\000\000\000\303\314\314' # 0x1008: mov eax, 2; ret
printf '\377\025\052\000\000\000\303\314' # 0x1010: call [rip + 0x2a]; ret
printf '\377\320\303\314\314\314\314\314' # 0x1018: call rax; ret
printf '\350\333\377\377\377\303\314\314' # 0x1020: call 0x1000; ret
printf '\314%.0s' $(seq 24) # 0x1028: int3 padding
printf '\000\020\000\000\000\000\000\000' # 0x1040: slot = 0x1000
} > "${probe_root}/computed-call.bin"
test "$(wc -c < "${probe_root}/computed-call.bin")" -eq 72
timeout 180s "${GHIDRA_INSTALL_DIR}/support/analyzeHeadless" "${probe_root}/project" ComputedCall \
-import "${probe_root}/computed-call.bin" -loader BinaryLoader -loader-baseAddr 0x1000 \
-processor x86:LE:64:default -noanalysis -readOnly -deleteProject \
-scriptPath "${GITHUB_WORKSPACE}/tests/conformance/ghidra;${GITHUB_WORKSPACE}/bridge/ghidra" \
-postScript ReaComputedCallCoverageProbe.java "${GITHUB_WORKSPACE}/bridge/ghidra" \
> "${probe_root}/output.log" 2>&1 || { cat "${probe_root}/output.log"; exit 1; }
cat "${probe_root}/output.log"
grep -F 'REA_COMPUTED_CALL_COVERAGE_OK slot=partial register=partial direct=complete' "${probe_root}/output.log"
+31 -4
View File
@@ -3608,11 +3608,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
JsonArray limitations = new JsonArray();
if (callerLimitation != null) limitations.add(callerLimitation);
String computedLimitation = calls == null ? null
: computedCallTargetLimitation(calls.computedCallSites());
if ((omitted != null && omitted > 0) || computedLimitation != null) directions.add("callees");
if (omitted != null && omitted > 0) {
limitations.add("Ghidra's callee inventory omits targets of " + omitted +
" call reference(s) or decoded call(s) without destinations.");
directions.add("callees");
}
if (computedLimitation != null) limitations.add(computedLimitation);
if (!limitations.isEmpty()) {
coverage.addProperty("status", "partial");
coverage.add("limitations", limitations);
@@ -3632,6 +3635,24 @@ public final class ReaGhidraBridge extends HeadlessScript {
return coverage;
}
/** Name a few sites inline; reference reads and call-target queries recover the rest. */
private static final int NAMED_COMPUTED_CALL_SITES = 4;
/**
* A computed call's ReferenceManager targets are static candidates: Ghidra
* records no proof that other values cannot reach the call, so the callee
* set stays incomplete while the recorded candidates remain observed.
*/
private static String computedCallTargetLimitation(List<String> sites) {
if (sites.isEmpty()) return null;
int named = Math.min(sites.size(), NAMED_COMPUTED_CALL_SITES);
String listed = String.join(", ", sites.subList(0, named)) +
(sites.size() > named ? " and " + (sites.size() - named) + " more" : "");
return "Ghidra records call reference targets at " + sites.size() +
" computed call site(s) (" + listed + "); these static references do not establish" +
" every possible target, so additional callees remain unknown.";
}
private boolean unrepresentedCaller(Reference reference, Set<Function> callers) {
if (!reference.getReferenceType().isCall()) return false;
Function caller = functionAtOrContaining(reference.getFromAddress());
@@ -3666,9 +3687,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
monitor.checkCancelled();
calls.observe(reference);
}
List<String> computedCallSites = new ArrayList<>();
for (Instruction instruction : instructions) {
boundary.observe(instruction);
if (!instruction.getFlowType().isCall()) continue;
FlowType flow = instruction.getFlowType();
if (!flow.isCall()) continue;
boolean destination = false;
for (Reference reference : instruction.getReferencesFrom()) {
if (!reference.getReferenceType().isCall()) continue;
@@ -3680,9 +3703,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
calls.unresolved(instruction.getAddress(),
"Instruction decoder identifies a call but ReferenceManager supplies no call destination");
}
// The same decoded flow classifies resolve_native_call_targets'
// mechanism; its recorded targets are candidates, not a closed set.
else if (flow.isComputed()) computedCallSites.add(canonicalAddress(instruction.getAddress()));
}
return new CallBoundaryObservation(boundary.values(),
returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls());
returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls(),
List.copyOf(computedCallSites));
}
private final class FunctionBoundaryLimitations {
@@ -5123,7 +5150,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private record AddressNameFailure(String reason, AddressNameConflict conflict) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {}
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls, List<String> computedCallSites) {}
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
private record SwiftLiteralLayout(
String representation,
+1 -1
View File
@@ -240,7 +240,7 @@ profile stages before retrying their retained descriptors.
| Function identity | Every function result carries the entry address and Ghidra FunctionManager classification for external, thunk, and resolved thunk target. These are observations, not proof that unresolved targetless calls have been recovered. |
| Assembly and pseudocode | Assembly is complete Ghidra Listing text; pseudocode is Ghidra decompiler output. Neither is original source, and cross-provider comparison never treats Hopper and Ghidra text as equal or unequal semantic facts. |
| Instruction fast path | `read_function_instructions` returns every raw Listing instruction for the requested function without invoking the decompiler or whole-program name/string inventories. |
| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. Synthetic entry-point references without actionable memory sources are omitted explicitly. |
| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. A computed call's recorded targets stay observed callees, but they are static candidates rather than an exhaustive set, so callee coverage is `partial` and names those call sites. Synthetic entry-point references without actionable memory sources are omitted explicitly. |
| CFG | Dossiers use `BasicBlockModel` and retain only non-call successors inside the function body. CFG topology is address-normalized for comparison; provider-specific block construction remains a declared difference. |
| P-code value flow | Function dossiers include up to 3,000 high-p-code operations, at most 64 inputs per operation, and 12,000 def-use edges. Results identify memory reads/writes, branches, and calls by p-code opcode, and mark decompiler-dead operations. Large results report truncation, an omitted-operation lower bound, and known omitted-input/edge counts. The dossier is an intra-function graph; `trace_native_values` composes derived parameter/argument and return/output dependencies across resolved calls. It does not resolve memory aliasing, call side effects, runtime behavior, persistent state, or business meaning. |
| Result extent | Function instruction scans and native API boundary observations remain complete. P-code flow is the explicit bounded exception; its count fields distinguish exact retained counts from lower-bound or known-omitted counts. Caller cancellation and provider failures remain distinct. |
+1 -1
View File
@@ -226,7 +226,7 @@ admits it explicitly. Raw-binary and GameCube DOL targets are refused; open
them with Ghidra. Admission is not verification: the real workflows under
[Verification](#verification) cover x86_64 and arm64 only.
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Neither `callees` nor `callgraph` reports the call sites behind its edges, so a computed call's targets are never established as exhaustive; callee coverage is therefore `partial` even for an uncapped reply, and a call path that does not reach its goal remains `unknown`. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
All profiles expose `list_documents` as the single target admitted for the session. Attached mode verifies its digest against IDA metadata before returning it; headless mode binds the digest-checked private input. `inspect_address_context`, `trace_feature`, and `binary_overview` can therefore resolve that identity without upstream document-listing tools. Segment enumeration is optional: when the selected profile cannot provide it, `binary_overview` still returns document and procedure/string counts and omits `segments` and `segment_count`; omission means unobserved, not empty. The modern headless profile also supports `resolve_containing_procedure`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. Attached profiles do not declare address containment resolution.
+4 -1
View File
@@ -201,7 +201,10 @@ coverage is not proof that the function has no calls.
sets `truncated: true`; any path already supported by observed edges remains
visible. When a requested goal is not reached but an expansion is unresolved,
`goal_status` is `unknown`; `not_reached` means traversal exhausted without
unresolved relationship reads. Cancellation remains a tool error.
unresolved relationship reads. A computed call whose recorded targets are not
established as exhaustive limits its expansion: the observed edge stays, and
the residual unknown names the provider's limit. Cancellation remains a tool
error.
`trace_feature` validates each reference's containing-procedure resolution using
the same contract as navigation context. Explicit absence remains a successful
+4
View File
@@ -126,6 +126,10 @@ before proposing another issue, and avoid treating lack of API parity as a bug.
returned without a known read limit; it does not claim every possible runtime
edge. Provider and execution caveats stay in `limitations` and do not imply
incomplete relationship coverage. Missing coverage remains `unknown`.
A call site whose target set the provider has not established limits that
direction: a targetless call, a computed call whose recorded targets are
static candidates, or a read that cannot classify its call sites. Keep the
observed edges and name the limit, so a negative path stays `unknown`.
- Declare process, filesystem, network, UI, and mutation effects truthfully;
enforce the requested target and lifecycle.
- Add limits only for real format, protocol, authority, or measured resource
+1
View File
@@ -609,6 +609,7 @@ const PATH_SCOPES = {
"src/ghidra/GhidraReleasePolicy.ts",
"scripts/verify-ghidra-bridge-sources.mjs",
"tests/conformance/ghidra/ReaArmContextProbe.java",
"tests/conformance/ghidra/ReaComputedCallCoverageProbe.java",
"tests/conformance/ghidra/ReaDecodeProbe.java",
".github/workflows/ghidra-bridge-sources.yml",
],
+1 -1
View File
@@ -58,7 +58,7 @@ const sharedAnalysisSemantics = {
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: "function-body-call-coverage-v5",
call_relationship_coverage: "function-body-call-coverage-v6",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
+1 -1
View File
@@ -1145,7 +1145,7 @@ describe("Ghidra client projection", () => {
expect(resolved.value.profile.parameters).toMatchObject({
language_id: "auto-from-header",
compiler_spec_id: "auto-default",
call_relationship_coverage: "function-body-call-coverage-v5",
call_relationship_coverage: "function-body-call-coverage-v6",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
});
+4 -3
View File
@@ -7,11 +7,12 @@ import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixt
import { GhidraProvider } from "./GhidraProvider.js";
// Digests commit to the current shared policy, including the instruction status
// revision that prevents reuse of snapshots with the previous ambiguous result.
// revision that prevents reuse of snapshots with the previous ambiguous result
// and the call coverage revision that limits computed-call callee sets.
const RAW_DIGESTS = {
"6502": "8e33776d1cc5c5d77d32923d5978c05ec2d989d00920dd458cc2dafb4c9c0cad",
"6502": "032d5d6765a10d80683b2d30366af75858e9f08fca8464e7186201e47cbb7494",
"x86-disabled-analyzer":
"d17bddc8ac110c891d30fb989fc7906df421bd9bea5df4aac05a950f50c7bdaf",
"5da58aca4c862e0930f294870494f4e34081c00bd148dec56c42299d77682de0",
} as const;
const configFor = (environment: NodeJS.ProcessEnv) => {
+9 -4
View File
@@ -329,24 +329,29 @@ describe("Ghidra snapshot profile admission", () => {
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
current: "function-body-call-coverage-v6",
legacy: "resolved-call-reference-diagnostics-v1",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
current: "function-body-call-coverage-v6",
legacy: "decoded-call-target-coverage-v2",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
current: "function-body-call-coverage-v6",
legacy: "decoded-call-target-coverage-v3",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
current: "function-body-call-coverage-v6",
legacy: "function-body-call-coverage-v4",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v6",
legacy: "function-body-call-coverage-v5",
},
])(
"refuses snapshots with outdated $parameter semantics",
async ({ parameter, current, legacy: prior }) => {
+17
View File
@@ -8,6 +8,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
import {
isX86OnlyTarget,
readCallGraphCallees,
UNCLASSIFIED_CALL_SITE_LIMITATION,
UNRECOGNIZED_CALLEE_LIMITATION,
} from "./IdaCallGraph.js";
@@ -228,6 +229,13 @@ describe("call graph coverage and failure handling", () => {
).toContain(
"Upstream callgraph was truncated; additional callees remain unknown.",
);
expect(result.coverage).toMatchObject({
status: "partial",
limitations: [
UNCLASSIFIED_CALL_SITE_LIMITATION,
"Upstream callgraph was truncated; additional callees remain unknown.",
],
});
});
it("keeps an explicitly uncapped root below its edge cap untruncated", async () => {
@@ -240,6 +248,15 @@ describe("call graph coverage and failure handling", () => {
result.limitations,
"an uncapped, unsaturated root has no truncation evidence",
).toEqual([UNRECOGNIZED_CALLEE_LIMITATION]);
expect(
result.coverage,
"function edges without call sites cannot establish a computed call's targets",
).toEqual({
status: "partial",
limitations: [UNCLASSIFIED_CALL_SITE_LIMITATION],
limited_directions: ["callees"],
diagnostics: [UNRECOGNIZED_CALLEE_LIMITATION],
});
});
it("reports a graph that saturates its per-function cap with root edges", async () => {
+10 -7
View File
@@ -16,6 +16,12 @@ import {
/** The graph only emits edges whose destinations IDA recognizes as functions. */
export const UNRECOGNIZED_CALLEE_LIMITATION =
"This relationship read does not establish calls to targets IDA does not recognize as functions; imports and other non-function call targets remain unknown.";
/**
* IDA callee reads return function edges without the call sites behind them,
* so a computed call's target set is never established as exhaustive.
*/
export const UNCLASSIFIED_CALL_SITE_LIMITATION =
"IDA's callee read does not identify computed or indirect call sites, so it cannot establish every target of such a call; additional callees may remain unknown.";
const TRUNCATED_LIMITATION =
"Upstream callgraph was truncated; additional callees remain unknown.";
const POSSIBLY_TRUNCATED_LIMITATION =
@@ -103,7 +109,7 @@ export const readCallGraphCallees = async (
);
const limitations = [UNRECOGNIZED_CALLEE_LIMITATION];
const diagnostics = [UNRECOGNIZED_CALLEE_LIMITATION];
const coverageLimitations: string[] = [];
const coverageLimitations = [UNCLASSIFIED_CALL_SITE_LIMITATION];
const truncated =
row.truncated === true ||
row.per_func_capped === true ||
@@ -166,12 +172,9 @@ export const readCallGraphCallees = async (
}),
limitations,
coverage: {
...(coverageLimitations.length === 0
? { status: "complete" as const }
: {
status: "partial" as const,
limitations: coverageLimitations,
}),
status: "partial",
limitations: coverageLimitations,
limited_directions: ["callees"],
diagnostics,
},
};
+5 -1
View File
@@ -19,6 +19,7 @@ import {
type IdaMcpConnection,
} from "./IdaMcpConnection.js";
import { parseConfig } from "../config/parseConfig.js";
import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "./IdaCallGraph.js";
import { IdaProvider } from "./IdaProvider.js";
import { idaCapabilities } from "./IdaProviderCapabilities.js";
import { IdaSessionClient } from "./IdaSessionClient.js";
@@ -384,7 +385,10 @@ describe("native IDA provider observations", () => {
});
if (!direct.ok) throw direct.error;
expect(direct.value.relationshipCoverage).toMatchObject({
status: more ? "partial" : "complete",
status: "partial",
limitations: expect.arrayContaining([
UNCLASSIFIED_CALL_SITE_LIMITATION,
]),
});
const dossier = await client.execute("analyze_function", {
procedure: "main",
+19 -11
View File
@@ -29,7 +29,11 @@ import {
import type { JsonValue } from "../domain/jsonValue.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js";
import { readCallGraphCallees, unavailableCallGraph } from "./IdaCallGraph.js";
import {
readCallGraphCallees,
UNCLASSIFIED_CALL_SITE_LIMITATION,
unavailableCallGraph,
} from "./IdaCallGraph.js";
import type { IdaMcpConnection } from "./IdaMcpConnection.js";
import type { IdaOperation } from "./IdaProviderCapabilities.js";
import {
@@ -552,20 +556,24 @@ export class IdaOperationRunner {
"Upstream callees capped results at 500; additional callees remain unknown.",
);
coverage =
page.more === true
page.more === undefined
? {
status: "unknown",
reason:
"Upstream callees omitted an explicit truncation marker; additional callees remain unknown.",
}
: {
status: "partial",
limitations: [
"Upstream callees capped results at 500; additional callees remain unknown.",
...(page.more
? [
"Upstream callees capped results at 500; additional callees remain unknown.",
]
: []),
UNCLASSIFIED_CALL_SITE_LIMITATION,
],
}
: page.more === false
? { status: "complete" }
: {
status: "unknown",
reason:
"Upstream callees omitted an explicit truncation marker; additional callees remain unknown.",
};
limited_directions: ["callees"],
};
for (const item of page.callees) {
if ("type" in item && item.type === "external")
result.set(
@@ -741,6 +741,10 @@ it.each([
"tests/conformance/ghidra/ReaArmContextProbe.java",
{ ghidra_bridge: true, runtime: false, package: false },
],
[
"tests/conformance/ghidra/ReaComputedCallCoverageProbe.java",
{ ghidra_bridge: true, runtime: false, package: false },
],
[
"tests/conformance/ghidra/ReaDecodeProbe.java",
{ ghidra_bridge: true, runtime: false, package: false },
@@ -1,6 +1,7 @@
import { rm } from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest";
import { z } from "zod";
import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "../../../../src/ida/IdaCallGraph.js";
import { IdaOperationRunner } from "../../../../src/ida/IdaOperationRunner.js";
import { IdaSessionClient } from "../../../../src/ida/IdaSessionClient.js";
import { createIdaTarget, RecordingIdaMcp } from "../../../fixtures/idaMcp.js";
@@ -11,6 +12,12 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const queriesSchema = z.object({ queries: z.array(z.string()) });
/** An uncapped page still cannot classify the call sites behind its edges. */
const uncappedCalleeCoverage = {
status: "partial",
limitations: [UNCLASSIFIED_CALL_SITE_LIMITATION],
limited_directions: ["callees"],
};
const addresses = Array.from(
{ length: 20 },
(_, index) => `0x${(0x2000 + index * 16).toString(16)}`,
@@ -79,7 +86,7 @@ describe.each(["modern", "native"] as const)(
"lookup_funcs",
]);
expect(runner.raw.at(-1)?.result).toEqual(addresses.map(row).reverse());
expect(runner.relationshipCoverage).toEqual({ status: "complete" });
expect(runner.relationshipCoverage).toEqual(uncappedCalleeCoverage);
});
it.each([
@@ -140,7 +147,7 @@ describe.each(["modern", "native"] as const)(
},
);
it("uses the batch through a modern session and preserves complete callee Evidence", async () => {
it("uses the batch through a modern session and preserves the observed callee Evidence", async () => {
const { producer, target, root } = await fixture();
const client = new IdaSessionClient(
{
@@ -160,7 +167,7 @@ it("uses the batch through a modern session and preserves complete callee Eviden
});
if (!result.ok) throw result.error;
expect(result.value.result).toEqual(["0x9000", ...addresses]);
expect(result.value.relationshipCoverage).toEqual({ status: "complete" });
expect(result.value.relationshipCoverage).toEqual(uncappedCalleeCoverage);
expect(
producer.calls
.filter(({ name }) => name === "lookup_funcs")
@@ -1,6 +1,7 @@
import { afterEach, expect, it } from "vitest";
import { z } from "zod";
import { EnhancedTools } from "../../../../src/application/EnhancedTools.js";
import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "../../../../src/ida/IdaCallGraph.js";
import {
callGraphSession,
edge,
@@ -37,8 +38,8 @@ it("keeps unknown caller coverage and callee diagnostics in an ARM64 dossier", a
if (!result.ok) throw result.error;
expect(result.value.relationshipCoverage).toMatchObject({
status: "unknown",
reason:
"Callers: Upstream xrefs do not establish direct caller relationships.",
reason: `Callees: ${UNCLASSIFIED_CALL_SITE_LIMITATION} Callers: Upstream xrefs do not establish direct caller relationships.`,
limited_directions: ["callers", "callees"],
diagnostics: expect.arrayContaining([
expect.stringContaining("targets IDA does not recognize as functions"),
]),
@@ -153,16 +154,26 @@ it("traverses every ARM64 callee in a call path without a goal and records no fa
{ source_address: "0x1000", target_address: "0x3000" },
],
failures: [],
truncated: false,
// Function edges without call sites leave each expansion's computed
// call targets unestablished, so the traversal stays limited.
truncated: true,
limitations: expect.arrayContaining([
"This relationship read does not establish calls to targets IDA does not recognize as functions; imports and other non-function call targets remain unknown.",
]),
residual_unknowns: [],
relationship_coverage: {
"0x1000": { status: "complete" },
"0x2000": { status: "complete" },
"0x3000": { status: "complete" },
},
residual_unknowns: ["0x1000", "0x2000", "0x3000"].map(
(address) =>
`Call relationships for ${address} are limited: ${UNCLASSIFIED_CALL_SITE_LIMITATION}`,
),
relationship_coverage: Object.fromEntries(
["0x1000", "0x2000", "0x3000"].map((address) => [
address,
{
status: "partial",
limitations: [UNCLASSIFIED_CALL_SITE_LIMITATION],
limited_directions: ["callees"],
},
]),
),
});
expect(
requestedRoots(producer),
+9
View File
@@ -19,6 +19,15 @@ binaries and Ghidra projects are never committed.
language's default context and checks each mnemonic, length and optional
complete operand. The `ghidra-bridge-sources` CI job runs it for `ARM:LE:32:Cortex`,
`ARM:LE:32:v8-m` and `Xtensa:LE:32:default`; the bytes are never executed.
- `ReaComputedCallCoverageProbe.java` decodes source-owned x86-64 calls
through a writable slot, a register and a direct target, injects only the
slot call's recorded `COMPUTED_CALL` target, and reads callees through the
production bridge. The recorded target stays an observed callee while callee
coverage is `partial` and names the call site; the targetless call stays
`partial` without that candidate limitation, and the direct call stays
`complete`. The `ghidra-bridge-sources` CI job runs it; the bytes are never
executed. It does not establish which references Ghidra's own analysis
records for other slot shapes.
- `inventory.c` produces separate x86-64 debug and stripped ELF executables.
It covers imports, an external `puts` function, linker thunks, source symbols,
direct calls, a targetless callback call, two referenced strings, and a
@@ -0,0 +1,150 @@
//@category REA Verification
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import ghidra.app.script.GhidraScript;
import ghidra.app.script.GhidraScriptUtil;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.symbol.RefType;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.SourceType;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
/**
* Real x86-64 decoding, functions and ReferenceManager state for calls through
* a writable slot, a register and a direct target. Only the slot call's
* recorded target is injected, as Ghidra's analysis records one for an
* initialized slot. The production bridge must keep that candidate while
* callee coverage stays partial and names the call site; the direct call is
* the complete control. The source-owned bytes are never executed.
*/
public class ReaComputedCallCoverageProbe extends GhidraScript {
private static final String COMPUTED_LIMITATION = "computed call site(s) (";
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
require(args.length == 1, "Expected the exact production bridge directory");
var source = GhidraScriptUtil.findScriptByName("ReaGhidraBridge.java");
require(source != null && new File(source.getAbsolutePath()).getCanonicalFile().equals(
new File(args[0], "ReaGhidraBridge.java").getCanonicalFile()), "Production bridge source");
GhidraScript bridge = GhidraScriptUtil.getProvider(source).getScriptInstance(source, errorWriter);
bridge.set(getState(), getControls());
require(currentProgram.getLanguageID().getIdAsString().equals("x86:LE:64:default"),
"x86-64 fixture language");
Address base = currentProgram.getMemory().getMinAddress();
Address selected = base; // mov eax, 1; ret
Address alternate = base.add(0x08); // mov eax, 2; ret
Address slotCaller = base.add(0x10); // call qword ptr [rip + 0x2a] (slot at +0x40); ret
Address registerCaller = base.add(0x18); // call rax; ret
Address directCaller = base.add(0x20); // call selected; ret
for (Address entry : new Address[] { selected, alternate, slotCaller, registerCaller, directCaller }) {
if (getInstructionAt(entry) == null) disassemble(entry);
require(getInstructionAt(entry) != null, "Decoded entry " + entry);
require(createFunction(entry, null) != null, "Created function at " + entry);
}
Instruction slotCall = requireCall(slotCaller, true);
requireCall(registerCaller, true);
Instruction directCall = requireCall(directCaller, false);
currentProgram.getReferenceManager().addMemoryReference(
slotCall.getAddress(), selected, RefType.COMPUTED_CALL, SourceType.ANALYSIS, 0);
if (!hasCallReference(directCall))
currentProgram.getReferenceManager().addMemoryReference(
directCall.getAddress(), selected, RefType.UNCONDITIONAL_CALL, SourceType.ANALYSIS, 0);
Field space = bridge.getClass().getDeclaredField("sessionDefaultAddressSpace");
space.setAccessible(true);
space.set(null, base.getAddressSpace());
Method callees = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class);
callees.setAccessible(true);
String selectedAddress = canonical(selected);
JsonObject computed = callees(callees, bridge, slotCaller);
require(contains(computed.getAsJsonArray("value"), selectedAddress),
"The recorded slot target remains an observed callee");
require(!contains(computed.getAsJsonArray("value"), canonical(alternate)),
"No callee is invented for the alternate value");
JsonObject computedCoverage = computed.getAsJsonObject("relationship_coverage");
require(computedCoverage.get("status").getAsString().equals("partial"),
"A computed call's recorded target is not an exhaustive callee set");
require(onlyCallees(computedCoverage), "Only the callee direction is limited");
require(limitationContains(computedCoverage, "1 " + COMPUTED_LIMITATION + canonical(slotCall.getAddress()) + ")"),
"The limitation names the computed call site");
JsonObject targetless = callees(callees, bridge, registerCaller);
JsonObject targetlessCoverage = targetless.getAsJsonObject("relationship_coverage");
require(targetlessCoverage.get("status").getAsString().equals("partial"),
"A targetless computed call keeps partial coverage");
require(!limitationContains(targetlessCoverage, COMPUTED_LIMITATION),
"A targetless call is not reported as having recorded candidates");
JsonObject direct = callees(callees, bridge, directCaller);
JsonArray directValue = direct.getAsJsonArray("value");
require(directValue.size() == 1 && contains(directValue, selectedAddress), "Direct callee");
require(direct.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("complete"),
"A direct call with its recorded destination stays complete");
println("REA_COMPUTED_CALL_COVERAGE_OK slot=partial register=partial direct=complete");
}
private Instruction requireCall(Address entry, boolean computed) {
Instruction instruction = getInstructionAt(entry);
require(instruction != null && instruction.getFlowType().isCall() &&
instruction.getFlowType().isComputed() == computed,
(computed ? "Computed" : "Direct") + " call decoded at " + entry);
return instruction;
}
private static boolean hasCallReference(Instruction instruction) {
for (Reference reference : instruction.getReferencesFrom())
if (reference.getReferenceType().isCall()) return true;
return false;
}
private JsonObject callees(Method method, Object bridge, Address procedure) throws Exception {
JsonObject input = new JsonObject();
input.add("document", JsonNull.INSTANCE);
input.addProperty("procedure", canonical(procedure));
JsonObject result;
try {
result = (JsonObject) method.invoke(bridge, input, false);
} catch (InvocationTargetException failure) {
throw new IllegalStateException("procedure_callees failed at " + procedure, failure.getCause());
}
println("REA_COMPUTED_CALL_COVERAGE " + canonical(procedure) + " " + result);
return result;
}
private static String canonical(Address address) {
return "0x" + Long.toHexString(address.getOffset());
}
private static boolean contains(JsonArray values, String expected) {
for (JsonElement value : values)
if (value.getAsString().equals(expected)) return true;
return false;
}
private static boolean onlyCallees(JsonObject coverage) {
JsonArray directions = coverage.getAsJsonArray("limited_directions");
return directions != null && directions.size() == 1 &&
directions.get(0).getAsString().equals("callees");
}
private static boolean limitationContains(JsonObject coverage, String expected) {
JsonArray limitations = coverage.getAsJsonArray("limitations");
if (limitations == null) return false;
for (JsonElement limitation : limitations)
if (limitation.getAsString().contains(expected)) return true;
return false;
}
private static void require(boolean condition, String label) {
if (!condition) throw new IllegalStateException(label);
}
}