feat(android): trace callers of external DEX methods (#8154)

Resolve exact external method targets from directly encoded DEX invokes.
Preserve complete caller identity, bounded position evidence and follow-up
selectors. Report aggregate position coverage independently of the caller
inventory, and reuse bounded prototype facts within the exact session.

Absorbs #8096. Fixes #7811.

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
This commit is contained in:
gensokyo-devandhhang authored and GitHub committed 2026-10-31 01:07:18 +08:00
1 parent 675d4076de
commit 0ffed7a48c
20 files changed
+1472 -76

No files matched your search

+160 -43
View File
@@ -11,6 +11,7 @@ import { parseAndroidManifestComponents } from "./AndroidManifestComponents.js";
import {
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { AndroidNativeDeclarations } from "../domain/android/androidNativeDeclarations.js";
@@ -19,11 +20,11 @@ import {
ANDROID_JNI_REPORT_BYTES,
projectAndroidJniBindings,
} from "../domain/android/androidJniBindings.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import {
jadxAppSchema,
type JadxClassPage,
jadxClassSchema,
jadxExternalReferenceSummarySchema,
jadxLoadSchema,
jadxMethodSchema,
jadxXrefsSchema,
@@ -68,7 +69,7 @@ const observedPackageSchema =
components: observedAndroidManifestComponentsSchema,
});
const methodsOf = (summary: ClassSummary) => {
const methodsOf = (summary: Pick<ClassSummary, "methods">) => {
const seen = new Map<string, number>();
return summary.methods.map((method) => {
const index = seen.get(method.name) ?? 0;
@@ -137,26 +138,33 @@ const inputFailure = (
]);
};
const classSummary = async (
tools: JadxToolPort,
request: Exclude<
AndroidRequest,
{
operation:
| "inspect_android_package"
| "search_android_classes"
| "inspect_android_jni_bindings";
}
>,
): Promise<ClassSummary> => {
// Only class inspection reports DEX static values; method and reference
// lookups keep the member-only summary so large constants cannot block them.
const raw = await tools.json("get_class_summary", {
class_name: request.input.class_name,
...(request.operation === "inspect_android_class"
? { dex_static_values: true }
: {}),
});
type ClassRequest = Exclude<
AndroidRequest,
{
operation:
| "inspect_android_package"
| "search_android_classes"
| "inspect_android_jni_bindings";
}
>;
type ReferenceSummary = Pick<ClassSummary, "full_name" | "methods"> & {
readonly reference_target?: "external";
};
const parsedClassSummary = (
raw: JsonValue,
request: ClassRequest,
): ClassSummary => {
if (
raw !== null &&
typeof raw === "object" &&
!Array.isArray(raw) &&
"reference_target" in raw
)
throw new AnalysisOutputError(
request.operation,
"External prototypes are not an APK class definition",
);
if (
raw !== null &&
typeof raw === "object" &&
@@ -197,6 +205,68 @@ const classSummary = async (
return value;
};
const classSummary = async (
tools: JadxToolPort,
request: ClassRequest,
): Promise<ClassSummary> =>
parsedClassSummary(
await tools.json("get_class_summary", {
class_name: request.input.class_name,
...(request.operation === "inspect_android_class"
? { dex_static_values: true }
: {}),
}),
request,
);
const referenceSummary = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "trace_android_references" }>,
): Promise<ReferenceSummary> => {
if (request.input.method_name === undefined)
return classSummary(tools, request);
const raw = await tools.json("get_class_summary", {
class_name: request.input.class_name,
method_name: request.input.method_name,
reference_target: true,
});
if (
raw === null ||
typeof raw !== "object" ||
Array.isArray(raw) ||
raw.reference_target !== "external"
)
return parsedClassSummary(raw, request);
const parsed = jadxExternalReferenceSummarySchema.safeParse(raw);
if (!parsed.success)
throw new AnalysisOutputError(
request.operation,
"JADX external prototype inventory is malformed",
{ cause: parsed.error },
);
const value = parsed.data;
if (
value.class_name !== request.input.class_name ||
value.observed_method_count !== value.prototypes.length ||
value.prototypes.some(
(method) =>
method.name !== request.input.method_name ||
method.raw_name !== method.name,
) ||
new Set(value.prototypes.map((method) => method.dex_descriptor)).size !==
value.prototypes.length
)
throw new AnalysisOutputError(
request.operation,
"JADX external prototypes do not match the selected method inventory",
);
return {
full_name: value.class_name,
methods: value.prototypes,
reference_target: "external",
};
};
const inspectPackage = async (
tools: JadxToolPort,
loaded: z.infer<typeof jadxLoadSchema>,
@@ -286,7 +356,7 @@ const searchClasses = async (
};
const matchingMethods = (
summary: ClassSummary,
summary: ReferenceSummary,
request: Extract<
AndroidRequest,
{ operation: "inspect_android_method" | "trace_android_references" }
@@ -304,6 +374,12 @@ const matchingMethods = (
return candidates;
};
const overloadChoices = (
candidates: ReturnType<typeof matchingMethods>,
external = false,
): string =>
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}${external ? ` [${method.dex_descriptor}]` : ""}`).join("; ")}`;
const inspectMethod = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "inspect_android_method" }>,
@@ -312,11 +388,7 @@ const inspectMethod = async (
): Promise<JsonValue> => {
const candidates = matchingMethods(summary, request);
if (candidates.length > 1 && request.input.overload_index === undefined)
inputFailure(
request,
"overload_index",
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
);
inputFailure(request, "overload_index", overloadChoices(candidates));
const index = request.input.overload_index ?? 0;
const selected =
candidates[index] ??
@@ -414,6 +486,13 @@ export const analyzeJadxRequest = async (
return inspectPackage(tools, loaded, engine);
if (request.operation === "search_android_classes")
return searchClasses(tools, request, loaded, engine);
if (request.operation === "trace_android_references")
return traceReferences(
tools,
request,
await referenceSummary(tools, request),
engine,
);
const summary = await classSummary(tools, request);
if (request.operation === "inspect_android_class")
return androidResultSchemas.inspect_android_class.parse({
@@ -433,18 +512,17 @@ export const analyzeJadxRequest = async (
...fieldQueries(summary),
]),
});
if (request.operation === "inspect_android_method")
return inspectMethod(tools, request, summary, context);
return traceReferences(tools, request, summary, engine);
return inspectMethod(tools, request, summary, context);
};
const traceReferences = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "trace_android_references" }>,
summary: ClassSummary,
summary: ReferenceSummary,
engine: Engine,
): Promise<JsonValue> => {
const methodName = request.input.method_name;
const external = summary.reference_target === "external";
const candidates = matchingMethods(summary, request);
if (methodName === undefined && request.input.overload_index !== undefined)
inputFailure(
@@ -456,7 +534,7 @@ const traceReferences = async (
inputFailure(
request,
"overload_index",
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
overloadChoices(candidates, external),
);
const index = request.input.overload_index ?? 0;
const selected = methodName === undefined ? undefined : candidates[index];
@@ -475,6 +553,7 @@ const traceReferences = async (
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
{
class_name: request.input.class_name,
...(external ? { reference_target: true } : {}),
...(methodName === undefined
? {}
: { method_name: methodName, overload_index: index }),
@@ -485,6 +564,20 @@ const traceReferences = async (
);
if (
xrefs.target !== expectedTarget ||
(external &&
(xrefs.reference_target !== "external" ||
xrefs.reference_scope !== "direct_dex_invokes" ||
xrefs.dex_descriptor !== selected?.dex_descriptor ||
xrefs.items.some(
(item) =>
item.kind !== "method" ||
item.source_selector === undefined ||
item.source_selector.class_name !== item.containing_class ||
item.source_selector.method_name !== item.name ||
item.direct_invokes === undefined ||
item.direct_invokes_exhaustive === undefined,
))) ||
(!external && xrefs.reference_target === "external") ||
(selected !== undefined &&
(xrefs.overload_index !== index ||
xrefs.reported_signature !== selected.reported_signature)) ||
@@ -495,15 +588,23 @@ const traceReferences = async (
request.operation,
"JADX references are incomplete or refer to another target",
);
return androidResultSchemas.trace_android_references.parse({
engine,
class_name: summary.full_name,
method_name: methodName ?? null,
overload_index: selected?.overload_index ?? null,
reported_signature: selected?.reported_signature ?? null,
dex_descriptor: selected?.dex_descriptor ?? null,
total: xrefs.total,
references: xrefs.items.map((reference) => ({
const positions = {
complete_using_nodes: 0,
partial_using_nodes: 0,
unknown_using_nodes: 0,
recorded_sites: 0,
};
const references = xrefs.items.map((reference) => {
positions.recorded_sites += reference.direct_invokes?.length ?? 0;
if (
reference.direct_invokes === undefined ||
reference.direct_invokes_exhaustive === undefined
)
positions.unknown_using_nodes++;
else if (reference.direct_invokes_exhaustive)
positions.complete_using_nodes++;
else positions.partial_using_nodes++;
return {
kind: reference.kind,
name: reference.name,
reported_full_name: reference.full_name,
@@ -511,9 +612,25 @@ const traceReferences = async (
top_class: reference.top_class,
source_line: null,
dex_offset: null,
...(reference.source_selector === undefined
? {}
: { source_selector: reference.source_selector }),
direct_invokes: reference.direct_invokes ?? null,
direct_invokes_exhaustive: reference.direct_invokes_exhaustive ?? null,
})),
};
});
return androidResultSchemas.trace_android_references.parse({
engine,
target_definition: external ? "external" : "defined",
...(external ? { reference_scope: "direct_dex_invokes" } : {}),
class_name: summary.full_name,
method_name: methodName ?? null,
overload_index: selected?.overload_index ?? null,
reported_signature: selected?.reported_signature ?? null,
dex_descriptor: selected?.dex_descriptor ?? null,
total: xrefs.total,
direct_invokes_coverage: positions,
references,
coverage: "complete",
reference_unit: "using_node",
});
+31 -11
View File
@@ -2,7 +2,10 @@ import { z } from "zod";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import { dexStaticValueSchema } from "../domain/android/androidAnalysis.js";
import {
androidMethodSelectorSchema,
dexStaticValueSchema,
} from "../domain/android/androidAnalysis.js";
import {
dexDescriptorSchema,
dexTypeSchema,
@@ -27,9 +30,15 @@ const jadxInputFailureSchema = z.strictObject({
field: z.enum(["class_name", "method_name", "overload_index"]),
message: z.string().min(1),
});
const jadxReferenceLimitSchema = z.strictObject({
kind: z.literal("reference-output-limit"),
stage: z.enum(["method_inventory", "caller_inventory"]),
limit_bytes: z.int().positive().safe(),
});
const bridgeFailureSchema = z.discriminatedUnion("kind", [
jadxInputFailureSchema,
jadxMemoryFailureSchema,
jadxReferenceLimitSchema,
]);
/** Arbitrary upstream failure text stays text unless it matches a named bridge contract. */
@@ -87,6 +96,15 @@ export const parseJadxClassPage = (value: JsonValue): JadxClassPage => {
return value as JadxClassPage;
};
const jadxMethodMetadataSchema = z.object({
name: z.string(),
raw_name: z.string().min(1),
signature: z.string(),
dex_descriptor: dexDescriptorSchema,
is_constructor: z.boolean(),
def_pos: z.int(),
});
export const jadxClassSchema = z.object({
full_name: z.string().min(1),
raw_name: z.string().min(1),
@@ -94,16 +112,7 @@ export const jadxClassSchema = z.object({
method_count: count,
field_count: count,
inner_class_count: count,
methods: z.array(
z.object({
name: z.string(),
raw_name: z.string().min(1),
signature: z.string(),
dex_descriptor: dexDescriptorSchema,
is_constructor: z.boolean(),
def_pos: z.int(),
}),
),
methods: z.array(jadxMethodMetadataSchema),
fields: z.array(
z.object({
name: z.string(),
@@ -118,6 +127,13 @@ export const jadxClassSchema = z.object({
/** Original binary names, index-aligned with the display names above. */
inner_class_raw_names: z.array(z.string()),
});
/** Reference-only prototypes are not an external class definition or field inventory. */
export const jadxExternalReferenceSummarySchema = z.object({
reference_target: z.literal("external"),
class_name: z.string().min(1),
observed_method_count: count,
prototypes: z.array(jadxMethodMetadataSchema).min(1),
});
export const jadxMethodSchema = z.object({
class_name: z.string(),
method_name: z.string(),
@@ -132,6 +148,9 @@ export const jadxMethodSchema = z.object({
});
export const jadxXrefsSchema = z.object({
target: z.string(),
reference_target: z.literal("external").optional(),
reference_scope: z.literal("direct_dex_invokes").optional(),
dex_descriptor: dexDescriptorSchema.optional(),
overload_index: count.optional(),
reported_signature: z.string().optional(),
total: count,
@@ -145,6 +164,7 @@ export const jadxXrefsSchema = z.object({
containing_class: z.string(),
top_class: z.string(),
def_pos: z.int(),
source_selector: androidMethodSelectorSchema.optional(),
direct_invokes_exhaustive: z.boolean().optional(),
direct_invokes: z
.array(
+1 -1
View File
@@ -10,7 +10,7 @@ export const JADX_RELEASE = Object.freeze({
/** Identity of REA's metadata bridge, distinct from the producing engine. */
export const JADX_BRIDGE_IDENTITY = Object.freeze({
name: "rea-jadx-bridge",
version: "9",
version: "10",
});
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
+15 -4
View File
@@ -23,6 +23,7 @@ import {
type JsonValue,
} from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js";
import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js";
import { readAndroidNativeInventory } from "./AndroidNativeInventory.js";
@@ -287,11 +288,10 @@ export class JadxSession {
calls: this.#observedCalls.map(({ complete }) => complete),
},
subject: target,
limitations: [
...(request.operation === "inspect_android_jni_bindings"
limitations:
request.operation === "inspect_android_jni_bindings"
? []
: [...JADX_LIMITATIONS, ...androidMappingLimitations(mapping)]),
],
: [...JADX_LIMITATIONS, ...androidMappingLimitations(mapping)],
locations:
request.operation === "inspect_android_package"
? [{ kind: "artifact-path", path: "AndroidManifest.xml" }]
@@ -382,6 +382,17 @@ export class JadxSession {
if (envelope.isError === true) {
const failure = parseJadxFailure(text);
if (failure !== undefined) {
if (failure.kind === "reference-output-limit")
throw new AnalysisResourceConstraintError(
request.operation,
"memory",
"External DEX reference metadata exceeds the bounded transport representation allowance.",
{ stage: failure.stage, limit_bytes: failure.limit_bytes },
fixedAllowanceOptions(
`The external DEX ${failure.stage} representation allowance of ${String(failure.limit_bytes)} bytes`,
"report",
),
);
if (name === "load_apk" && failure.kind === "jvm-memory-failure")
throw new AnalysisResourceConstraintError(
request.operation,
+7 -3
View File
@@ -261,16 +261,20 @@ export const registerAndroidCommands = (
});
cli.command(CLI_COMMANDS.traceAndroidReferences, {
description:
"Trace incoming APK using nodes for a class or selected method",
"Trace APK using nodes for a defined class/method or an externally invoked DEX method",
args: z.object({ path, className }),
options: z.object({
methodName: z
.string()
.optional()
.describe("Declared method name; omit for class references"),
.describe(
"Defined or directly referenced external method; omit for APK class references",
),
overloadIndex: cliIntegerArgument(0)
.optional()
.describe("Index among same-name methods from inspect-android-class"),
.describe(
"Index among defined methods or observed external prototypes listed by an ambiguous query",
),
}),
run: ({ args, options }) =>
execute(
+10 -1
View File
@@ -167,7 +167,7 @@ export const ANDROID_TOOL_CONTRACTS = [
name: "trace_android_references",
...toolContractMetadata("trace_android_references"),
description:
"Trace incoming static using nodes to an exact Android class or selected method overload. The total counts containing nodes, not individual invoke instructions. Use inspect_android_class to choose an overload_index when names collide. Source lines and the containing node dex_offset remain unknown. direct_invokes carries exact DEX code-unit instruction offsets when available; direct_invokes_exhaustive false marks byte-bounded partial instruction evidence, and null means coverage is unknown. Using-node coverage remains separate; no runtime calls are inferred.",
"Trace incoming static using nodes to an exact Android class or selected method overload. A method target may be an external class directly named by DEX invokes: target_definition external and reference_scope direct_dex_invokes identify this narrower inventory, without inventing a class definition or body. Ambiguous external requests list observed DEX prototypes; select overload_index explicitly. For APK-defined targets use inspect_android_class. The total counts containing nodes, not individual invokes. External callers include source_selector for inspect_android_method. Source lines and the containing node dex_offset remain unknown. direct_invokes carries exact DEX code-unit offsets when available; direct_invokes_exhaustive false marks byte-bounded partial instruction evidence, and null means unknown. No reflection, JNI or runtime dispatch is inferred.",
kind: "android-provider",
inputSchema: androidInputSchemas.trace_android_references,
outputSchema: evidenceResultOf(
@@ -178,6 +178,15 @@ export const ANDROID_TOOL_CONTRACTS = [
title: "Find references to a class",
input: { path: "/tmp/Example.apk", class_name: "example.MainActivity" },
},
{
title: "Find APK callers of an observed external API prototype",
input: {
path: "/tmp/Example.apk",
class_name: "android.app.NotificationManager",
method_name: "notify",
overload_index: 0,
},
},
],
},
] as const satisfies readonly ToolContract[];
+26 -1
View File
@@ -58,6 +58,9 @@ export const androidInputSchemas = {
}),
trace_android_references: z.strictObject({
...classTarget,
class_name: classTarget.class_name.describe(
"Exact APK-defined class, or an externally referenced DEX class when method_name is selected",
),
method_name: z
.string()
.min(1)
@@ -70,7 +73,7 @@ export const androidInputSchemas = {
.nonnegative()
.optional()
.describe(
"Index among same-name methods from inspect_android_class; requires method_name",
"Index among same-name APK methods or observed external DEX prototypes; requires method_name",
),
}),
} as const;
@@ -199,6 +202,12 @@ const method = z.strictObject({
is_constructor: z.boolean(),
dex_descriptor: dexDescriptorSchema,
});
/** Exact member selector shared with follow-up APK method inspection. */
export const androidMethodSelectorSchema = z.strictObject({
class_name: z.string().min(1),
method_name: z.string().min(1),
overload_index: z.int().nonnegative(),
});
const engine = z.strictObject({
name: z.string().min(1),
version: z.string().nullable(),
@@ -276,12 +285,26 @@ export const androidResultSchemas = {
}),
trace_android_references: z.strictObject({
...common,
/** Older retained Evidence omits this fact; omission does not mean external. */
target_definition: z.enum(["defined", "external"]).exactOptional(),
/** External results inventory directly encoded invokes, not reflection or dispatch. */
reference_scope: z.literal("direct_dex_invokes").exactOptional(),
class_name: z.string(),
method_name: z.string().nullable(),
overload_index: z.int().nonnegative().nullable(),
reported_signature: z.string().nullable(),
dex_descriptor: dexDescriptorSchema.nullable(),
total: z.int().nonnegative(),
/** Position coverage is separate from the complete using-node inventory.
* Historical Evidence may omit this aggregate; omission is unexamined. */
direct_invokes_coverage: z
.strictObject({
complete_using_nodes: z.int().nonnegative(),
partial_using_nodes: z.int().nonnegative(),
unknown_using_nodes: z.int().nonnegative(),
recorded_sites: z.int().nonnegative(),
})
.exactOptional(),
references: z.array(
z.strictObject({
kind: z.string(),
@@ -291,6 +314,8 @@ export const androidResultSchemas = {
top_class: z.string(),
source_line: z.null(),
dex_offset: z.null(),
/** An explicit follow-up selector accepted by inspect_android_method. */
source_selector: androidMethodSelectorSchema.exactOptional(),
/** false means transport-bounded instruction evidence; null is unknown. */
direct_invokes_exhaustive: z.boolean().nullable(),
/** Raw direct invoke instructions inside this using node, when available. */