mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7e0b28083 | ||
|
|
2a312e999f |
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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user