From 9943c1c8bc35fef95632ff6b778bdbf35dd3cfab Mon Sep 17 00:00:00 2001 From: gensokyo-dev Date: Sat, 14 Nov 2026 14:42:13 +0000 Subject: [PATCH] fix(android): bound split facts and keep saved JNI reports readable Charge the manifest split facts against the native inventory's retained JSON allowance and refuse more than 64 split types or 256-character names as an unknown, so a crafted manifest cannot overflow the response. Reports saved before split facts existed still parse: the report schema treats absent facts as optional while the live bridge contract requires them. The JADX regression now decodes a generated binary manifest through nativeDeclarations, with named and resource-ID-only attributes. Refs #9756 Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- bridge/android/ReaJadxBridge.java | 19 +++-- src/domain/android/androidJniBindings.test.ts | 15 ++++ src/domain/android/androidJniBindings.ts | 7 +- .../android/androidNativeDeclarations.ts | 19 +++-- .../android/ReaJadxMetadataRegression.java | 80 ++++++++++++++++++- 5 files changed, 123 insertions(+), 17 deletions(-) diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index 5b09d967e8..e74395895b 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -204,6 +204,8 @@ class ReaJadxBridge { // response allowance accounts for worst-case JSON escaping before retention. private static final int NATIVE_DEX_BYTES = 64 * 1024 * 1024; private static final int NATIVE_MANIFEST_BYTES = 8 * 1024 * 1024; + private static final int NATIVE_SPLIT_TYPES = 64; + private static final int NATIVE_SPLIT_TYPE_CHARS = 256; private static final long NATIVE_TOTAL_DEX_BYTES = 256L * 1024 * 1024; // This JSON is escaped again inside an 8 MiB MCP frame. private static final long NATIVE_JSON_BYTES = 4L * 1024 * 1024; @@ -300,20 +302,25 @@ class ReaJadxBridge { .parse(new java.io.ByteArrayInputStream(bytes)).getCodeStr()); } catch (IOException | RuntimeException failure) { if (failure instanceof java.util.concurrent.CancellationException) throw (RuntimeException) failure; + String reason = "Root AndroidManifest.xml split requirements could not be read: " + + failure.getClass().getSimpleName() + + (failure.getMessage() == null ? "" : " (" + failure.getMessage() + ")"); return Map.of("status", "unknown", "reason", - "Root AndroidManifest.xml split requirements could not be read: " - + failure.getClass().getSimpleName() - + (failure.getMessage() == null ? "" : " (" + failure.getMessage() + ")")); + reason.length() > 512 ? reason.substring(0, 512) : reason); } } - /** Split requirements declared by decoded manifest text. */ + /** Split requirements declared by decoded manifest text, within a fixed report allowance. */ static Map manifestSplitFacts(String xml) { Element root = manifestRoot(xml); String android = "http://schemas.android.com/apk/res/android"; List types = new ArrayList<>(); for (String type : root.getAttributeNS(android, "requiredSplitTypes").split(",")) { - if (!type.isBlank()) types.add(type.strip()); + if (type.isBlank()) continue; + if (types.size() == NATIVE_SPLIT_TYPES || type.strip().length() > NATIVE_SPLIT_TYPE_CHARS) + throw new IllegalArgumentException("requiredSplitTypes exceeds the " + NATIVE_SPLIT_TYPES + + "-entry, " + NATIVE_SPLIT_TYPE_CHARS + "-character report allowance"); + types.add(type.strip()); } boolean required = false; for (Node child = root.getFirstChild(); child != null; child = child.getNextSibling()) { @@ -378,6 +385,8 @@ class ReaJadxBridge { // Before any early return, so every result states whether splits hold code. inventory.manifestSplits = manifestSplitRequirements(archive, names.getOrDefault("AndroidManifest.xml", 0)); + // Split facts share the retained JSON allowance; escaping is at most 6x. + inventory.retainedBytes += 64L + 6L * inventory.manifestSplits.toString().length(); long totalBytes = 0; int entryIndex = -1; directory = archive.entries(); diff --git a/src/domain/android/androidJniBindings.test.ts b/src/domain/android/androidJniBindings.test.ts index 8629f6b858..a2c85fa845 100644 --- a/src/domain/android/androidJniBindings.test.ts +++ b/src/domain/android/androidJniBindings.test.ts @@ -412,6 +412,21 @@ describe("split APK requirements", () => { }); }); + it("still reads a saved report from before split facts were recorded", () => { + const result = withSplits({ + status: "observed", + required_split_types: [], + split_required: false, + }); + if (!result.ok) throw new Error(JSON.stringify(result.error)); + const { manifest_splits: _omitted, ...native_inventory } = + result.value.native_inventory; + expect( + androidJniReportSchema.safeParse({ ...result.value, native_inventory }) + .success, + ).toBe(true); + }); + it("leaves an APK without required splits complete", () => { const result = withSplits({ status: "observed", diff --git a/src/domain/android/androidJniBindings.ts b/src/domain/android/androidJniBindings.ts index 669bec474c..e33cd5e3e5 100644 --- a/src/domain/android/androidJniBindings.ts +++ b/src/domain/android/androidJniBindings.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import { + androidManifestSplitsSchema, androidNativeDeclarationSchema, androidNativeDeclarationsSchema, type AndroidNativeDeclarations, @@ -146,7 +147,11 @@ export const androidJniBindingSchema = z.strictObject({ export const androidJniReportSchema = z.strictObject({ apk: z.strictObject({ path: z.string(), sha256: digest }), selection: androidJniSelectionSchema, - native_inventory: androidNativeDeclarationsSchema.omit({ methods: true }), + // Reports saved before the bridge read split facts omit them; live + // inventories always carry them. + native_inventory: androidNativeDeclarationsSchema + .omit({ methods: true }) + .extend({ manifest_splits: androidManifestSplitsSchema.exactOptional() }), library_inventory: androidJniLibraryInventorySchema, declarations: z.array(androidJniDeclarationSchema), selected_declaration_ids: z.array(z.string()), diff --git a/src/domain/android/androidNativeDeclarations.ts b/src/domain/android/androidNativeDeclarations.ts index 809f5e6382..41a53d12dc 100644 --- a/src/domain/android/androidNativeDeclarations.ts +++ b/src/domain/android/androidNativeDeclarations.ts @@ -18,6 +18,16 @@ export const androidNativeDeclarationSchema = z.strictObject({ method_index: count, }); +/** Split requirements the bridge reads from the root manifest, or why they are unknown. */ +export const androidManifestSplitsSchema = z.discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("observed"), + required_split_types: z.array(z.string().min(1)), + split_required: z.boolean(), + }), + z.strictObject({ status: z.literal("unknown"), reason: z.string().min(1) }), +]); + /** Scope is standard root classes*.dex members, never runtime-loaded code. */ export const androidNativeDeclarationsSchema = z.strictObject({ scope: z.literal("apk-root-dex-declarations"), @@ -31,14 +41,7 @@ export const androidNativeDeclarationsSchema = z.strictObject({ }), unknowns: z.array(z.string()), /** Root manifest split requirements; required splits can carry native libraries. */ - manifest_splits: z.discriminatedUnion("status", [ - z.strictObject({ - status: z.literal("observed"), - required_split_types: z.array(z.string().min(1)), - split_required: z.boolean(), - }), - z.strictObject({ status: z.literal("unknown"), reason: z.string().min(1) }), - ]), + manifest_splits: androidManifestSplitsSchema, }); export type AndroidNativeDeclarations = z.infer< diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index aa522f1432..6b0bdba59a 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -43,7 +43,7 @@ public class ReaJadxMetadataRegression { return ((TextContent) response.getContent().get(0)).getText(); } - private static void verifyManifestSummary() { + private static void verifyManifestSummary() throws Exception { for (String prefix : List.of("android", "a")) { for (String encoded : List.of("one & beta", "one &amp; beta")) { String xml = " split = ReaJadxBridge.manifestSplitFacts("" @@ -92,9 +92,83 @@ public class ReaJadxMetadataRegression { + " requiredSplitTypes=\"unnamespaced\">"); require(plain.get("required_split_types").equals(List.of()) && plain.get("split_required").equals(false), - "Split facts came from an unnamespaced or false declaration: " + plain); + "Split facts came from an unnamespaced or false declaration: " + plain); boolean refused = false; + try { + ReaJadxBridge.manifestSplitFacts(""); + } catch (IllegalArgumentException expected) { refused = true; } + require(refused, "An unbounded requiredSplitTypes list was admitted"); + // The bridge decodes the binary manifest itself, without a session; framework + // attribute names must resolve through their resource IDs when names are stripped. + for (boolean named : List.of(true, false)) { + Path apk = Files.createTempFile("rea-split-manifest-", ".apk"); + try { + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) { + zip.putNextEntry(new ZipEntry("AndroidManifest.xml")); + zip.write(splitManifest(named)); + zip.closeEntry(); + } + Object facts = ReaJadxBridge.nativeDeclarations(apk).get("manifest_splits"); + require(facts.equals(Map.of("status", "observed", + "required_split_types", List.of("base__abi", "base__density"), "split_required", true)), + "Binary manifest split facts were lost (named=" + named + "): " + facts); + } finally { + Files.deleteIfExists(apk); + } + } } + /** A binary manifest: requiredSplitTypes on , isSplitRequired on . */ + private static byte[] splitManifest(boolean namedAttributes) { + List strings = List.of("http://schemas.android.com/apk/res/android", "manifest", + namedAttributes ? "requiredSplitTypes" : "", "base__abi,base__density", "application", + namedAttributes ? "isSplitRequired" : "x"); + java.io.ByteArrayOutputStream data = new java.io.ByteArrayOutputStream(); + java.nio.ByteBuffer offsets = littleEndian(4 * strings.size()); + for (String value : strings) { + offsets.putInt(data.size()); + byte[] utf16 = value.getBytes(StandardCharsets.UTF_16LE); + data.writeBytes(littleEndian(2).putShort((short) value.length()).array()); + data.writeBytes(utf16); + data.writeBytes(new byte[2]); + } + byte[] pool = chunk(0x0001, 28, littleEndian(20 + offsets.capacity() + data.size()) + .putInt(strings.size()).putInt(0).putInt(0).putInt(28 + offsets.capacity()).putInt(0) + .put(offsets.array()).put(data.toByteArray()).array()); + java.nio.ByteBuffer ids = littleEndian(4 * 6); + for (int id : new int[] {0, 0, 0x0101064e, 0, 0, 0x01010591}) ids.putInt(id); + byte[] resources = chunk(0x0180, 8, ids.array()); + byte[] manifest = startElement(1, 2, 3, 0x03, 3); + byte[] application = startElement(4, 5, -1, 0x12, -1); + java.io.ByteArrayOutputStream body = new java.io.ByteArrayOutputStream(); + for (byte[] part : List.of(pool, resources, manifest, application, endElement(4), endElement(1))) + body.writeBytes(part); + return chunk(0x0003, 8, body.toByteArray()); + } + + private static java.nio.ByteBuffer littleEndian(int size) { + return java.nio.ByteBuffer.allocate(size).order(java.nio.ByteOrder.LITTLE_ENDIAN); + } + + private static byte[] chunk(int type, int headerSize, byte[] body) { + return littleEndian(8 + body.length).putShort((short) type).putShort((short) headerSize) + .putInt(8 + body.length).put(body).array(); + } + + /** One element with one android-namespaced attribute row. */ + private static byte[] startElement(int name, int attribute, int raw, int type, int value) { + return chunk(0x0102, 16, littleEndian(48).putInt(-1).putInt(-1).putInt(-1).putInt(name) + .putShort((short) 20).putShort((short) 20).putShort((short) 1).putShort((short) 0) + .putShort((short) 0).putShort((short) 0) + .putInt(0).putInt(attribute).putInt(raw).putShort((short) 8).put((byte) 0) + .put((byte) type).putInt(value).array()); + } + + private static byte[] endElement(int name) { + return chunk(0x0103, 16, littleEndian(16).putInt(-1).putInt(-1).putInt(-1).putInt(name).array()); + } + + private static void verifyRootManifests(SessionHolder holder, SessionConfig config, Constructor constructor, Method handle, Path output) throws Exception { verifyManifestSummary();