Files
reverse-engineering-anything/tests/boundary/android/JadxAnalysis.test.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

919 lines
26 KiB
TypeScript

import { expect, it } from "vitest";
import {
analyzeJadxRequest,
type JadxToolPort,
} from "../../../src/android/JadxAnalysis.js";
import {
type AndroidRequest,
androidInputSchemas,
androidResultSchemas,
} from "../../../src/domain/android/androidAnalysis.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
const summary = {
full_name: "fixture.Target",
raw_name: "fixture.Target",
name: "Target",
method_count: 2,
field_count: 0,
inner_class_count: 0,
methods: [
{
name: "choose",
signature: "choose(String): void",
dex_descriptor: "(Ljava/lang/String;)V",
is_constructor: false,
def_pos: 0,
},
{
name: "choose",
signature: "choose(int): void",
dex_descriptor: "(I)V",
is_constructor: false,
def_pos: 0,
},
],
fields: [],
inner_classes: [],
};
const engine = {
name: "jadx",
version: "0.7.1",
artifact_sha256: "a".repeat(64),
source_revision: null,
worker_count: 1,
heap_limit_mib: null,
};
it.each([
["", "", true, true, null],
["16", "", true, true, null],
["17", "", false, true, null],
["33", "", false, null, null],
["16", "17", false, true, null],
["Preview", "", null, null, null],
["1", "Preview", null, null, null],
["", "", null, null, 'android:targetSdkVersion=""'],
["", "", null, null, 'android:minSdkVersion=""'],
[
"16",
"",
null,
null,
'android:minSdkVersion="16" android:targetSdkVersion=""',
],
] as const)(
"derives export rules from minimum SDK %s and declared target %s",
async (minimum, target, providerDefault, activityDefault, sourceSdk) => {
const sdk =
sourceSdk ??
[
minimum === "" ? "" : `android:minSdkVersion="${minimum}"`,
target === "" ? "" : `android:targetSdkVersion="${target}"`,
]
.filter(Boolean)
.join(" ");
const manifest = `<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">${sdk === "" ? "" : `<uses-sdk ${sdk}/>`}<application><provider android:name=".Data"/><activity android:name=".Filtered"><intent-filter><action android:name="example.OPEN"/></intent-filter></activity></application></manifest>`;
const tools: JadxToolPort = {
json: (name) => {
expect(name).toBe("get_app_info");
return Promise.resolve({
package: "com.example",
version_name: "",
version_code: "",
min_sdk: minimum,
target_sdk: target,
permissions: [],
});
},
text: (name) => {
expect(name).toBe("get_android_manifest");
return Promise.resolve(manifest);
},
listClasses: () => Promise.reject(new Error("Unexpected class query")),
};
const report = androidResultSchemas.inspect_android_package.parse(
await analyzeJadxRequest(
tools,
{
operation: "inspect_android_package",
input: { path: "/fixture.apk" },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 0,
resource_count: 1,
threads: 1,
resources: "full",
},
engine,
),
);
expect(report.target_sdk).toBe(target || null);
expect(report.min_sdk).toBe(minimum || null);
expect(report.components.status).toBe("complete");
expect(report.components.items[0]?.exported_default).toBe(providerDefault);
expect(report.components.items[1]?.exported_default).toBe(activityDefault);
if (sourceSdk !== null || minimum === "Preview" || target === "Preview")
expect(report.components.limitations.join(" ")).toContain(
"target SDK is unknown",
);
else if (minimum === "33" && target === "")
expect(report.components.limitations.join(" ")).toContain(
"requires an explicit value",
);
else expect(report.components.limitations).toEqual([]);
},
);
const nestedClassInput = {
path: "/fixture.apk",
class_name: "fixture.Outer$Worker",
};
const nestedClassRequests: AndroidRequest[] = [
{ operation: "inspect_android_class", input: nestedClassInput },
{
operation: "inspect_android_method",
input: { ...nestedClassInput, method_name: "choose" },
},
{ operation: "trace_android_references", input: nestedClassInput },
{
operation: "trace_android_references",
input: { ...nestedClassInput, method_name: "choose" },
},
];
it.each(nestedClassRequests)(
"preserves the raw nested class selector for $operation and its followups: $input",
async (request) => {
const calls: Record<string, unknown>[] = [];
const fullName = "fixture.Outer.Worker";
const selected = summary.methods[1]!;
const tools: JadxToolPort = {
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
text: () => Promise.reject(new Error("unexpected text call")),
json: (name, input) => {
calls.push({ name, ...input });
if (name === "get_class_summary")
return Promise.resolve({
...summary,
full_name: fullName,
raw_name: nestedClassInput.class_name,
method_count: 1,
methods: [selected],
});
if (name === "get_method_body")
return Promise.resolve({
class_name: fullName,
method_name: selected.name,
full_name: `${fullName}.${selected.name}`,
overload_index: 0,
overload_count: 1,
dex_descriptor: selected.dex_descriptor,
mode: "java",
fell_back: false,
markers: [],
body: "void choose(int value) {}",
});
return Promise.resolve({
target:
input.method_name === undefined
? fullName
: `${fullName}.${selected.name}`,
...(input.method_name === undefined
? {}
: {
overload_index: 0,
reported_signature: selected.signature,
}),
total: 0,
count: 0,
resolve_line: false,
items: [],
});
},
};
const result = await analyzeJadxRequest(
tools,
request,
{} as never,
engine,
);
expect(result).toMatchObject({ class_name: fullName });
expect(calls.map((call) => call.class_name)).toEqual(
Array.from({ length: calls.length }, () => nestedClassInput.class_name),
);
expect(calls).toHaveLength(
request.operation === "inspect_android_class" ? 1 : 2,
);
},
);
it("rejects a nested class when neither producer identity matches the requested selector", async () => {
await expect(
analyzeJadxRequest(
{
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
text: () => Promise.reject(new Error("unexpected text call")),
json: () =>
Promise.resolve({
...summary,
full_name: "fixture.Outer.Worker",
raw_name: "fixture.Outer$Different",
}),
},
{ operation: "inspect_android_class", input: nestedClassInput },
{} as never,
engine,
),
).rejects.toMatchObject({ _tag: "AnalysisInputError" });
});
it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => {
const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`);
names[0] = "fixture.TargetDuplicate";
names[4096] = "fixture.TargetDuplicate";
const calls: Record<string, unknown>[] = [];
const tools: JadxToolPort = {
json: () => Promise.reject(new Error("unexpected JSON call")),
listClasses: (input) => {
calls.push({ ...input });
const offset = Number(input.offset);
return Promise.resolve({
scope: "top_level" as const,
total_classes: 10000,
next_offset: offset === 0 ? 8191 : 10000,
offset,
limit: 4096,
items: offset === 0 ? names.slice(0, 4096) : names.slice(4096),
});
},
text: () => Promise.reject(new Error("unexpected text call")),
};
const result = await analyzeJadxRequest(
tools,
{
operation: "search_android_classes",
input: { path: "/fixture.apk", query: "Target" },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 10000,
resource_count: 0,
threads: 1,
resources: "full",
},
engine,
);
expect(calls).toEqual([
{ query: "Target", offset: 0, limit: 4096 },
{ query: "Target", offset: 8191, limit: 4096 },
]);
expect(result).toMatchObject({
total_classes: 10000,
matches: names,
duplicate_names: [{ name: "fixture.TargetDuplicate", occurrences: 2 }],
coverage: "complete",
});
});
it.each([
{ name: "inventory total", query: "Target", page: { total_classes: 9 } },
{ name: "out-of-range cursor", query: "Target", page: { next_offset: 11 } },
{
name: "impossible examined row count",
query: "Target",
page: { next_offset: 1, items: ["TargetA", "TargetB"] },
},
{ name: "offset identity", query: "Target", page: { offset: 1 } },
{ name: "requested page size", query: "Target", page: { limit: 1 } },
{ name: "nonmatching row", query: "Target", page: { items: ["Other"] } },
{
name: "non-progressing page",
query: "Target",
page: { next_offset: 0, items: [] },
},
{ name: "premature page end", query: "Target", page: { next_offset: 9 } },
{ name: "empty-query completeness", query: "", page: {} },
])(
"refuses changed $name instead of reporting complete search coverage",
async ({ query, page }) => {
const tools: JadxToolPort = {
json: () => Promise.reject(new Error("unexpected JSON call")),
listClasses: () =>
Promise.resolve({
scope: "top_level" as const,
total_classes: 10,
next_offset: 10,
offset: 0,
limit: 4096,
items: ["fixture.Target"],
...page,
}),
text: () => Promise.reject(new Error("unexpected text call")),
};
await expect(
analyzeJadxRequest(
tools,
{
operation: "search_android_classes",
input: { path: "/fixture.apk", query },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 10,
resource_count: 0,
threads: 1,
resources: "full",
},
engine,
),
).rejects.toThrow();
},
);
it("accepts an examined empty inventory without requiring cursor progress", async () => {
const tools: JadxToolPort = {
json: () => Promise.reject(new Error("unexpected JSON call")),
listClasses: () =>
Promise.resolve({
scope: "top_level" as const,
total_classes: 0,
offset: 0,
next_offset: 0,
limit: 4096,
items: [],
}),
text: () => Promise.reject(new Error("unexpected text call")),
};
const result = await analyzeJadxRequest(
tools,
{
operation: "search_android_classes",
input: { path: "/fixture.apk", query: "Target" },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 0,
resource_count: 0,
threads: 1,
resources: "full",
},
engine,
);
expect(result).toMatchObject({
total_classes: 0,
matches: [],
duplicate_names: [],
coverage: "complete",
});
});
it("requires explicit direct invoke coverage state in result evidence", () => {
const result = {
engine,
class_name: "fixture.Target",
method_name: null,
overload_index: null,
reported_signature: null,
dex_descriptor: null,
total: 1,
references: [
{
kind: "method",
name: "caller",
reported_full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
source_line: null,
dex_offset: null,
direct_invokes: null,
},
],
coverage: "complete",
reference_unit: "using_node",
};
expect(
androidResultSchemas.trace_android_references.safeParse(result).success,
).toBe(false);
expect(
androidResultSchemas.trace_android_references.safeParse({
...result,
references: [
{ ...result.references[0], direct_invokes_exhaustive: null },
],
}).success,
).toBe(true);
});
it("publishes exact DEX prototypes instead of discarding bridge metadata", async () => {
const tools: JadxToolPort = {
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: () => Promise.resolve(summary),
text: () => Promise.reject(new Error("unexpected text call")),
};
const result = await analyzeJadxRequest(
tools,
{
operation: "inspect_android_class",
input: androidInputSchemas.inspect_android_class.parse({
path: "/fixture.apk",
class_name: "fixture.Target",
}),
},
{} as never,
engine,
);
expect(result).toMatchObject({
methods: [
{ name: "choose", dex_descriptor: "(Ljava/lang/String;)V" },
{ name: "choose", dex_descriptor: "(I)V" },
],
});
});
const constantFields = [
{
name: "MAX_SIZE",
type: "int",
def_pos: 0,
dex_static_value: { type: "int", value: 1024 },
},
{
name: "PACKET_FLAG_CONFIG",
type: "long",
def_pos: 0,
dex_static_value: { type: "long", value: "-9223372036854775808" },
},
{
name: "TAG",
type: "java.lang.String",
def_pos: 0,
dex_static_value: { type: "string", value: "flags" },
},
{ name: "INSTANCE", type: "fixture.Target", def_pos: 0 },
];
const inspectClassWithFields = (fields: JsonValue[]) =>
analyzeJadxRequest(
{
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: () =>
Promise.resolve({ ...summary, field_count: fields.length, fields }),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "inspect_android_class",
input: androidInputSchemas.inspect_android_class.parse({
path: "/fixture.apk",
class_name: "fixture.Target",
}),
},
{} as never,
engine,
);
it("reports DEX static values of static final fields without a static initializer", async () => {
const result = await inspectClassWithFields(constantFields);
expect(result).toMatchObject({
fields: [
{
name: "MAX_SIZE",
reported_type: "int",
dex_static_value: { type: "int", value: 1024 },
},
{
name: "PACKET_FLAG_CONFIG",
reported_type: "long",
dex_static_value: { type: "long", value: "-9223372036854775808" },
},
{
name: "TAG",
reported_type: "java.lang.String",
dex_static_value: { type: "string", value: "flags" },
},
{
name: "INSTANCE",
reported_type: "fixture.Target",
dex_static_value: null,
},
],
});
});
it.each([
{ type: "long", value: -9223372036854775808 },
{ type: "int", value: "1024" },
{ type: "double", value: 0.5 },
{ type: "enum", value: "fixture.Mode.ON" },
])(
"refuses a lossy or unsupported DEX static value $type",
async (dex_static_value) => {
await expect(
inspectClassWithFields([
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
]),
).rejects.toThrow("JADX class summary failed or was malformed");
},
);
it.each([
{ type: "byte", value: -128 },
{ type: "byte", value: 127 },
{ type: "short", value: -32_768 },
{ type: "char", value: 65_535 },
{ type: "int", value: -2_147_483_648 },
{ type: "int", value: 2_147_483_647 },
{ type: "long", value: "-9223372036854775808" },
{ type: "long", value: "9223372036854775807" },
{ type: "float", value: "3.4028235E38" },
{ type: "float", value: "-3.4028235E38" },
{ type: "float", value: "1.4E-45" },
{ type: "float", value: "-0.0" },
{ type: "float", value: "NaN" },
{ type: "float", value: "-Infinity" },
{ type: "double", value: "1.7976931348623157E308" },
{ type: "double", value: "4.9E-324" },
{ type: "double", value: "0.0" },
{ type: "double", value: "Infinity" },
])(
"accepts the boundary DEX static value $type $value",
async (dex_static_value) => {
await expect(
inspectClassWithFields([
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
]),
).resolves.toMatchObject({ fields: [{ dex_static_value }] });
},
);
it.each([
{ type: "byte", value: 128 },
{ type: "short", value: -32_769 },
{ type: "char", value: -1 },
{ type: "int", value: 2_147_483_648 },
{ type: "long", value: "9223372036854775808" },
{ type: "long", value: "-9223372036854775809" },
{ type: "long", value: "-0" },
{ type: "float", value: "1.0E40" },
{ type: "float", value: "3.4028236E38" },
{ type: "float", value: "1.0E-50" },
{ type: "double", value: "1.8E308" },
{ type: "double", value: "1.0E-400" },
])(
"refuses an out-of-range DEX static value $type $value",
async (dex_static_value) => {
await expect(
inspectClassWithFields([
{ name: "VALUE", type: "int", def_pos: 0, dex_static_value },
]),
).rejects.toThrow("JADX class summary failed or was malformed");
},
);
it.each([
{ operation: "inspect_android_class", input: {}, values: true },
{
operation: "inspect_android_method",
input: { method_name: "choose", overload_index: 1 },
values: false,
},
{
operation: "trace_android_references",
input: { method_name: "choose", overload_index: 1 },
values: false,
},
{ operation: "trace_android_references", input: {}, values: false },
] as const)(
"requests DEX static values for $operation only when inspecting a class",
async ({ operation, input, values }) => {
const calls: Record<string, unknown>[] = [];
const reply = (name: string): JsonValue => {
if (name === "get_class_summary") return summary;
if (name === "get_method_body")
return {
class_name: "fixture.Target",
method_name: "choose",
full_name: "fixture.Target.choose",
overload_index: 1,
overload_count: 2,
dex_descriptor: "(I)V",
mode: "java",
fell_back: false,
markers: [],
body: "void choose(int value) {}",
};
return {
target:
name === "get_xrefs_to_class"
? "fixture.Target"
: "fixture.Target.choose",
...(name === "get_xrefs_to_class"
? {}
: { overload_index: 1, reported_signature: "choose(int): void" }),
total: 0,
count: 0,
resolve_line: false,
items: [],
};
};
await analyzeJadxRequest(
{
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: (name, args) => {
calls.push({ name, ...args });
return Promise.resolve(reply(name));
},
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation,
input: { path: "/fixture.apk", class_name: "fixture.Target", ...input },
} as never,
{} as never,
engine,
);
expect(calls[0]).toEqual({
name: "get_class_summary",
class_name: "fixture.Target",
...(values ? { dex_static_values: true } : {}),
});
},
);
it("traces only the selected overloaded method and checks the producer identity", async () => {
const calls: Record<string, unknown>[] = [];
const tools: JadxToolPort = {
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: (name, input) => {
calls.push({ name, ...input });
if (name === "get_class_summary") return Promise.resolve(summary);
return Promise.resolve({
target: "fixture.Target.choose",
overload_index: 1,
reported_signature: "choose(int): void",
total: 1,
count: 1,
resolve_line: false,
items: [
{
kind: "method",
name: "caller",
full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
def_pos: 0,
direct_invokes: [
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
},
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 3,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
},
],
},
],
});
},
text: () => Promise.reject(new Error("unexpected text call")),
};
const request = {
operation: "trace_android_references",
input: androidInputSchemas.trace_android_references.parse({
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
overload_index: 1,
}),
} as const;
const result = await analyzeJadxRequest(tools, request, {} as never, engine);
expect(result).toMatchObject({
method_name: "choose",
overload_index: 1,
reported_signature: "choose(int): void",
dex_descriptor: "(I)V",
total: 1,
reference_unit: "using_node",
references: [
{
direct_invokes: [
{ dex_code_unit_offset: 0 },
{ dex_code_unit_offset: 3 },
],
},
],
});
expect(calls[1]).toMatchObject({
name: "get_xrefs_to_method",
overload_index: 1,
});
});
it("rejects an ambiguous method and a producer response for a different overload", async () => {
const xrefs = {
target: "fixture.Target.choose",
overload_index: 0,
reported_signature: "choose(String): void",
total: 0,
count: 0,
resolve_line: false,
items: [],
};
const tools: JadxToolPort = {
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: (name) =>
Promise.resolve(name === "get_class_summary" ? summary : xrefs),
text: () => Promise.reject(new Error("unexpected text call")),
};
const input = {
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
};
await expect(
analyzeJadxRequest(
tools,
{
operation: "trace_android_references",
input,
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisInputError",
});
await expect(
analyzeJadxRequest(
tools,
{
operation: "trace_android_references",
input: { ...input, overload_index: 1 },
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisOutputError",
});
});
const invokeSite = {
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
};
const traceWithReference = (fields: Record<string, unknown>) =>
analyzeJadxRequest(
{
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: (name) =>
Promise.resolve(
name === "get_class_summary"
? summary
: {
target: "fixture.Target.choose",
overload_index: 0,
reported_signature: "choose(String): void",
total: 1,
count: 1,
resolve_line: false,
items: [
{
kind: "method",
name: "caller",
full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
def_pos: 0,
...fields,
},
],
},
),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "trace_android_references",
input: {
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
overload_index: 0,
},
},
{} as never,
engine,
);
it.each([
[{}, null],
[{ direct_invokes: [] }, []],
])(
"preserves unavailable versus observed-empty invoke sites: %j",
async (fields, expected) => {
const result = await traceWithReference(fields as Record<string, unknown>);
expect(result).toMatchObject({
references: [
{ direct_invokes: expected, direct_invokes_exhaustive: null },
],
});
},
);
it.each([
{ ...invokeSite, dex_code_unit_offset: -1 },
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
},
])("rejects malformed direct invoke producer evidence: %j", async (site) => {
await expect(
traceWithReference({ direct_invokes: [site] }),
).rejects.toThrow();
});
it("preserves bounded instruction coverage separately from using-node coverage", async () => {
const result = await traceWithReference({
direct_invokes: [invokeSite],
direct_invokes_exhaustive: false,
});
expect(result).toMatchObject({
coverage: "complete",
reference_unit: "using_node",
references: [
{ direct_invokes: [invokeSite], direct_invokes_exhaustive: false },
],
});
});
it.each([
"not-a-dex-descriptor",
"(Lbad(name;)V",
"(Lbad)name;)V",
"()Lbad(name;",
"()Lbad)name;",
])(
"rejects malformed DEX prototype %s at the adapter boundary",
async (descriptor) => {
const invalidSummary = {
...summary,
methods: summary.methods.map((method) => ({
...method,
dex_descriptor: descriptor,
})),
};
await expect(
analyzeJadxRequest(
{
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: () => Promise.resolve(invalidSummary),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "inspect_android_class",
input: { path: "/fixture.apk", class_name: "fixture.Target" },
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisOutputError",
cause: {
issues: expect.arrayContaining([
expect.objectContaining({
path: ["methods", 0, "dex_descriptor"],
}),
]),
},
});
},
);