mirror of
https://github.com/morluto/rea.git
synced 2026-10-21 21:32:26 -05:00
fix(il2cpp): preserve recognized truncated metadata prefixes
This commit is contained in:
1 parent
eb7acc70bf
commit
b3aadaf474
10 files changed
+273
-25
No files matched your search
@@ -91,17 +91,20 @@ same bundle. Export writes only the caller-selected output file.
|
||||
|
||||
## Facts and uncertainty
|
||||
|
||||
| Family | Evidence used |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| .NET | A PE optional header declares a nonempty CLI directory. |
|
||||
| Unity Mono | A managed assembly below `*_Data/Managed` or `Contents/Resources/Data/Managed` has an exact UnityEngine assembly reference. |
|
||||
| Unity IL2CPP | An IL2CPP metadata header coexists with a conventionally named native `GameAssembly.dll`, `GameAssembly.so`, `GameAssembly.dylib` or `libil2cpp.so` image. Build correspondence remains unknown. |
|
||||
| Godot | A `GDPC` header at file start or a validated executable trailer offset. |
|
||||
| GameMaker | A bounded `FORM` container starts with a `GEN8` chunk. |
|
||||
| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. |
|
||||
| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. |
|
||||
| Family | Evidence used |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| .NET | A PE optional header declares a nonempty CLI directory. |
|
||||
| Unity Mono | A managed assembly below `*_Data/Managed` or `Contents/Resources/Data/Managed` has an exact UnityEngine assembly reference. |
|
||||
| Unity IL2CPP | A recognized IL2CPP metadata magic (with the raw version when complete) coexists with a conventionally named native `GameAssembly.dll`, `GameAssembly.so`, `GameAssembly.dylib` or `libil2cpp.so` image. Build correspondence remains unknown. |
|
||||
| Godot | A `GDPC` header at file start or a validated executable trailer offset. |
|
||||
| GameMaker | A bounded `FORM` container starts with a `GEN8` chunk. |
|
||||
| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. |
|
||||
| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. |
|
||||
|
||||
An observed metadata header with supported raw version 29, 31 or 39 offers an
|
||||
When the four-byte metadata magic is present but version bytes are incomplete,
|
||||
the fact retains `raw_version: null`; it does not offer an IL2CPP metadata
|
||||
inspection probe. A complete magic/version prefix with supported raw version 29,
|
||||
31 or 39 offers an
|
||||
`inspect_il2cpp_metadata` follow-up at its admitted file path, including when no
|
||||
native library was found. This probe does not establish full metadata validity
|
||||
or correspondence to a native binary. See [IL2CPP metadata](il2cpp-metadata.md).
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { DesktopFile, DesktopFact } from "../domain/desktopApplication.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import { IL2CPP_METADATA_MAGIC } from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js";
|
||||
import { parsePeRecord, peRvaToFileOffset } from "../domain/binaryTarget.js";
|
||||
import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js";
|
||||
import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js";
|
||||
@@ -98,13 +98,14 @@ export const inspectDesktopFile = async (
|
||||
row.facts.push(observation);
|
||||
};
|
||||
const prefix = await read(0, Math.min(size, 64));
|
||||
if (prefix.length >= 8 && prefix.readUInt32LE(0) === IL2CPP_METADATA_MAGIC)
|
||||
const metadataPrefix = observeIl2cppMetadataHeaderPrefix(prefix);
|
||||
if (metadataPrefix.kind === "il2cpp")
|
||||
fact({
|
||||
kind: "il2cpp-metadata-header",
|
||||
offset: 0,
|
||||
value: {
|
||||
magic: "0xfab11baf",
|
||||
raw_version: prefix.readUInt32LE(4),
|
||||
raw_version: metadataPrefix.rawVersion,
|
||||
},
|
||||
});
|
||||
if (prefix.length >= 20 && prefix.toString("latin1", 0, 4) === "GDPC")
|
||||
|
||||
@@ -56,7 +56,10 @@ const desktopFactSchema = z.discriminatedUnion("kind", [
|
||||
),
|
||||
fact(
|
||||
"il2cpp-metadata-header",
|
||||
z.strictObject({ magic: z.literal("0xfab11baf"), raw_version: byteOffset }),
|
||||
z.strictObject({
|
||||
magic: z.literal("0xfab11baf"),
|
||||
raw_version: byteOffset.nullable(),
|
||||
}),
|
||||
),
|
||||
fact(
|
||||
"godot-pck-header",
|
||||
|
||||
@@ -87,7 +87,7 @@ export const projectDesktopApplication = (
|
||||
candidate(
|
||||
"unity-il2cpp",
|
||||
[...metadata, ...il2cpp],
|
||||
"IL2CPP metadata headers and conventionally named native libraries coexist; their build correspondence is unverified.",
|
||||
"IL2CPP metadata magic and conventionally named native libraries coexist; metadata validity and build correspondence are unverified.",
|
||||
"inferred",
|
||||
);
|
||||
for (const file of il2cpp)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { IL2CPP_METADATA_MAGIC } from "./il2cppMetadata.js";
|
||||
|
||||
/** Parsed identity facts available before the complete IL2CPP header exists. */
|
||||
export type Il2cppMetadataHeaderPrefix =
|
||||
| {
|
||||
readonly kind: "unavailable-magic";
|
||||
readonly magic: null;
|
||||
readonly rawVersion: null;
|
||||
readonly version: null;
|
||||
}
|
||||
| {
|
||||
readonly kind: "other-magic";
|
||||
readonly magic: number;
|
||||
readonly rawVersion: null;
|
||||
readonly version: null;
|
||||
}
|
||||
| {
|
||||
readonly kind: "il2cpp";
|
||||
readonly magic: typeof IL2CPP_METADATA_MAGIC;
|
||||
/** Unsigned bytes as stored; null means fewer than eight prefix bytes. */
|
||||
readonly rawVersion: number | null;
|
||||
/** The decoder's signed Int32 interpretation; null means unavailable. */
|
||||
readonly version: number | null;
|
||||
};
|
||||
|
||||
/** Parse the shared magic/version prefix once, retaining incomplete identity explicitly. */
|
||||
export const observeIl2cppMetadataHeaderPrefix = (
|
||||
bytes: Uint8Array,
|
||||
): Il2cppMetadataHeaderPrefix => {
|
||||
if (bytes.byteLength < 4)
|
||||
return {
|
||||
kind: "unavailable-magic",
|
||||
magic: null,
|
||||
rawVersion: null,
|
||||
version: null,
|
||||
};
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const magic = view.getUint32(0, true);
|
||||
if (magic !== IL2CPP_METADATA_MAGIC)
|
||||
return {
|
||||
kind: "other-magic",
|
||||
magic,
|
||||
rawVersion: null,
|
||||
version: null,
|
||||
};
|
||||
if (bytes.byteLength < 8)
|
||||
return {
|
||||
kind: "il2cpp",
|
||||
magic: IL2CPP_METADATA_MAGIC,
|
||||
rawVersion: null,
|
||||
version: null,
|
||||
};
|
||||
return {
|
||||
kind: "il2cpp",
|
||||
magic: IL2CPP_METADATA_MAGIC,
|
||||
rawVersion: view.getUint32(4, true),
|
||||
version: view.getInt32(4, true),
|
||||
};
|
||||
};
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
Il2cppMetadataFormatFailure,
|
||||
} from "./Il2cppMetadataFormat.js";
|
||||
import {
|
||||
IL2CPP_METADATA_MAGIC,
|
||||
IL2CPP_METADATA_VERSIONS,
|
||||
il2cppMetadataSectionNames,
|
||||
type Il2cppMetadata,
|
||||
type Il2cppMetadataSection,
|
||||
type IL2cppMetadataSectionName,
|
||||
} from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js";
|
||||
import {
|
||||
readIl2cppMembers,
|
||||
readIl2cppMemberTables,
|
||||
@@ -129,13 +129,18 @@ const isIndexWidth = (value: number): value is IndexWidth =>
|
||||
value === 1 || value === 2 || value === 4;
|
||||
|
||||
const readHeader = (bytes: Buffer) => {
|
||||
const magic = bytes.length < 4 ? null : bytes.readUInt32LE(0);
|
||||
if (bytes.length < 8 || magic !== IL2CPP_METADATA_MAGIC)
|
||||
const prefix = observeIl2cppMetadataHeaderPrefix(bytes);
|
||||
if (prefix.kind !== "il2cpp")
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unrecognized-magic",
|
||||
"the file does not start with the IL2CPP global-metadata magic 0xFAB11BAF; encrypted or non-metadata inputs are not decoded",
|
||||
);
|
||||
const version = bytes.readInt32LE(4);
|
||||
if (prefix.version === null)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"truncated-header",
|
||||
`the file has ${bytes.length} bytes, fewer than the 8-byte magic/version prefix; the metadata version is unknown`,
|
||||
);
|
||||
const { magic, version } = prefix;
|
||||
if (!isSupportedVersion(version))
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unsupported-version",
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
il2cppMetadataSummaryViewResultSchema,
|
||||
} from "../../src/domain/analysisView/analysisView.js";
|
||||
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
parseEvidenceBundle,
|
||||
} from "../../src/domain/evidenceBundle.js";
|
||||
import { jsonObjectSchema } from "../../src/domain/jsonValue.js";
|
||||
import { il2cppMetadataSchema } from "../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
import {
|
||||
@@ -221,6 +224,81 @@ cliTest(
|
||||
30000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports a recognized IL2CPP magic with an incomplete version as corrupt truncation through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-prefix-fail-");
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
|
||||
for (const length of [4, 5, 6, 7]) {
|
||||
const path = join(root, `magic-${length}.dat`);
|
||||
const bytes = Buffer.alloc(length);
|
||||
bytes.writeUInt32LE(0xfab11baf, 0);
|
||||
await writeFile(path, bytes);
|
||||
|
||||
const expected = {
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
issues: [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: expect.stringContaining(
|
||||
"truncated-header: the file has",
|
||||
),
|
||||
},
|
||||
],
|
||||
partial_observation: {
|
||||
normalized_result: {
|
||||
artifact: {
|
||||
path,
|
||||
bytes: length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
},
|
||||
inventory_status: "not-returned-as-complete",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-il2cpp-metadata", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject(expected);
|
||||
|
||||
const response = await call("inspect_il2cpp_metadata", { path });
|
||||
const projected = parseMcpToolError(response).error;
|
||||
expect(projected).toMatchObject({
|
||||
...expected,
|
||||
details: {
|
||||
...expected.details,
|
||||
partial_observation: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: expect.any(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
const reference = projected.details?.partial_observation as {
|
||||
evidence_id: string;
|
||||
};
|
||||
const bundle = parseEvidenceBundle(
|
||||
toolContract("get_evidence_bundle").outputSchema.parse(
|
||||
(await call("get_evidence_bundle", { detail: "complete" }))
|
||||
.structuredContent,
|
||||
).result,
|
||||
);
|
||||
expect(
|
||||
bundle.records.find(
|
||||
({ evidence_id }) => evidence_id === reference.evidence_id,
|
||||
),
|
||||
).toMatchObject(expected.details.partial_observation);
|
||||
}
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
const metadataPageReader =
|
||||
(
|
||||
call: Awaited<ReturnType<typeof connectLocalToolsMcp>>["call"],
|
||||
|
||||
@@ -1181,8 +1181,8 @@ it("preserves admitted Unicode and escaped AssemblyRef names in complete Evidenc
|
||||
);
|
||||
});
|
||||
|
||||
it.each([27, 28, 31, 106])(
|
||||
"suggests metadata-only inspection for supported raw version %s",
|
||||
it.each([27, 28, 31, 106, 0xffffffff])(
|
||||
"preserves raw metadata version %s and suggests inspection only when supported",
|
||||
async (version) => {
|
||||
const root = await createTestTempDirectory("rea-desktop-metadata-probe-");
|
||||
const bytes = Buffer.alloc(8);
|
||||
@@ -1209,3 +1209,26 @@ it.each([27, 28, 31, 106])(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([4, 5, 6, 7])(
|
||||
"retains IL2CPP magic with an unknown version for a %i-byte prefix",
|
||||
async (length) => {
|
||||
const root = await createTestTempDirectory("rea-desktop-metadata-prefix-");
|
||||
const bytes = Buffer.alloc(length);
|
||||
bytes.writeUInt32LE(0xfab11baf);
|
||||
await put(root, "global-metadata.dat", bytes);
|
||||
const { report } = await inspect(root);
|
||||
const metadata = report.files.find((file) =>
|
||||
file.path.endsWith("global-metadata.dat"),
|
||||
);
|
||||
|
||||
expect(metadata?.facts).toContainEqual({
|
||||
kind: "il2cpp-metadata-header",
|
||||
offset: 0,
|
||||
value: { magic: "0xfab11baf", raw_version: null },
|
||||
});
|
||||
expect(report.next_probes).not.toContainEqual(
|
||||
expect.objectContaining({ operation: "inspect_il2cpp_metadata" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
il2cppIndexWidth,
|
||||
il2cppMetadataRecordBytes,
|
||||
readIl2cppMetadata,
|
||||
readIl2cppMetadataForLinking,
|
||||
readIl2cppMetadataSteps,
|
||||
} from "../../../src/il2cpp/Il2cppMetadataFile.js";
|
||||
import {
|
||||
@@ -24,9 +25,14 @@ import {
|
||||
type Il2cppMetadataFixture,
|
||||
} from "../../fixtures/il2cpp/metadata.js";
|
||||
|
||||
const failure = (bytes: Buffer) => {
|
||||
const failure = (
|
||||
bytes: Buffer,
|
||||
reader:
|
||||
| typeof readIl2cppMetadata
|
||||
| typeof readIl2cppMetadataForLinking = readIl2cppMetadata,
|
||||
) => {
|
||||
try {
|
||||
readIl2cppMetadata(bytes);
|
||||
reader(bytes);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Il2cppMetadataFormatFailure) return cause;
|
||||
throw cause;
|
||||
@@ -161,7 +167,8 @@ describe("rejected IL2CPP global-metadata", () => {
|
||||
"unrecognized-magic",
|
||||
"unsupported",
|
||||
);
|
||||
rejects(Buffer.alloc(4), "unrecognized-magic", "unsupported");
|
||||
for (const length of [4, 5, 6, 7])
|
||||
rejects(Buffer.alloc(length), "unrecognized-magic", "unsupported");
|
||||
rejects(
|
||||
mutated((_, bytes) => bytes.writeInt32LE(30, 4)),
|
||||
"unsupported-version",
|
||||
@@ -169,6 +176,40 @@ describe("rejected IL2CPP global-metadata", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the decoder's signed interpretation of a complete high-bit version", () => {
|
||||
const bytes = Buffer.alloc(8);
|
||||
bytes.writeUInt32LE(0xfab11baf, 0);
|
||||
bytes.writeUInt32LE(0xffffffff, 4);
|
||||
const rejected = failure(bytes);
|
||||
expect(rejected.reason).toBe("unsupported-version");
|
||||
expect(rejected.kind).toBe("unsupported");
|
||||
expect(rejected.message).toContain("metadata version -1 is not supported");
|
||||
});
|
||||
|
||||
it.each([4, 5, 6, 7])(
|
||||
"retains recognized magic and reports a %i-byte version prefix as truncated",
|
||||
(length) => {
|
||||
const bytes = Buffer.alloc(length);
|
||||
bytes.writeUInt32LE(0xfab11baf, 0);
|
||||
const rejected = failure(bytes);
|
||||
expect(rejected.reason).toBe("truncated-header");
|
||||
expect(rejected.kind).toBe("corrupt");
|
||||
expect(rejected.message).toContain(
|
||||
"fewer than the 8-byte magic/version prefix",
|
||||
);
|
||||
expect(rejected.message).toContain("metadata version is unknown");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the same truncated-prefix classification before native linking", () => {
|
||||
const bytes = Buffer.alloc(7);
|
||||
bytes.writeUInt32LE(0xfab11baf, 0);
|
||||
const rejected = failure(bytes, readIl2cppMetadataForLinking);
|
||||
expect(rejected.reason).toBe("truncated-header");
|
||||
expect(rejected.kind).toBe("corrupt");
|
||||
expect(rejected.message).toContain("metadata version is unknown");
|
||||
});
|
||||
|
||||
it("rejects a truncated header and an unexpected header size", () => {
|
||||
rejects(
|
||||
createIl2cppMetadataFixture().bytes.subarray(0, 200),
|
||||
|
||||
@@ -352,6 +352,41 @@ it("retains the exact empty source observation without claiming a decoded invent
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the exact source when recognized magic has no complete version", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-short-prefix-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const bytes = Buffer.alloc(7);
|
||||
bytes.writeUInt32LE(0xfab11baf, 0);
|
||||
await writeFile(path, bytes);
|
||||
const result = await inspectMetadata(path);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected truncated metadata prefix");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: expect.stringMatching(
|
||||
/truncated-header:.*metadata version is unknown/u,
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "observe_il2cpp_metadata_source",
|
||||
normalized_result: {
|
||||
artifact: {
|
||||
path,
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
},
|
||||
inventory_status: "not-returned-as-complete",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the metadata source observation when the selected binary does not link", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-binary-failure-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
|
||||
Reference in new issue
Block a user