fix(il2cpp): preserve recognized truncated metadata prefixes

This commit is contained in:
morluto committed 2026-10-21 01:47:09 +08:00
1 parent eb7acc70bf
commit b3aadaf474
10 files changed
+273 -25

No files matched your search

+13 -10
View File
@@ -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).
+4 -3
View File
@@ -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")
+4 -1
View File
@@ -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",
+1 -1
View File
@@ -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),
};
};
+9 -4
View File
@@ -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",
+79 -1
View File
@@ -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");