fix(android): preserve executable static initializer evidence (#1876)

Return the exact static initializer disassembly before Java projection can
lose field assignments. Retain byte limits and representation markers, and
verify fully folded and mixed initialization through the real CLI/MCP lane.

Closes #1846

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <[email protected]>
This commit is contained in:
Ysopwnable
2026-10-11 23:40:42 +08:00
committed by GitHub
co-authored by factory-droid[bot] Kaoru0822
parent ea40f717b2
commit efbc25fbea
10 changed files with 342 additions and 8 deletions
+16 -2
View File
@@ -126,6 +126,20 @@ class ReaJadxBridge {
return result;
}
private JadxSession.SmartCode methodBody(JadxSession current, ClassSnapshot cls,
MethodSnapshot selected, JavaMethod method) {
if (!selected.shortId().equals("<clinit>()V"))
return current.getMethodBodySmart(method, current.getMaxSourceBytes(), true);
// Java code generation can fold all or part of <clinit> into field declarations.
// Its method text cannot establish coverage, even when a static block remains.
JadxSession.MethodSmali smali = current.getMethodSmali(cls.node(), method.getName());
if (!smali.getFound() || smali.getBlocks().size() != 1)
throw new IllegalArgumentException("Static initializer disassembly requires one exact method: " + selected.shortId());
return new JadxSession.SmartCode(
TextUtilKt.truncateToBytes(smali.getBlocks().get(0), current.getMaxSourceBytes()),
"smali", true, List.of("static_initializer_disassembly"));
}
private CallToolResult handle(String name, JsonObject args) {
if (name.equals("rea_jvm_status")) return json(Map.of(
"engine_reported_version", BuildInfo.VERSION,
@@ -150,7 +164,7 @@ class ReaJadxBridge {
int index = integer(args, "overload_index", 0);
if (index >= overloads.size()) throw new InputFailure("overload_index", "overload_index out of range: " + index);
JavaMethod method = method(cls, overloads.get(index));
JadxSession.SmartCode code = current.getMethodBodySmart(method, current.getMaxSourceBytes(), true);
JadxSession.SmartCode code = methodBody(current, cls, overloads.get(index), method);
return json(Map.of("class_name", cls.summary().get("full_name"),
"method_name", overloads.get(index).name(), "full_name", method.getFullName(),
"overload_index", index, "overload_count", overloads.size(),
@@ -211,7 +225,7 @@ class ReaJadxBridge {
String description = switch (name) {
case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit.";
case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation.";
case "get_method_body" -> "Source for an exact class and stable same-name overload index, with smali fallback.";
case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization.";
default -> "Complete static references to an exact class or unique method; source lines are unresolved.";
};
Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties),
+6
View File
@@ -94,6 +94,12 @@ fallback for an overloaded method instead of attributing all bodies to one
selected overload. Native/abstract methods with no decompiled body report
`body_status: "not_available"` without inferring which implementation flag caused it.
Static initializers (`<clinit>`) return smali with the
`static_initializer_disassembly` marker. Java decompilation can move their
instructions into field declarations, leaving an empty or incomplete method
body. The selected method's disassembly preserves those instructions and retains
the same source byte limit and explicit truncation reporting.
## Interpret the result
- Package inspection retains decoded manifest XML, summary declarations,
+14 -3
View File
@@ -7,13 +7,16 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../../dist/domain/evidence.js";
import { hashAndroidFile } from "../../../dist/android/AndroidTargetSnapshot.js";
import { hashAndroidFile } from "./digest.mjs";
import { JADX_RELEASE } from "../../../dist/android/JadxRelease.js";
import fixture from "../../fixtures/android/apidemos.json" with { type: "json" };
import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs";
import { verifyAndroidMetadata } from "./metadata.mjs";
import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs";
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
import {
resolveJadxConfigurationInputs,
validateJadxConfiguration,
} from "../../../dist/android/JadxConfiguration.js";
import { verifyWindowsAndroidLifecycle } from "./windows-lifecycle.mjs";
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
@@ -35,10 +38,16 @@ assert.equal(
JADX_RELEASE.sha256,
"Real Android lane requires the audited headless JADX release JAR",
);
const { java, jvmArguments } = await resolveJadxConfiguration(
const configuration = await resolveJadxConfigurationInputs(
process.env,
"inspect_android_package",
);
await validateJadxConfiguration(
configuration,
process.env,
"inspect_android_package",
);
const { java, jvmArguments } = configuration;
const execute = promisify(execFile);
await execute(java, ["-version"], { timeout: 10_000 });
const entrypoint =
@@ -52,6 +61,8 @@ await verifyAndroidMetadata({
repository,
execute,
jvmArguments,
entrypoint,
environment,
bridge: resolve(dirname(entrypoint), "../bridge/android/ReaJadxBridge.java"),
});
const tasks = [
+9
View File
@@ -0,0 +1,9 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
/** Verify artifact identity without depending on private runtime snapshot helpers. */
export const hashAndroidFile = async (path) => {
const hash = createHash("sha256");
for await (const bytes of createReadStream(path)) hash.update(bytes);
return hash.digest("hex");
};
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../../dist/domain/evidence.js";
import { hashAndroidFile } from "./digest.mjs";
/** Verify folded field assignments and remaining static-block calls through both public adapters. */
export const verifyAndroidInitializers = async ({
apk,
repository,
entrypoint,
environment,
execute,
}) => {
const digest = await hashAndroidFile(apk);
const cases = [
{
class_name: "fixture.InitializationPolicy",
witnesses: [
"declared-policy",
"->singletonList",
"->POLICY:Ljava/util/List;",
],
},
{
class_name: "fixture.MixedInitialization",
witnesses: ["field-value", "->LABEL:Ljava/lang/String;", "->touch()V"],
},
];
const check = (value, scenario) => {
const evidence = parseEvidence(value);
assert.equal(evidence.subject.digest.sha256, digest);
const result = evidence.normalized_result;
assert.equal(result.class_name, scenario.class_name);
assert.equal(result.method.name, "<clinit>");
assert.equal(result.overload_count, 1);
assert.equal(result.body_status, "available");
assert.equal(result.representation, "smali");
assert.equal(result.fell_back, true);
assert.ok(result.markers.includes("static_initializer_disassembly"));
assert.equal(result.source.status, "complete");
for (const witness of scenario.witnesses)
assert.ok(
result.source.text.includes(witness),
`Missing initializer evidence: ${witness}`,
);
return evidence;
};
const cli = new Map();
for (const scenario of cases) {
const response = await execute(
process.execPath,
[
entrypoint,
"inspect-android-method",
apk,
scenario.class_name,
"<clinit>",
"--format",
"json",
],
{
cwd: repository,
env: environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
cli.set(scenario.class_name, check(JSON.parse(response.stdout), scenario));
}
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
const client = new Client({
name: "rea-real-android-initializers",
version: "1",
});
try {
await client.connect(transport, { timeout: 30_000 });
for (const scenario of cases) {
const response = await client.callTool(
{
name: "inspect_android_method",
arguments: {
path: apk,
class_name: scenario.class_name,
method_name: "<clinit>",
},
},
{ timeout: 150_000 },
);
assert.notEqual(response.isError, true, JSON.stringify(response));
const evidence = check(response.structuredContent, scenario);
assert.deepEqual(
evidence.normalized_result,
cli.get(scenario.class_name).normalized_result,
);
assert.deepEqual(
evidence.parameters,
cli.get(scenario.class_name).parameters,
);
assert.deepEqual(
evidence.provider,
cli.get(scenario.class_name).provider,
);
}
await client.ping();
} catch (cause) {
process.stderr.write(stderr);
throw cause;
} finally {
await client.close();
}
assert.equal(
await hashAndroidFile(apk),
digest,
"Static inspection changed fixture bytes",
);
console.log(
"PASS real CLI/MCP static initializer evidence, identity, parity and unchanged APK",
);
};
+19
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { verifyAndroidInitializers } from "./initializers.mjs";
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
export const verifyAndroidMetadata = async ({
@@ -11,6 +12,8 @@ export const verifyAndroidMetadata = async ({
bridge,
repository,
execute,
entrypoint,
environment,
jvmArguments = [],
}) => {
const javac =
@@ -55,6 +58,8 @@ export const verifyAndroidMetadata = async ({
`${classes}${delimiter}${jar}`,
"ReaJadxMetadataRegression",
apk,
resolve(repository, "tests/fixtures/android/static-initializers"),
classes,
],
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
);
@@ -62,9 +67,23 @@ export const verifyAndroidMetadata = async ({
result.stdout,
/METADATA_REGRESSION_PASS classes=\d+ overloads=3/u,
);
assert.match(
result.stdout,
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
);
console.log(
"PASS real cold metadata, full generic types and stable overloaded method selection",
);
console.log(
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
);
await verifyAndroidInitializers({
apk: resolve(classes, "initializers.apk"),
repository,
entrypoint,
environment,
execute,
});
} finally {
await rm(classes, { recursive: true, force: true });
}
+16 -3
View File
@@ -13,7 +13,10 @@ import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
import {
resolveJadxConfigurationInputs,
validateJadxConfiguration,
} from "../../../dist/android/JadxConfiguration.js";
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
export async function verifyWindowsAndroidLifecycle(options) {
@@ -36,7 +39,12 @@ export async function verifyWindowsAndroidLifecycle(options) {
}
async function verifyJavaSelection(environment) {
const selected = await resolveJadxConfiguration(
const selected = await resolveJadxConfigurationInputs(
environment,
"inspect_android_package",
);
await validateJadxConfiguration(
selected,
environment,
"inspect_android_package",
);
@@ -51,7 +59,12 @@ async function verifyJavaSelection(environment) {
// The owned junction borrows the selected JDK directory without copying it.
await symlink(dirname(selected.java), alias, "junction");
pathEnvironment.Path = `"${alias}"`;
const onPath = await resolveJadxConfiguration(
const onPath = await resolveJadxConfigurationInputs(
pathEnvironment,
"inspect_android_package",
);
await validateJadxConfiguration(
onPath,
pathEnvironment,
"inspect_android_package",
);
@@ -1,11 +1,16 @@
import com.atxx.jhmcp.*;
import com.android.tools.smali.smali.Smali;
import com.android.tools.smali.smali.SmaliOptions;
import com.google.gson.*;
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult;
import io.modelcontextprotocol.kotlin.sdk.types.TextContent;
import jadx.api.*;
import jadx.core.dex.nodes.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
import kotlin.coroutines.EmptyCoroutineContext;
import kotlinx.coroutines.BuildersKt;
@@ -37,6 +42,86 @@ public class ReaJadxMetadataRegression {
return value;
}
private static Path initializerFixture(String sources, String output) throws Exception {
Path directory = Path.of(output);
SmaliOptions options = new SmaliOptions();
options.apiLevel = 28;
options.jobs = 1;
options.outputDexFile = directory.resolve("initializers.dex").toString();
require(Smali.assemble(options, List.of(
Path.of(sources, "InitializationPolicy.smali").toString(),
Path.of(sources, "MixedInitialization.smali").toString())), "Initializer fixture assembly failed");
Path apk = directory.resolve("initializers.apk");
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) {
zip.putNextEntry(new ZipEntry("classes.dex"));
Files.copy(Path.of(options.outputDexFile), zip);
zip.closeEntry();
}
return apk;
}
private static JsonObject methodArguments(String className, String methodName) {
JsonObject value = arguments(className);
value.addProperty("method_name", methodName);
value.addProperty("overload_index", 0);
return value;
}
private static void verifyInitializers(SessionHolder holder, SessionConfig config,
Constructor<?> constructor, Method handle, Path apk) throws Exception {
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(apk.toString(), config, continuation));
Object bridge = constructor.newInstance(holder);
String[] classNames = { "fixture.InitializationPolicy", "fixture.MixedInitialization" };
String[][] witnesses = {
{ "declared-policy", "->singletonList", "->POLICY:Ljava/util/List;" },
{ "field-value", "->LABEL:Ljava/lang/String;", "->touch()V" }
};
for (int index = 0; index < classNames.length; index++) {
JsonObject summary = call(handle, bridge, "get_class_summary", arguments(classNames[index]));
JsonObject input = methodArguments(classNames[index], "<clinit>");
JsonObject body = call(handle, bridge, "get_method_body", input);
String text = body.get("body").getAsString();
for (String witness : witnesses[index]) require(text.contains(witness), "Missing initializer instruction: " + witness);
require(body.get("mode").getAsString().equals("smali"), "Static initializer must preserve DEX instructions");
require(body.get("fell_back").getAsBoolean(), "Disassembly fallback was not reported");
require(body.getAsJsonArray("markers").contains(new JsonPrimitive("static_initializer_disassembly")),
"Static initializer representation reason was not reported");
require(text.contains(".method static constructor <clinit>()V") && text.contains(".end method"),
"Initializer method boundaries missing");
require(body.get("overload_index").getAsInt() == 0 && body.get("overload_count").getAsInt() == 1,
"Initializer identity changed");
require(body.equals(call(handle, bridge, "get_method_body", input)), "Repeated initializer body changed");
require(summary.equals(call(handle, bridge, "get_class_summary", arguments(classNames[index]))),
"Frozen initializer metadata changed after source generation");
}
for (String name : List.of("missing", "originalNative")) {
JsonObject body = call(handle, bridge, "get_method_body", methodArguments(classNames[0], name));
String text = body.get("body").getAsString();
require(body.get("mode").getAsString().equals("java") && !text.contains("{") && !text.contains(".method"),
"Executable body was fabricated for " + name + ": " + text);
}
JsonObject ordinary = call(handle, bridge, "get_method_body", methodArguments(classNames[1], "touch"));
require(ordinary.get("mode").getAsString().equals("java") && ordinary.get("body").getAsString().contains("COUNT"),
"Ordinary executable method no longer produces Java");
SessionConfig bounded = new SessionConfig(96, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL);
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(apk.toString(), bounded, continuation));
JsonObject partial = call(handle, constructor.newInstance(holder), "get_method_body",
methodArguments(classNames[0], "<clinit>"));
require(partial.get("mode").getAsString().equals("smali")
&& partial.get("body").getAsString().contains("[truncated: exceeds 96 bytes, total "),
"Static initializer ignored the source byte limit or omitted its truncation marker");
String prefix = partial.get("body").getAsString().split("\n\n\\.\\.\\. \\[truncated:", 2)[0];
require(prefix.getBytes(StandardCharsets.UTF_8).length <= 96, "Initializer text exceeded its byte limit");
System.out.println("INITIALIZER_REGRESSION_PASS folded=2 bounded=true");
}
public static void main(String[] args) throws Exception {
SessionConfig config = new SessionConfig(1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL);
SessionHolder holder = new SessionHolder(config);
@@ -89,6 +174,7 @@ public class ReaJadxMetadataRegression {
"Frozen summary changed after code generation");
require(apiSummary.equals(call(handle, bridge, "get_class_summary", apiArgs)), "Unrelated frozen summary changed");
System.out.println("METADATA_REGRESSION_PASS classes=" + states.size() + " overloads=" + signatures.size());
verifyInitializers(holder, config, constructor, handle, initializerFixture(args[1], args[2]));
} finally {
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
@@ -0,0 +1,25 @@
.class public abstract Lfixture/InitializationPolicy;
.super Ljava/lang/Object;
.field public static final POLICY:Ljava/util/List;
.method static constructor <clinit>()V
.registers 1
const-string v0, "declared-policy"
invoke-static {v0}, Ljava/util/Collections;->singletonList(Ljava/lang/Object;)Ljava/util/List;
move-result-object v0
sput-object v0, Lfixture/InitializationPolicy;->POLICY:Ljava/util/List;
return-void
.end method
.method public constructor <init>()V
.registers 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public abstract missing()V
.end method
.method public native originalNative()V
.end method
@@ -0,0 +1,21 @@
.class public Lfixture/MixedInitialization;
.super Ljava/lang/Object;
.field public static final LABEL:Ljava/lang/String;
.field public static COUNT:I
.method static constructor <clinit>()V
.registers 1
const-string v0, "field-value"
sput-object v0, Lfixture/MixedInitialization;->LABEL:Ljava/lang/String;
invoke-static {}, Lfixture/MixedInitialization;->touch()V
return-void
.end method
.method public static touch()V
.registers 1
sget v0, Lfixture/MixedInitialization;->COUNT:I
add-int/lit8 v0, v0, 0x1
sput v0, Lfixture/MixedInitialization;->COUNT:I
return-void
.end method