mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
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>
This commit is contained in:
1 parent
18b72ca461
commit
9943c1c8bc
5 files changed
+123
-17
No files matched your search
@@ -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<String, Object> manifestSplitFacts(String xml) {
|
||||
Element root = manifestRoot(xml);
|
||||
String android = "http://schemas.android.com/apk/res/android";
|
||||
List<String> 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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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 = "<manifest xmlns:" + prefix + "=\"http://schemas.android.com/apk/res/android\""
|
||||
@@ -81,7 +81,7 @@ public class ReaJadxMetadataRegression {
|
||||
verifyManifestSplitFacts();
|
||||
}
|
||||
|
||||
private static void verifyManifestSplitFacts() {
|
||||
private static void verifyManifestSplitFacts() throws Exception {
|
||||
String android = "xmlns:a=\"http://schemas.android.com/apk/res/android\"";
|
||||
Map<String, Object> split = ReaJadxBridge.manifestSplitFacts("<manifest " + android
|
||||
+ " a:requiredSplitTypes=\"base__abi, base__density\"><application a:isSplitRequired=\"true\"/>"
|
||||
@@ -92,9 +92,83 @@ public class ReaJadxMetadataRegression {
|
||||
+ " requiredSplitTypes=\"unnamespaced\"><application a:isSplitRequired=\"false\"/></manifest>");
|
||||
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("<manifest " + android + " a:requiredSplitTypes=\""
|
||||
+ String.join(",", Collections.nCopies(65, "s")) + "\"/>");
|
||||
} 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 <manifest>, isSplitRequired on <application>. */
|
||||
private static byte[] splitManifest(boolean namedAttributes) {
|
||||
List<String> 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();
|
||||
|
||||
Reference in new issue
Block a user