From 139fd52477b0bc3205167abd219d6940988dffd7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:19:07 +0800 Subject: [PATCH] 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 --- docs/il2cpp-metadata.md | 36 +++++----- src/il2cpp/Il2cppBinaryFile.ts | 19 ++++++ .../boundary/il2cpp/il2cppBinaryFile.test.ts | 29 +++++++++ .../il2cpp/il2cppMetadataProvider.test.ts | 65 ++++++++++++++++++- 4 files changed, 132 insertions(+), 17 deletions(-) diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 06d67a500e..db25d25798 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -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. diff --git a/src/il2cpp/Il2cppBinaryFile.ts b/src/il2cpp/Il2cppBinaryFile.ts index d1c5f41fc9..f096e861db 100644 --- a/src/il2cpp/Il2cppBinaryFile.ts +++ b/src/il2cpp/Il2cppBinaryFile.ts @@ -90,6 +90,25 @@ export function* linkIl2cppBinarySteps( methodImage[type.method_start + method] = index; } } + const methodByImageAndRow = metadata.images.map( + () => new Map(), + ); + 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]!; diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index b384c2d0a2..bd54aa8f52 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -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([ diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index 4b9aac1041..915f3ebc42 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -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"),