Files
reverse-engineering-anything/src/android/JadxAnalysis.ts
T
Abel YagubyanandAbelo9996 955c7c80ec fix(android): report DEX static values of static final fields (#6981)
* fix(android): report DEX static values of static final fields

Fixes #6397

* fix(android): request DEX static values only for class inspection

* fix(android): range-check wide DEX static values

* fix(android): move the bridge to version 7 after main took 6

---------

Co-authored-by: Abelo9996 <Abelo9996@users.noreply.github.com>
2026-10-25 02:17:13 +08:00

390 lines
13 KiB
TypeScript

import type { z } from "zod";
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
import { androidResultSchemas } from "../domain/android/androidAnalysis.js";
import { parseAndroidManifestComponents } from "./AndroidManifestComponents.js";
import {
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
jadxAppSchema,
type JadxClassPage,
jadxClassSchema,
jadxLoadSchema,
jadxMethodSchema,
jadxXrefsSchema,
normalizeJadxText,
} from "./JadxProtocol.js";
/** The adapter consumes tool payloads, never provider RPC details outside this layer. */
export interface JadxToolPort {
listClasses(input: {
readonly query: string;
readonly offset: number;
readonly limit: number;
}): Promise<JadxClassPage>;
json(
name: string,
input: Readonly<Record<string, JsonValue>>,
): Promise<JsonValue>;
text(
name: string,
input: Readonly<Record<string, JsonValue>>,
): Promise<string>;
}
type Engine = z.infer<
typeof androidResultSchemas.inspect_android_package
>["engine"];
type ClassSummary = z.infer<typeof jadxClassSchema>;
const methodsOf = (summary: ClassSummary) => {
const seen = new Map<string, number>();
return summary.methods.map((method) => {
const index = seen.get(method.name) ?? 0;
seen.set(method.name, index + 1);
return {
name: method.name,
reported_signature: method.signature,
overload_index: index,
is_constructor: method.is_constructor,
dex_descriptor: method.dex_descriptor,
};
});
};
const inputFailure = (
request: AndroidRequest,
field: string,
message: string,
): never => {
throw new AnalysisInputError(request.operation, undefined, [
{ path: [field], reason: "invalid_value", message },
]);
};
const classSummary = async (
tools: JadxToolPort,
request: Exclude<
AndroidRequest,
{ operation: "inspect_android_package" | "search_android_classes" }
>,
): 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 }
: {}),
});
if (
raw !== null &&
typeof raw === "object" &&
!Array.isArray(raw) &&
typeof raw.error === "string"
)
throw new AnalysisOutputError(
request.operation,
`JADX class summary failed: ${raw.error}`,
);
const summary = jadxClassSchema.safeParse(raw);
if (!summary.success)
throw new AnalysisOutputError(
request.operation,
"JADX class summary failed or was malformed; negative counts are not an empty class",
{ cause: summary.error },
);
const value = summary.data;
if (
value.full_name !== request.input.class_name &&
value.raw_name !== request.input.class_name
)
inputFailure(
request,
"class_name",
`Exact class ${request.input.class_name} resolved to ${value.full_name}; use the fully qualified class name from search_android_classes.`,
);
if (
value.method_count !== value.methods.length ||
value.field_count !== value.fields.length ||
value.inner_class_count !== value.inner_classes.length
)
throw new AnalysisOutputError(
request.operation,
"JADX class member counts do not match the returned inventory",
);
return value;
};
const inspectPackage = async (
tools: JadxToolPort,
loaded: z.infer<typeof jadxLoadSchema>,
engine: Engine,
): Promise<JsonValue> => {
const app = jadxAppSchema.parse(await tools.json("get_app_info", {}));
const manifest = normalizeJadxText(
await tools.text("get_android_manifest", {}),
);
return androidResultSchemas.inspect_android_package.parse({
engine,
package_name: app.package || null,
version_name: app.version_name || null,
version_code: app.version_code || null,
min_sdk: app.min_sdk || null,
target_sdk: app.target_sdk || null,
permissions: app.permissions,
manifest,
components: parseAndroidManifestComponents(
manifest.text,
manifest.status,
app.target_sdk || null,
app.min_sdk || null,
),
class_count: loaded.class_count,
resource_count: loaded.resource_count,
signature_verification: "not_performed",
});
};
const searchClasses = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "search_android_classes" }>,
loaded: z.infer<typeof jadxLoadSchema>,
engine: Engine,
): Promise<JsonValue> => {
const matches: string[] = [];
const pageSize = 4096;
let offset = 0;
do {
const page = await tools.listClasses({
query: request.input.query,
offset,
limit: pageSize,
});
if (
page.total_classes !== loaded.class_count ||
page.offset !== offset ||
page.limit !== pageSize ||
page.next_offset < offset ||
page.next_offset > loaded.class_count ||
page.items.length > pageSize ||
page.items.length > page.next_offset - offset ||
(request.input.query === "" &&
page.items.length !== page.next_offset - offset) ||
page.items.some((name) => !name.includes(request.input.query))
)
throw new AnalysisOutputError(
request.operation,
"JADX class pagination changed identity or counts",
);
if (
(offset < loaded.class_count && page.next_offset === offset) ||
(page.next_offset < loaded.class_count && page.items.length !== pageSize)
)
throw new AnalysisOutputError(
request.operation,
"JADX class pagination is incomplete",
);
matches.push(...page.items);
offset = page.next_offset;
} while (offset < loaded.class_count);
const occurrences = new Map<string, number>();
for (const name of matches)
occurrences.set(name, (occurrences.get(name) ?? 0) + 1);
const duplicateNames = [...occurrences]
.filter(([, count]) => count > 1)
.map(([name, count]) => ({ name, occurrences: count }));
return androidResultSchemas.search_android_classes.parse({
engine,
query: request.input.query,
total_classes: loaded.class_count,
matches,
duplicate_names: duplicateNames,
coverage: "complete",
});
};
const matchingMethods = (
summary: ClassSummary,
request: Extract<
AndroidRequest,
{ operation: "inspect_android_method" | "trace_android_references" }
>,
) => {
const candidates = methodsOf(summary).filter(
(method) => method.name === request.input.method_name,
);
if (request.input.method_name !== undefined && candidates.length === 0)
inputFailure(
request,
"method_name",
`Method ${summary.full_name}.${request.input.method_name} was not found; inspect_android_class lists available methods.`,
);
return candidates;
};
const inspectMethod = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "inspect_android_method" }>,
summary: ClassSummary,
engine: Engine,
): 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("; ")}`,
);
const index = request.input.overload_index ?? 0;
const selected =
candidates[index] ??
inputFailure(
request,
"overload_index",
`Index ${index} is outside the ${candidates.length} matching overloads.`,
);
const body = jadxMethodSchema.parse(
await tools.json("get_method_body", {
class_name: request.input.class_name,
method_name: request.input.method_name,
overload_index: index,
smali_fallback: true,
}),
);
if (
body.class_name !== summary.full_name ||
body.method_name !== request.input.method_name ||
body.overload_index !== index ||
body.overload_count !== candidates.length ||
body.dex_descriptor !== selected.dex_descriptor
)
throw new AnalysisOutputError(
request.operation,
"JADX returned a different method or overload inventory",
);
return androidResultSchemas.inspect_android_method.parse({
engine,
class_name: summary.full_name,
method: selected,
overload_count: candidates.length,
representation: body.mode,
body_status: body.body.length === 0 ? "not_available" : "available",
fell_back: body.fell_back,
markers: body.markers,
source: normalizeJadxText(body.body),
});
};
/** Normalize only the selected analyst outcome and reject ambiguous engine selectors. */
export const analyzeJadxRequest = async (
tools: JadxToolPort,
request: AndroidRequest,
loaded: z.infer<typeof jadxLoadSchema>,
engine: Engine,
): Promise<JsonValue> => {
if (request.operation === "inspect_android_package")
return inspectPackage(tools, loaded, engine);
if (request.operation === "search_android_classes")
return searchClasses(tools, request, loaded, engine);
const summary = await classSummary(tools, request);
if (request.operation === "inspect_android_class")
return androidResultSchemas.inspect_android_class.parse({
engine,
class_name: summary.full_name,
methods: methodsOf(summary),
fields: summary.fields.map((field) => ({
name: field.name,
reported_type: field.type,
dex_static_value: field.dex_static_value ?? null,
})),
inner_classes: summary.inner_classes,
});
if (request.operation === "inspect_android_method")
return inspectMethod(tools, request, summary, engine);
return traceReferences(tools, request, summary, engine);
};
const traceReferences = async (
tools: JadxToolPort,
request: Extract<AndroidRequest, { operation: "trace_android_references" }>,
summary: ClassSummary,
engine: Engine,
): Promise<JsonValue> => {
const methodName = request.input.method_name;
const candidates = matchingMethods(summary, request);
if (methodName === undefined && request.input.overload_index !== undefined)
inputFailure(
request,
"overload_index",
"Method name is required for an overload index.",
);
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("; ")}`,
);
const index = request.input.overload_index ?? 0;
const selected = methodName === undefined ? undefined : candidates[index];
if (methodName !== undefined && selected === undefined)
inputFailure(
request,
"overload_index",
`Index ${index} is outside the ${candidates.length} matching overloads.`,
);
const expectedTarget =
methodName === undefined
? summary.full_name
: `${summary.full_name}.${methodName}`;
const xrefs = jadxXrefsSchema.parse(
await tools.json(
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
{
class_name: request.input.class_name,
...(methodName === undefined
? {}
: { method_name: methodName, overload_index: index }),
limit: 2_147_483_647,
resolve_line: false,
},
),
);
if (
xrefs.target !== expectedTarget ||
(selected !== undefined &&
(xrefs.overload_index !== index ||
xrefs.reported_signature !== selected.reported_signature)) ||
xrefs.total !== xrefs.count ||
xrefs.count !== xrefs.items.length
)
throw new AnalysisOutputError(
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) => ({
kind: reference.kind,
name: reference.name,
reported_full_name: reference.full_name,
containing_class: reference.containing_class,
top_class: reference.top_class,
source_line: null,
dex_offset: null,
direct_invokes: reference.direct_invokes ?? null,
direct_invokes_exhaustive: reference.direct_invokes_exhaustive ?? null,
})),
coverage: "complete",
reference_unit: "using_node",
});
};