fix(il2cpp): reject duplicate method tokens per image

Reject duplicate MethodDef row identities within one owning image before publishing native addresses or Ghidra seeds, while retaining cross-image reuse and folded code addresses.

Fixes #6610
This commit is contained in:
morluto
2026-10-23 19:19:07 +08:00
parent 72dd48a44c
commit 139fd52477
4 changed files with 132 additions and 17 deletions
+20 -16
View File
@@ -206,7 +206,11 @@ image, the single record whose name pointer reaches that image's name string and
whose count equals the image's method count. It then requires exactly one
`codeGenModulesCount`/`codeGenModules` pair that lists exactly those records. No
symbols are needed, so stripped builds work. A method's token row id selects its
slot in the module's method pointer array.
slot in the module's method pointer array. Within one image, each method must
claim a distinct MethodDef token row; duplicate rows are refused before any
linked method table or seed output is published. Row ids may repeat across
different images, and distinct rows may still resolve to the same address when
the binary uses identical-code folding.
Module records establish names through their declared NUL-terminated string pointers. NUL/0xFF padding, spaces, Unicode and suffix-shared storage are supported. A pointer to a longer string does not match just its suffix; a pointer directly into a shared suffix may name it. Exact reversed UTF-8 matching avoids rescanning the file for every name and retains duplicate-name owners until module records resolve them. A contributor investigated a Linux
`GameAssembly.so` from Unity 2023.2.13f1 whose static registration was not located. When no static pair is resolved on x86-64, the reader searches executable ranges for bytes consistent with RIP-relative LEA/MOV references to module-array candidates. Exactly one referenced array candidate must remain. `code_gen_modules_field` is then `null`, and Evidence is **inferred**: instruction boundaries, the absence of a static registration elsewhere, and runtime use were not verified. The module records and slots themselves remain file observations. Static-table associations are **derived**. Otherwise the result is `code-registration-not-found`.
@@ -269,21 +273,21 @@ Il2CppType byref flag in different bits, so a rendered name could gain or lose
The builds checked are listed after the module search rules above.
| Reason | Category | Meaning |
| --------------------------------- | ----------- | ------------------------------------------------------------------------------------------------- |
| `unsupported-container` | unsupported | Not a 64-bit little-endian Mach-O, PE or ELF image (ELF: AArch64 or x86-64 with dynamic section). |
| `slice-selection-required` | unsupported | A universal Mach-O was given without `binary_architecture`. |
| `slice-not-found` | unsupported | The requested slice is absent, or a thin binary was given an architecture. |
| `unsupported-pointer-encoding` | unsupported | Mach-O chained fixups or Android packed (APS2) ELF relocations; pointers not decoded. |
| `malformed-container` | input error | The container headers are damaged. |
| `code-gen-modules-not-found` | input error | An image has no module record; often a binary from a different build. |
| `ambiguous-code-gen-module` | input error | More than one module record matches an image. |
| `code-registration-not-found` | input error | No unique module table or executable-byte array candidate was resolved. |
| `invalid-method-token` | input error | A method token is not a method row inside its module's pointer count. |
| `invalid-method-pointer` | input error | A slot is unavailable or its nonzero address lacks unambiguous executable file coverage. |
| `metadata-registration-not-found` | input error | No single MetadataRegistration matches the type-definition count. |
| `invalid-type` | input error | A type index or `Il2CppType` record is out of range, unmapped or malformed. |
| `unsupported-type-kind` | unsupported | A valid type kind has no implemented rendering. |
| Reason | Category | Meaning |
| --------------------------------- | ----------- | --------------------------------------------------------------------------------------------------- |
| `unsupported-container` | unsupported | Not a 64-bit little-endian Mach-O, PE or ELF image (ELF: AArch64 or x86-64 with dynamic section). |
| `slice-selection-required` | unsupported | A universal Mach-O was given without `binary_architecture`. |
| `slice-not-found` | unsupported | The requested slice is absent, or a thin binary was given an architecture. |
| `unsupported-pointer-encoding` | unsupported | Mach-O chained fixups or Android packed (APS2) ELF relocations; pointers not decoded. |
| `malformed-container` | input error | The container headers are damaged. |
| `code-gen-modules-not-found` | input error | An image has no module record; often a binary from a different build. |
| `ambiguous-code-gen-module` | input error | More than one module record matches an image. |
| `code-registration-not-found` | input error | No unique module table or executable-byte array candidate was resolved. |
| `invalid-method-token` | input error | A token is outside its module's method-pointer rows, or a MethodDef row is reused within one image. |
| `invalid-method-pointer` | input error | A slot is unavailable or its nonzero address lacks unambiguous executable file coverage. |
| `metadata-registration-not-found` | input error | No single MetadataRegistration matches the type-definition count. |
| `invalid-type` | input error | A type index or `Il2CppType` record is out of range, unmapped or malformed. |
| `unsupported-type-kind` | unsupported | A valid type kind has no implemented rendering. |
Format and selection failures name `binary_path`; capacity refusals remain typed resource errors. Failed linking preserves the metadata source identity, validated counts, selected binary path/architecture and its SHA-256 when observed, plus prior producer observations. It never publishes an incomplete native table as complete. The binary input is bounded to1GiB.
+19
View File
@@ -90,6 +90,25 @@ export function* linkIl2cppBinarySteps(
methodImage[type.method_start + method] = index;
}
}
const methodByImageAndRow = metadata.images.map(
() => new Map<number, number>(),
);
for (let index = 0; index < methodImage.length; index++) {
if (index % LINK_BATCH === 0) yield;
const owner = methodImage[index]!;
if (owner === -1) continue;
const token = facts.methodTokens[index]!;
const row = token & 0xffffff;
if (token >>> 24 !== 0x06 || row === 0) continue;
const methodsByRow = methodByImageAndRow[owner]!;
const earlier = methodsByRow.get(row);
if (earlier !== undefined)
throw new Il2cppBinaryFormatFailure(
"invalid-method-token",
`methods ${earlier} and ${index} in image ${metadata.images[owner]!.name} share MethodDef token 0x${token.toString(16)}`,
);
methodsByRow.set(row, index);
}
yield;
const methodAddress = (index: number): Il2cppMethodCode => {
const owner = methodImage[index]!;
@@ -76,6 +76,35 @@ it("links methods to code through module tokens, not metadata order", () => {
);
});
it("rejects reused MethodDef tokens within an image but preserves cross-image reuse and folded addresses", () => {
const duplicateTokens = Uint32Array.from(facts.methodTokens);
duplicateTokens[1] = duplicateTokens[0]!;
expect(() =>
linkIl2cppBinary(createIl2cppBinaryFixture(), metadata, {
...facts,
methodTokens: duplicateTokens,
}),
).toThrow(
expect.objectContaining({
reason: "invalid-method-token",
kind: "malformed",
}),
);
// The fixture deliberately reuses MethodDef row ids in its second image.
expect(link(createIl2cppBinaryFixture()).method_addresses).toEqual(
expectedAddresses,
);
// Distinct rows may still point at the same native address after folding.
const folded = createIl2cppBinaryFixture();
folded.writeBigUInt64LE(folded.readBigUInt64LE(0x308), 0x300);
const linkedFolded = link(folded);
expect(linkedFolded.method_addresses[0]?.address).toBe(
linkedFolded.method_addresses[1]?.address,
);
});
it("requires an explicit slice of a universal Mach-O and reads only that slice", () => {
const thin = createIl2cppBinaryFixture();
const universal = createUniversalFixture([
@@ -1,15 +1,19 @@
import { setImmediate } from "node:timers/promises";
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { Il2cppMetadataProvider } from "../../../src/il2cpp/Il2cppMetadataProvider.js";
import { Il2cppGhidraSeedWriter } from "../../../src/il2cpp/Il2cppGhidraSeedWriter.js";
import { Il2cppMetadataService } from "../../../src/application/il2cpp/Il2cppMetadataService.js";
import { il2cppMetadataSchema } from "../../../src/domain/il2cpp/il2cppMetadata.js";
import {
createElfFixture,
createIl2cppBinaryFixture,
@@ -548,6 +552,65 @@ it("accepts cancellation between binary linking batches and keeps the metadata s
}
});
it("refuses duplicate MethodDef tokens before publishing linked Evidence or Ghidra seeds", async () => {
const root = await createTestTempDirectory(
"rea-il2cpp-duplicate-method-token-",
);
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "GameAssembly.dylib");
const duplicateSeeds = join(root, "duplicate.tsv");
const foldedSeeds = join(root, "folded.tsv");
const fixture = createIl2cppMetadataFixture({ version: 31 });
const duplicate = Buffer.from(fixture.bytes);
const firstTokenAt = fixture.record("methods", 0) + 24;
const secondTokenAt = fixture.record("methods", 1) + 24;
duplicate.writeUInt32LE(duplicate.readUInt32LE(firstTokenAt), secondTokenAt);
await writeFile(path, duplicate);
await writeFile(binaryPath, createIl2cppBinaryFixture());
const service = new Il2cppMetadataService(
new Il2cppMetadataProvider(),
new Il2cppGhidraSeedWriter(),
);
try {
const refused = await service.inspectWithGhidraSeeds(
{ path, binary_path: binaryPath, detail: "complete" },
{ path: duplicateSeeds },
);
expect(refused.ok).toBe(false);
if (refused.ok)
throw new Error("Duplicate MethodDef identity was accepted");
if (!(refused.error instanceof AnalysisInputError))
throw new Error("Duplicate MethodDef identity was not an input refusal");
expect(refused.error.issues[0]?.message).toContain("invalid-method-token");
expect(refused.error.issues[0]?.message).toContain("share MethodDef token");
expect(refused.error.partialObservation).toMatchObject({
normalized_result: { artifact: { path } },
});
await expect(readFile(duplicateSeeds)).rejects.toMatchObject({
code: "ENOENT",
});
const foldedBinary = createIl2cppBinaryFixture();
foldedBinary.writeBigUInt64LE(foldedBinary.readBigUInt64LE(0x308), 0x300);
await writeFile(path, fixture.bytes);
await writeFile(binaryPath, foldedBinary);
const accepted = await service.inspectWithGhidraSeeds(
{ path, binary_path: binaryPath, detail: "complete" },
{ path: foldedSeeds },
);
expect(accepted.ok).toBe(true);
if (!accepted.ok) throw accepted.error;
const result = il2cppMetadataSchema.parse(accepted.value.normalized_result);
expect(result.method_addresses[0]?.address).toBe(
result.method_addresses[1]?.address,
);
expect(await readFile(foldedSeeds, "utf8")).toContain("# folded");
} finally {
await service.close();
}
});
it("retains both observed file identities and the selected architecture when native linking fails", async () => {
const root = await createTestTempDirectory("rea-il2cpp-failed-pair-");
const path = join(root, "global-metadata.dat"),