diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index d4326aff0f..0b89ddf7a0 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -241,7 +241,7 @@ record addresses remain distinct candidates, and conflicting file mappings remain malformed. 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`. +`GameAssembly.so` from Unity 2023.2.13f1 whose static registration was not located. When no static pair is resolved on x86-64 or arm64, the reader searches executable ranges for bytes consistent with a reference to a module-array candidate: RIP-relative LEA/MOV on x86-64, an adjacent `adrp`/`add` pair on arm64. 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`. Cpp2IL's StrippedCodeRegSupport plugin also recovers the module list of an inlined CodeRegistration from the module names; this fallback additionally requires the exact located module set and a single code reference. A Unity 2022.3.62f3 macOS Master build checked by a contributor keeps neither registration symbol; its type table is then inferred as described below. The binary/metadata pair is caller-selected. Name/count/token agreement is structural evidence, not a cryptographic same-build proof. File-offset locations refer to the primary metadata artifact; linked virtual addresses refer to `binary.artifact`. Both SHA-256 identities remain available in compact native pages as `artifact` and `binary_source`. @@ -299,6 +299,30 @@ The result adds: `Il2CppMetadataRegistration.typesCount` and its value. That record is found as the single place whose `fieldOffsetsCount` and `typeDefinitionsSizesCount` both equal the metadata type-definition count and whose type table is file mapped. +Linking reads only that table. When no record on x86-64 or arm64 has both of +those counts, the reader collects maximal runs of pointers to Il2CppType-shaped +records (an assigned Il2CppTypeEnum kind from 0x01 to 0x1e; class and +value-type data below the type-definition count) that are long enough for every +type index the metadata references. Exactly one such run may exist in the +image, it must start and end inside its mapping (a run touching either end may +continue in a neighbouring mapping), and its start must be loaded by the same +instruction forms as the module-array fallback. `types_field` is then `null`, +`types_count` is that run's length rather than an observed `typesCount`, and +Evidence is **inferred**. The run length is at least every referenced type +index but can differ from `typesCount`: a type-shaped pointer after the table +would lengthen it, and an unreadable slot (such as an unresolved relocation) +would end it early. Only the type indices the metadata references are read. A +second long-enough run anywhere, a run that a type-shaped neighbour extends +before the loaded address, a run touching a mapping end or a slot the loader +binds or leaves unresolved (its stored word is only a placeholder), and a covered +128-byte record whose two counts match but whose other fields are unusable all +leave `metadata-registration-not-found` rather than a guess; the search stops +at the second run. The length requirement only separates the type table from +other pointer runs when the metadata references most of it: on the one Master +build checked, the highest referenced index is 6,031 of 6,576 entries. A +metadata referencing few type indices usually +leaves several short runs and fails closed. + Counts use their declared 32-bit width independently of alignment padding. The complete 128-byte registration must have unambiguous loader coverage. Its used fields through byte 112 must be file-backed; a trailing declared zero-fill @@ -354,7 +378,8 @@ the result has no rows, and `generic_method_pointers_field`, `generic_method_pointers_count` and `generic_method_table_stride` are `null`. A table of a few rows can still leave two layouts agreeing, which fails as ambiguous rather than picking one. Metadata versions 24 and 39 and builds -without a static CodeRegistration (`code_gen_modules_field` `null`) fail as +without a static CodeRegistration (`code_gen_modules_field` `null`) or +MetadataRegistration (`types_field` `null`) fail as `generic-methods-unsupported`: for version 24 the type-definition generic container offset and the generic class layout have not been checked against a real build, so it is refused rather than guessed. @@ -419,10 +444,10 @@ The builds checked are listed after the module search rules above. | `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. | +| `metadata-registration-not-found` | input error | No single MetadataRegistration or code-loaded type-table candidate was resolved. | | `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. | -| `generic-methods-unsupported` | unsupported | Generic instances were requested for metadata version 24 or 39 or a build without a static CodeRegistration. | +| `generic-methods-unsupported` | unsupported | Generic instances were requested for metadata version 24 or 39, or a build lacks either static registration. | | `generic-method-registration-not-found` | input error | No CodeRegistration layout bounds every genericMethodTable row, or the generic tables are unreadable. | | `ambiguous-generic-method-registration` | input error | More than one CodeRegistration layout bounds every genericMethodTable row. | diff --git a/src/application/il2cpp/Il2cppMetadataService.test.ts b/src/application/il2cpp/Il2cppMetadataService.test.ts index 6ec65a5704..696fcc158f 100644 --- a/src/application/il2cpp/Il2cppMetadataService.test.ts +++ b/src/application/il2cpp/Il2cppMetadataService.test.ts @@ -261,13 +261,17 @@ it("closes both ports and keeps combined cleanup incomplete until both owners re metadata.resolveCleanup(); expect(failure.cleanupIncomplete).toBe(false); }); -it.each([false, true])( - "classifies native association confidence (inferred: %s)", - async (inferred) => { +it.each([ + ["static", null, "derived"], + ["module array", "code_gen_modules_field", "inferred"], + ["type table", "types_field", "inferred"], +] as const)( + "classifies native association confidence (%s)", + async (_, inferred, confidence) => { const value = linkedReport(); - if (inferred) value.binary!.code_gen_modules_field = null; + if (inferred !== null) value.binary![inferred] = null; const result = await serviceReturning(value).inspect(selectedPair); if (!result.ok) throw result.error; - expect(result.value.confidence).toBe(inferred ? "inferred" : "derived"); + expect(result.value.confidence).toBe(confidence); }, ); diff --git a/src/application/il2cpp/Il2cppMetadataService.ts b/src/application/il2cpp/Il2cppMetadataService.ts index 59ecc52f57..76cdae05f2 100644 --- a/src/application/il2cpp/Il2cppMetadataService.ts +++ b/src/application/il2cpp/Il2cppMetadataService.ts @@ -240,7 +240,8 @@ export class Il2cppMetadataService { confidence: value.binary === null ? "observed" - : value.binary.code_gen_modules_field === null + : value.binary.code_gen_modules_field === null || + value.binary.types_field === null ? "inferred" : "derived", limitations: value.limitations, diff --git a/src/contracts/il2cpp/il2cppToolContracts.ts b/src/contracts/il2cpp/il2cppToolContracts.ts index 975fa74df1..d0da88d605 100644 --- a/src/contracts/il2cpp/il2cppToolContracts.ts +++ b/src/contracts/il2cpp/il2cppToolContracts.ts @@ -16,7 +16,7 @@ export const IL2CPP_TOOL_CONTRACTS = [ kind: "artifact-provider", requiresActiveTarget: true, description: - "Inspect local Unity IL2CPP metadata (24/27/29/31/39), optionally linking a caller-selected 64-bit little-endian Mach-O, PE or ELF binary. Static reading only. Default summary retains complete Evidence; use parent_evidence_id with inspect_analysis_view kind il2cpp-metadata for named collections, indices, fields and paging. Parameters accept method_indices; method_addresses accept addresses and states. Stable row indices are scoped to the metadata digest; native pages also identify the binary. Native states distinguish executable code, observed zero slots (no-body), and unknown owners/relocations. Shared addresses preserve all methods. code_gen_modules pages accept image_indices; summaries omit that table. Type names are C#-like; version27 retains indices with unknown names. generic_instances adds genericMethodTable rows with Il2CppDumper-style names (versions 27/29/31, static CodeRegistration); generic_method_instances pages accept method_indices, addresses and states; summaries count issues, paged as generic_method_issues. Unrequested native counts are null. Static-table links are derived; the x86-64 executable-byte fallback is inferred and does not verify instruction boundaries. Input guards:256MiB metadata/1GiB binary;64MiB encoded report; one active inspection. Unsupported layouts, pointer encodings and resource limits have typed reasons. Full Evidence remains available with detail complete. Neither static linking nor structural agreement proves runtime behavior or a same-build pair.", + "Inspect local Unity IL2CPP metadata (24/27/29/31/39), optionally linking a caller-selected 64-bit little-endian Mach-O, PE or ELF binary. Static reading only. Default summary retains complete Evidence; use parent_evidence_id with inspect_analysis_view kind il2cpp-metadata for named collections, indices, fields and paging. Parameters accept method_indices; method_addresses accept addresses and states. Stable row indices are scoped to the metadata digest; native pages also identify the binary. Native states distinguish executable code, observed zero slots (no-body), and unknown owners/relocations. Shared addresses preserve all methods. code_gen_modules pages accept image_indices; summaries omit that table. Type names are C#-like; version27 retains indices with unknown names. generic_instances adds genericMethodTable rows with Il2CppDumper-style names (versions 27/29/31, static CodeRegistration and MetadataRegistration); generic_method_instances pages accept method_indices, addresses and states; summaries count issues, paged as generic_method_issues. Unrequested native counts are null. Static-table links are derived; the x86-64/arm64 executable-byte fallback is inferred and does not verify instruction boundaries. Input guards:256MiB metadata/1GiB binary;64MiB encoded report; one active inspection. Unsupported layouts, pointer encodings and resource limits have typed reasons. Full Evidence remains available with detail complete. Neither static linking nor structural agreement proves runtime behavior or a same-build pair.", inputSchema: inspectIl2cppMetadataInputSchema, outputSchema: evidenceResultOf( z.union([il2cppMetadataSchema, il2cppMetadataSummaryViewResultSchema]), diff --git a/src/domain/analysisView/il2cppMetadataView.test.ts b/src/domain/analysisView/il2cppMetadataView.test.ts index 2aa3263ee2..1ac3c4f01f 100644 --- a/src/domain/analysisView/il2cppMetadataView.test.ts +++ b/src/domain/analysisView/il2cppMetadataView.test.ts @@ -554,6 +554,24 @@ it("keeps native summaries bounded while paging selected module facts", () => { }); }); +it.each([ + [ + "code_gen_modules_field", + "Module-array association", + "Type-table association", + ], + ["types_field", "Type-table association", "Module-array association"], +] as const)( + "keeps an inferred %s as its own unknown", + (field, present, absent) => { + const value = linkedReport(); + value.binary![field] = null; + const unknowns = summarize(parent(value, false)).unknowns.map(String); + expect(unknowns.filter((line) => line.startsWith(present))).toHaveLength(1); + expect(unknowns.some((line) => line.startsWith(absent))).toBe(false); + }, +); + const genericLinked = (): Il2cppMetadata => { const linked = linkedReport(); return { diff --git a/src/domain/analysisView/il2cppMetadataView.ts b/src/domain/analysisView/il2cppMetadataView.ts index 7f1db7831e..e18cbeebc2 100644 --- a/src/domain/analysisView/il2cppMetadataView.ts +++ b/src/domain/analysisView/il2cppMetadataView.ts @@ -606,6 +606,11 @@ export const projectIl2cppMetadataView = ( "Module-array association is inferred from executable byte patterns; instruction boundaries and runtime use remain unverified.", ] : []), + ...(report.binary != null && report.binary.types_field === null + ? [ + "Type-table association and types_count are inferred from executable byte patterns and Il2CppType-shaped records; instruction boundaries and runtime use remain unverified.", + ] + : []), ...[ ...il2cppMetadataMemberCollections, ...il2cppMetadataBinaryCollections, diff --git a/src/domain/il2cpp/il2cppMetadata.ts b/src/domain/il2cpp/il2cppMetadata.ts index f589877bfe..5d21a659f4 100644 --- a/src/domain/il2cpp/il2cppMetadata.ts +++ b/src/domain/il2cpp/il2cppMetadata.ts @@ -288,8 +288,12 @@ export const il2cppBinaryLinkSchema = z.strictObject({ /** The code-generation module pointer array. */ code_gen_modules_array: il2cppAddressSchema, code_gen_modules: z.array(il2cppCodeGenModuleSchema), - /** MetadataRegistration's typesCount field, followed by the types pointer. */ - types_field: il2cppAddressSchema, + /** + * MetadataRegistration's typesCount field, followed by the types pointer; + * null when no static registration had the counts and executable byte-pattern + * candidates identified the type table, whose run length is then types_count. + */ + types_field: il2cppAddressSchema.nullable(), types_count: count, /** Null unless generic_instances was requested. */ generic_methods: il2cppGenericMethodLinkSchema.nullable(), diff --git a/src/il2cpp/Il2cppBinaryFile.ts b/src/il2cpp/Il2cppBinaryFile.ts index da738ce63b..0bcd18d74c 100644 --- a/src/il2cpp/Il2cppBinaryFile.ts +++ b/src/il2cpp/Il2cppBinaryFile.ts @@ -45,8 +45,13 @@ export interface Il2cppBinaryLink { * slot is zero, "unknown" when its owner or relocated pointer is unresolved. */ readonly method_addresses: Il2cppMethodAddress[]; - /** Address of MetadataRegistration's typesCount field. */ - readonly types_field: bigint; + /** + * Address of MetadataRegistration's typesCount field, or null when no static + * registration matched and an executable byte-pattern candidate identified + * the type table; types_count is then the inferred run length, at least + * every referenced type index but not an observed typesCount. + */ + readonly types_field: bigint | null; readonly types_count: number; /** Il2CppType table index and display name per method return, field and parameter. */ readonly return_types: Il2cppTypeReference[]; @@ -170,6 +175,7 @@ export function* linkIl2cppBinarySteps( const registration = yield* findMetadataRegistration( image, metadata.types.length, + yield* referencedTypeCount(facts), ); const typeName = createTypeNamer( image, @@ -222,7 +228,7 @@ export function* linkIl2cppBinarySteps( facts, registration_.field, // findMetadataRegistration reports typesCount, word 6. - registration.field - 48n, + registration.field === null ? null : registration.field - 48n, options, ) : null; @@ -243,6 +249,23 @@ export function* linkIl2cppBinarySteps( }; } +/** One past the highest type index any return, field or parameter references. */ +function* referencedTypeCount( + facts: Il2cppMetadataLinkFacts, +): Generator { + let count = 0; + for (const types of [ + facts.returnTypes, + facts.fieldTypes, + facts.parameterTypes, + ]) + for (let index = 0; index < types.length; index++) { + if (index % LINK_BATCH === 0) yield; + count = Math.max(count, types[index]! + 1); + } + return count; +} + /** Rows between cooperative yields while linking. */ const LINK_BATCH = 256; diff --git a/src/il2cpp/Il2cppBinaryGenerics.ts b/src/il2cpp/Il2cppBinaryGenerics.ts index 7b286bb31d..bf3b2582da 100644 --- a/src/il2cpp/Il2cppBinaryGenerics.ts +++ b/src/il2cpp/Il2cppBinaryGenerics.ts @@ -103,7 +103,8 @@ export function* linkGenericMethodsSteps( metadata: Il2cppMetadataDecoded, facts: Il2cppMetadataLinkFacts, modulesField: bigint | null, - metadataRegistration: bigint, + /** Null when the type table was inferred without a static MetadataRegistration. */ + metadataRegistration: bigint | null, options: Il2cppBinaryReadOptions, ): Generator { const version = metadata.metadata_version; @@ -113,6 +114,11 @@ export function* linkGenericMethodsSteps( "generic-methods-unsupported", "this binary keeps no static CodeRegistration, so genericMethodPointers cannot be located", ); + if (metadataRegistration === null) + throw new Il2cppBinaryFormatFailure( + "generic-methods-unsupported", + "this binary keeps no static MetadataRegistration, so genericMethodTable cannot be located", + ); const word = (address: bigint) => readPointer(image, address); const count = (address: bigint) => { const value = word(address); diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index 9716549f2c..e8ff9fd516 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -6,9 +6,11 @@ import { binaryMetadataArchitecture } from "../domain/binaryMetadataArchitecture import { BinaryFormatFailure, BinaryResourceFailure, + createFileMappingIndex, mappedFileOffset, isZeroFilledSpan, type BinaryContainer, + type FileMappingIndex, } from "../native/metadata/BinaryContainer.js"; import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; import { @@ -74,6 +76,8 @@ const selectImage = ( export interface NativeImage { readonly bytes: Buffer; readonly container: BinaryContainer; + /** One image's immutable load mappings, shared by stored and loader-aware reads. */ + readonly fileMappings: FileMappingIndex; readonly imageBase: bigint; readonly unresolvedPointerSlots: ReadonlyMap; readonly zeroFillPointers: ReadonlyMap; @@ -190,6 +194,10 @@ export function* openImageSteps( return { bytes: image, container, + fileMappings: createFileMappingIndex( + container.mappings, + container.zero_fills, + ), imageBase, unresolvedPointerSlots: relocated?.unresolved ?? new Map(), zeroFillPointers: relocated?.zeroFillPointers ?? new Map(), @@ -200,12 +208,7 @@ export function* openImageSteps( /** Stored bytes only; loader interpretation belongs to readSlot. */ const readStoredWord = (image: NativeImage, address: bigint): bigint | null => { try { - const offset = mappedFileOffset( - image.container.mappings, - address, - 8, - image.container.zero_fills, - ); + const offset = image.fileMappings.offset(address, 8); return image.bytes.readBigUInt64LE(offset); } catch (cause) { if (cause instanceof BinaryFormatFailure) return null; @@ -226,14 +229,7 @@ export const readUInt32 = ( const virtual = image.zeroFillPointers.get(address); if (virtual !== undefined) return Number(virtual & 0xffffffffn); try { - return image.bytes.readUInt32LE( - mappedFileOffset( - image.container.mappings, - address, - 4, - image.container.zero_fills, - ), - ); + return image.bytes.readUInt32LE(image.fileMappings.offset(address, 4)); } catch (cause) { if (cause instanceof BinaryFormatFailure) return null; throw cause; @@ -245,12 +241,7 @@ export const hexAddress = (value: bigint) => `0x${value.toString(16)}`; export const isMapped = (image: NativeImage, address: bigint, size: number) => { try { - mappedFileOffset( - image.container.mappings, - address, - size, - image.container.zero_fills, - ); + image.fileMappings.offset(address, size); return true; } catch (cause) { if (cause instanceof BinaryFormatFailure) return false; @@ -302,12 +293,7 @@ export const hasPointerArrayCoverage = ( if (fileEnd === cursor) { if (!isZeroFilled(image, cursor, size)) return false; } else { - mappedFileOffset( - image.container.mappings, - cursor, - size, - image.container.zero_fills, - ); + image.fileMappings.offset(cursor, size); } } catch (cause) { if (cause instanceof BinaryFormatFailure) return false; @@ -321,12 +307,7 @@ export const hasPointerArrayCoverage = ( /** Both executable coverage and the unique full loader mapping must agree. */ export const isExecutable = (image: NativeImage, address: bigint): boolean => { try { - const offset = mappedFileOffset( - image.container.mappings, - address, - 1, - image.container.zero_fills, - ); + const offset = image.fileMappings.offset(address, 1); return ( offset === mappedFileOffset(image.container.executable_mappings ?? [], address, 1) @@ -359,8 +340,12 @@ export const readSlot = ( const virtual = image.zeroFillPointers.get(address); let raw: bigint | null; if (virtual !== undefined) raw = virtual; - else if (isZeroFilled(image, address, 8)) raw = 0n; - else raw = readStoredWord(image, address); + else { + // A successful indexed read already excludes zero-fill aliases. Only a + // non-file-backed slot needs the zero-fill ownership walk. + raw = readStoredWord(image, address); + if (raw === null && isZeroFilled(image, address, 8)) raw = 0n; + } if (raw === null) return null; if (image.machoPointerFixups !== undefined) { const resolution = image.machoPointerFixups.decode(address, raw); diff --git a/src/il2cpp/Il2cppBinaryRegistration.ts b/src/il2cpp/Il2cppBinaryRegistration.ts index 02a98caa68..ed14a452e3 100644 --- a/src/il2cpp/Il2cppBinaryRegistration.ts +++ b/src/il2cpp/Il2cppBinaryRegistration.ts @@ -7,10 +7,16 @@ import { isMapped, hasPointerArrayCoverage, readPointer, + readSlot, readUInt32, type NativeImage, } from "./Il2cppBinaryImage.js"; import type { Il2cppMetadataDecoded } from "./Il2cppMetadataFile.js"; +import { + IL2CPP_TYPE_KIND_MAX, + il2cppTypeKind, + isDefinitionTypeKind, +} from "./Il2cppBinaryTypes.js"; import type { Il2cppCodeGenModule } from "./Il2cppBinaryFile.js"; // Bound the retained name-address index independently of binary size. No // repeated name is evidence of ambiguous module ownership by itself. @@ -245,7 +251,11 @@ export function* findCodeRegistration( } } } - if (matches.length === 0 && image.container.architecture === "amd64") { + const architecture = image.container.architecture; + if ( + matches.length === 0 && + (architecture === "amd64" || architecture === "arm64") + ) { const array = yield* findModuleArrayByCode(image, expected); if (array !== null) return { field: null, array }; } @@ -258,12 +268,16 @@ export function* findCodeRegistration( } /** - * Some x86-64 builds (a Unity 2023.2 Linux GameAssembly.so) keep no static - * CodeRegistration: the compiler folded the module array and its count into - * the one loop that walks it, so no data word points at the array. Find every + * Some builds keep no static CodeRegistration: the compiler folded the module + * array and its count into the one loop that walks it, so no data word points + * at the array. Seen on x86-64 (a Unity 2023.2 Linux GameAssembly.so) and on + * arm64 (a Unity 2022.3 macOS Master/LTO GameAssembly.dylib). Find every * pointer array that lists exactly the located modules, then require exactly - * one of them to be the target of a RIP-relative `lea`/`mov` (REX.W 8D/8B with - * a disp32 operand). Anything else stays code-registration-not-found. + * one of them to be the target of an address-forming instruction in an + * executable range: on x86-64 a RIP-relative `lea`/`mov` (REX.W 8D/8B with a + * disp32 operand), on arm64 an `adrp` immediately followed by an `add` of its + * page offset to the same register. Anything else stays + * code-registration-not-found. */ const MODULE_ARRAY_CANDIDATES = 64; @@ -303,6 +317,17 @@ function* findModuleArrayByCode( } } if (arrays.size === 0) return null; + const referenced = + image.container.architecture === "arm64" + ? yield* arm64PageReferences(image, arrays) + : yield* ripRelativeReferences(image, arrays); + return referenced.size === 1 ? [...referenced][0]! : null; +} + +function* ripRelativeReferences( + image: NativeImage, + arrays: ReadonlySet, +): Generator> { const referenced = new Set(); for (const mapping of image.container.executable_mappings ?? []) { const bytes = image.bytes; @@ -319,7 +344,44 @@ function* findModuleArrayByCode( if (arrays.has(target)) referenced.add(target); } } - return referenced.size === 1 ? [...referenced][0]! : null; + return referenced; +} + +/** + * arm64 instructions are 4-byte aligned words, so only aligned addresses are + * decoded. `adrp xd, page` forms the 4 KiB page of pc plus a signed 21-bit + * page delta; the next word must be `add xd, xd, #imm12` (64-bit, unshifted), + * which adds the offset within that page. Pairs split by other instructions or + * loads through `ldr` are not followed. + */ +function* arm64PageReferences( + image: NativeImage, + arrays: ReadonlySet, +): Generator> { + const referenced = new Set(); + const bytes = image.bytes; + for (const mapping of image.container.executable_mappings ?? []) { + const start = Number((4n - (mapping.address % 4n)) % 4n); + for (let at = start; at + 8 <= mapping.size; at += 4) { + if ((at & 0xffff) < 4) yield; + const adrp = bytes.readUInt32LE(mapping.offset + at); + if ((adrp & 0x9f000000) >>> 0 !== 0x90000000) continue; + const add = bytes.readUInt32LE(mapping.offset + at + 4); + if ((add & 0xffc00000) >>> 0 !== 0x91000000) continue; + const register = adrp & 0x1f; + // Register 31 is xzr for ADRP and sp for ADD, so no address is formed. + if (register === 31) continue; + if ((add & 0x1f) !== register || ((add >>> 5) & 0x1f) !== register) + continue; + const pages = + (((((adrp >>> 5) & 0x7ffff) << 2) | ((adrp >>> 29) & 3)) << 11) >> 11; + const pc = mapping.address + BigInt(at); + const target = + (pc & ~0xfffn) + (BigInt(pages) << 12n) + BigInt((add >>> 10) & 0xfff); + if (arrays.has(target)) referenced.add(target); + } + } + return referenced; } /** @@ -327,20 +389,26 @@ function* findModuleArrayByCode( * fieldOffsetsCount and typeDefinitionsSizesCount (words 10 and 12) both equal * the metadata type-definition count and are followed by mapped pointers. * typesCount and types are words 6 and 7. Counts are int32 in 8-byte slots. + * Linking reads only the type table, so when no static registration matches on + * x86-64 or arm64 the table is inferred from code instead (field null). */ export function* findMetadataRegistration( image: NativeImage, typeDefinitions: number, + requiredTypes: number, ): Generator< void, { - readonly field: bigint; + readonly field: bigint | null; readonly count: number; readonly types: bigint; } > { const matches: { field: bigint; count: number; types: bigint }[] = []; const matchedFields = new Set(); + // A record whose two counts match is static evidence even when its other + // fields are unusable; code is then not asked to stand in for it. + let countsMatched = false; const low = (offset: number) => image.bytes.readUInt32LE(offset); for (const mapping of image.container.mappings) { const start = Number((8n - (mapping.address % 8n)) % 8n); @@ -355,6 +423,7 @@ export function* findMetadataRegistration( if (!hasPointerArrayCoverage(image, address, 16)) continue; if (readUInt32(image, address + 80n) !== typeDefinitions) continue; if (readUInt32(image, address + 96n) !== typeDefinitions) continue; + countsMatched = true; const count = readUInt32(image, address + 48n); const types = readPointer(image, address + 56n); const fieldOffsets = readPointer(image, address + 88n); @@ -387,10 +456,181 @@ export function* findMetadataRegistration( }); } } + const architecture = image.container.architecture; + let inferred = false; + if ( + !countsMatched && + requiredTypes > 0 && + (architecture === "amd64" || architecture === "arm64") + ) { + inferred = true; + const table = yield* findTypeTableByCode( + image, + typeDefinitions, + requiredTypes, + ); + if (table !== null) return { field: null, ...table }; + } if (matches.length !== 1) throw new Il2cppBinaryFormatFailure( "metadata-registration-not-found", - `${matches.length} MetadataRegistration candidates count ${typeDefinitions} type definitions`, + countsMatched && matches.length === 0 + ? `a MetadataRegistration counts ${typeDefinitions} type definitions, but its type table, field offsets or type sizes are not mapped` + : inferred + ? `no MetadataRegistration counts ${typeDefinitions} type definitions, and no single code-loaded run of Il2CppType pointers covers every referenced type index` + : `${matches.length} MetadataRegistration candidates count ${typeDefinitions} type definitions`, ); return matches[0]!; } + +/** + * An inlined MetadataRegistration leaves the Il2CppType pointer table loaded + * directly by code. Candidates are maximal runs of pointers to Il2CppType-shaped + * records (a defined Il2CppTypeEnum kind; class and value-type data below the + * metadata type-definition count) long enough for every type index the + * metadata references. Exactly one such run may exist in the image, it must + * end inside its mapping, and code must load its start with the same + * instruction forms as the module-array fallback; anything else fails closed. + * The run length becomes the count: never below what the metadata needs, but + * not an observed typesCount. + */ +/** Il2CppTypeEnum values 0x01 to 0x1e; 0x17 and 0x1a are unassigned. */ +const isDefinedTypeKind = (kind: number) => + kind >= 1 && kind <= IL2CPP_TYPE_KIND_MAX && kind !== 0x17 && kind !== 0x1a; + +function* findTypeTableByCode( + image: NativeImage, + typeDefinitions: number, + requiredTypes: number, +): Generator { + const { bytes, container } = image; + // Nomination and loader-aware confirmation share the image's mapping index. + // Most words are not record addresses; reject those without the throwing + // lookup. Starts are sorted, and reach[i] is the furthest end among the + // first i + 1 mappings, so one binary search finds a possible container. + const spans = container.mappings + .filter((mapping) => mapping.size > 0) + .map((mapping) => ({ + start: mapping.address, + end: mapping.address + BigInt(mapping.size), + })) + .sort((left, right) => + left.start < right.start ? -1 : left.start > right.start ? 1 : 0, + ); + const reach: bigint[] = []; + for (const [index, span] of spans.entries()) + reach.push( + index === 0 || span.end > reach[index - 1]! + ? span.end + : reach[index - 1]!, + ); + const maybeMapped = (address: bigint) => { + let lowIndex = 0; + let highIndex = spans.length - 1; + let found = -1; + while (lowIndex <= highIndex) { + const middle = (lowIndex + highIndex) >> 1; + if (spans[middle]!.start <= address) { + found = middle; + lowIndex = middle + 1; + } else highIndex = middle - 1; + } + return found >= 0 && reach[found]! >= address + 12n; + }; + const recordOffset = (address: bigint) => { + if (!maybeMapped(address)) return -1; + try { + return image.fileMappings.offset(address, 12); + } catch (cause) { + // Unmapped, zero-filled or ambiguously mapped records are not types. + if (cause instanceof BinaryFormatFailure) return -1; + throw cause; + } + }; + const isType = (address: bigint) => { + // Records hold an 8-byte pointer first; zero and unaligned words are not types. + if (address === 0n || (address & 7n) !== 0n) return false; + const offset = recordOffset(address); + if (offset < 0) return false; + const kind = il2cppTypeKind(bytes.readUInt32LE(offset + 8)); + if (!isDefinedTypeKind(kind)) return false; + if (!isDefinitionTypeKind(kind)) return true; + return bytes.readBigUInt64LE(offset) < BigInt(typeDefinitions); + }; + // A run that touches either end of its mapping may continue in a + // neighbouring one, so its length and start are unknown; it still counts as + // a candidate but cannot be chosen. + const runs = new Map(); + // Stored words only nominate slots: a slot the loader binds or leaves + // unresolved has no known value, so a run it bounds has an unknown extent + // whatever its placeholder holds. + // The same holds for a record's kind word and its definition index. + const recordState = (target: bigint): "type" | "other" | "unknown" => { + if (recordOffset(target) < 0) return "other"; + const attributes = readUInt32(image, target + 8n); + if (attributes === "unknown") return "unknown"; + if (attributes === null) return "other"; + const kind = il2cppTypeKind(attributes); + if (!isDefinedTypeKind(kind)) return "other"; + if (!isDefinitionTypeKind(kind)) return "type"; + const data = readSlot(image, target); + if (data === "unknown") return "unknown"; + return typeof data === "bigint" && data < BigInt(typeDefinitions) + ? "type" + : "other"; + }; + // A word whose slot or record the loader leaves unknown bounds a run with + // an unknown extent. + const unresolved = (address: bigint) => { + const slot = readSlot(image, address); + return ( + slot === "unknown" || + (typeof slot === "bigint" && recordState(slot) === "unknown") + ); + }; + for (const mapping of container.mappings) { + const start = Number((8n - (mapping.address % 8n)) % 8n); + let runStart = -1; + let startUnknown = false; + for (let at = start; at <= mapping.size; at += 8) { + if ((at & 0xffff) < 8) yield; + const address = mapping.address + BigInt(at); + let endUnknown = at + 8 > mapping.size; + if (!endUnknown) { + const raw = bytes.readBigUInt64LE(mapping.offset + at); + if (isType(raw)) { + const slot = readSlot(image, address); + const state = slot === raw ? recordState(slot) : "other"; + if (state === "type") { + if (runStart < 0) { + runStart = at; + startUnknown = at === start || unresolved(address - 8n); + } + continue; + } + endUnknown = slot === "unknown" || state === "unknown"; + } else if (runStart >= 0) endUnknown = unresolved(address); + } + if (runStart >= 0 && (at - runStart) / 8 >= requiredTypes) { + runs.set( + mapping.address + BigInt(runStart), + endUnknown || startUnknown ? null : (at - runStart) / 8, + ); + // Another long-enough run could be the real table while code loads + // only this one, so a second candidate fails closed at once. + if (runs.size > 1) return null; + } + runStart = -1; + } + } + if (runs.size !== 1) return null; + const [start, count] = [...runs][0]!; + if (count === null) return null; + const starts = new Set([start]); + const referenced = + container.architecture === "arm64" + ? yield* arm64PageReferences(image, starts) + : yield* ripRelativeReferences(image, starts); + if (!referenced.has(start)) return null; + return { count, types: start }; +} diff --git a/src/il2cpp/Il2cppBinaryTypes.ts b/src/il2cpp/Il2cppBinaryTypes.ts index c29b05d9dc..57af891a6f 100644 --- a/src/il2cpp/Il2cppBinaryTypes.ts +++ b/src/il2cpp/Il2cppBinaryTypes.ts @@ -9,6 +9,16 @@ import type { Il2cppMetadataDecoded, Il2cppMetadataLinkFacts, } from "./Il2cppMetadataFile.js"; + +/** Il2CppType's type enum: bits 16-23 of the word after its 8-byte data. */ +export const il2cppTypeKind = (attributes: number): number => + (attributes >>> 16) & 0xff; +/** Largest Il2CppTypeEnum value read as a type kind (IL2CPP_TYPE_MVAR). */ +export const IL2CPP_TYPE_KIND_MAX = 0x1e; +/** Class and value-type kinds, whose data is a TypeDefinitionIndex. */ +export const isDefinitionTypeKind = (kind: number): boolean => + kind === 0x11 || kind === 0x12; + const PRIMITIVES = new Map([ [0x01, "void"], [0x02, "bool"], @@ -127,12 +137,12 @@ const createTypeRenderer = ( if (tick()) yield; const data = pointer(address, "Il2CppType data"); const bits = u32(address + 8n, "Il2CppType attributes"); - const kind = (bits >>> 16) & 0xff; + const kind = il2cppTypeKind(bits); const index = Number(BigInt.asIntN(32, data & 0xffffffffn)); let result: string; const primitive = rules.primitives.get(kind); if (primitive !== undefined) result = primitive; - else if (kind === 0x11 || kind === 0x12) + else if (isDefinitionTypeKind(kind)) result = yield* rules.definition(index); else if (kind === 0x0f || kind === 0x1d) result = join([ diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 8818b6c6de..ec20e8cf85 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -67,6 +67,11 @@ const INPUT_BYTES = 256 * 1024 * 1024; const BINARY_BYTES = 1024 * 1024 * 1024; // A 12.9 MB Unity 6000.4 file with 112,232 methods serializes to 7.8 MB. const OUTPUT_BYTES = 64 * 1024 * 1024; +/** Instruction form the module-array fallback matched, per architecture. */ +const inferredReferenceForm = (architecture: string) => + architecture === "arm64" + ? "an adjacent ADRP+ADD pair" + : "RIP-relative LEA/MOV"; const failureForPrimary = ( facts: ArtifactFilePrimaryFacts, path: string, @@ -229,7 +234,7 @@ const binaryReport = ( address: hex(module.address), method_pointer_count: module.method_pointer_count, })), - types_field: hex(link.types_field), + types_field: link.types_field === null ? null : hex(link.types_field), types_count: link.types_count, generic_methods: link.generic_methods === null @@ -587,7 +592,12 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { ...(linked.binary !== null && linked.binary.code_gen_modules_field === null ? [ - "The static CodeRegistration pair was not resolved. The module array is inferred from executable bytes consistent with RIP-relative LEA/MOV referencing one array candidate; instruction boundaries and runtime use were not verified.", + `The static CodeRegistration pair was not resolved. The module array is inferred from executable bytes consistent with ${inferredReferenceForm(linked.binary.architecture)} referencing one array candidate; instruction boundaries and runtime use were not verified.`, + ] + : []), + ...(linked.binary !== null && linked.binary.types_field === null + ? [ + `No static MetadataRegistration had the type-definition counts. The type table is inferred from executable bytes consistent with ${inferredReferenceForm(linked.binary.architecture)} referencing the one run of Il2CppType-shaped pointers long enough for every referenced type index; types_count is that run's length, at least every referenced type index but not an observed typesCount, and instruction boundaries and runtime use were not verified.`, ] : []), ...(binaryPath === undefined diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index 3f754a3c64..224121527d 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -50,6 +50,14 @@ const expectedAddress = (value: bigint | "no-body" | "unknown") => const expectedAddresses = FIXTURE_METHOD_ADDRESSES.map(expectedAddress); const link = (bytes: Buffer, architecture?: string) => linkIl2cppBinary(bytes, metadata, facts, architecture); +// A type-table fallback needs the metadata to reference most of the table, as +// real builds do; referencing type 3 requires all four fixture entries. +const wholeTable = { + ...facts, + returnTypes: Int32Array.from(facts.returnTypes, () => 3), +}; +const linkWholeTable = (bytes: Buffer) => + linkIl2cppBinary(bytes, metadata, wholeTable); const failure = (bytes: Buffer, architecture?: string) => { try { link(bytes, architecture); @@ -430,6 +438,389 @@ it.each([ ).toBe("code-registration-not-found"); }); +it("finds an arm64 module array through the ADRP+ADD pair that loads it", () => { + const linked = link( + createIl2cppBinaryFixture({ + codeRegistration: false, + adrpAdd: [[0xc00, 0x500]], + }), + ); + expect(linked.code_gen_modules_field).toBeNull(); + expect(linked.code_gen_modules_array).toBe(FIXTURE_BINARY_BASE + 0x500n); + expect(linked.method_addresses).toEqual(expectedAddresses); +}); + +it.each([ + ["no instruction pair loads the array", {}], + ["the pair starts off a 4-byte instruction boundary", { at: 0xc02 }], + ["two module-array candidates are each loaded", { second: true }], + ["the pair lies outside executable ranges", { protection: 3 }], +] as const)( + "keeps arm64 code-registration-not-found when %s", + (_, variant: { at?: number; second?: boolean; protection?: number }) => { + const bytes = createIl2cppBinaryFixture({ + codeRegistration: false, + adrpAdd: + Object.keys(variant).length === 0 + ? [] + : [ + [variant.at ?? 0xc00, 0x500], + ...(variant.second === true ? [[0xc10, 0x520] as const] : []), + ], + }); + if (variant.second === true) bytes.copy(bytes, 0x520, 0x500, 0x510); + if (variant.protection !== undefined) + bytes.writeUInt32LE(variant.protection, 92); + expect(failure(bytes).reason).toBe("code-registration-not-found"); + }, +); + +it("finds an arm64 type table through the ADRP+ADD pair that loads it", () => { + const linked = linkWholeTable( + createIl2cppBinaryFixture({ + metadataRegistration: false, + adrpAdd: [[0xc20, 0x900]], + }), + ); + const reference = linkWholeTable(createIl2cppBinaryFixture()); + expect(linked.types_field).toBeNull(); + expect(linked.types_count).toBe(4); + expect(linked.return_types).toEqual(reference.return_types); + expect(linked.field_types).toEqual(reference.field_types); + expect(linked.parameter_types).toEqual(reference.parameter_types); +}); + +it("finds an x86-64 type table through the instruction that loads it", () => { + const image = createIl2cppBinaryFixture({ metadataRegistration: false }); + // lea rcx, [rip + disp32] at 0xc40, loading the type table at 0x900. + image.set([0x48, 0x8d, 0x0d], 0xc40); + image.writeInt32LE(0x900 - (0xc40 + 7), 0xc43); + const linked = linkWholeTable(createElfFixture(image, { machine: 62 })); + expect(linked.types_field).toBeNull(); + expect(linked.types_count).toBe(4); + expect(linked.return_types).toEqual( + linkWholeTable( + createElfFixture(createIl2cppBinaryFixture(), { machine: 62 }), + ).return_types, + ); +}); + +it.each([ + ["the pair loads an address inside the run", { target: 0x908 }], + [ + "a type-shaped word extends the run before the loaded address", + { before: true }, + ], + ["two runs are each loaded", { second: true }], + [ + "the loaded run is shorter than the referenced type indices", + { short: true }, + ], + ["a record's kind is past the Il2CppTypeEnum range", { kind: 0x1f }], + [ + "a value-type record names a type definition past the metadata", + { definition: 4n }, + ], +] as const)( + "keeps metadata-registration-not-found when %s", + ( + _, + variant: { + target?: number; + before?: boolean; + second?: boolean; + short?: boolean; + kind?: number; + definition?: bigint; + }, + ) => { + const bytes = createIl2cppBinaryFixture({ + metadataRegistration: false, + adrpAdd: [ + [0xc20, variant.target ?? 0x900], + ...(variant.second === true ? [[0xc30, 0xb00] as const] : []), + ], + }); + if (variant.second === true) bytes.copy(bytes, 0xb00, 0x900, 0x920); + // The int record (type 3) is a valid Il2CppType, so the run starts at 0x8f8. + if (variant.before === true) + bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x9b0n, 0x8f8); + // Kind 0 ends the run after two entries; the facts below reference type 3. + if (variant.short === true) bytes.writeUInt32LE(0, 0x980 + 2 * 16 + 8); + // Either record breaks the four-entry run into pieces shorter than four. + if (variant.kind !== undefined) + bytes.writeUInt32LE(variant.kind << 16, 0x980 + 16 + 8); + if (variant.definition !== undefined) + bytes.writeBigUInt64LE(variant.definition, 0x980 + 2 * 16); + // Facts referencing type 3 require a four-entry run. + const required = + variant.short === true || + variant.kind !== undefined || + variant.definition !== undefined + ? { ...facts, returnTypes: Int32Array.from(facts.returnTypes, () => 3) } + : facts; + let reason: string | undefined; + try { + linkIl2cppBinary(bytes, metadata, required); + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + reason = cause.reason; + } + expect(reason).toBe("metadata-registration-not-found"); + }, +); + +it.each([ + [ + // The real table is unloaded; a copy elsewhere is loaded instead. + "only a second long-enough run is loaded", + { metadataRegistration: false, adrpAdd: [[0xc20, 0xb00]] }, + (bytes: Buffer) => bytes.copy(bytes, 0xb00, 0x900, 0x920), + ], + [ + "a static MetadataRegistration matches its counts but not its type table", + { adrpAdd: [[0xc20, 0x900]] }, + (bytes: Buffer) => + bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0xdead0000n, 0x800 + 56), + ], + [ + "the loaded run reaches the end of its mapping", + { + metadataRegistration: false, + adrpAdd: [[0xc20, 0x900]], + splitSegmentAt: 0x920, + }, + () => undefined, + ], + [ + "a record's kind is the unassigned Il2CppTypeEnum value 0x17", + { metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] }, + (bytes: Buffer) => bytes.writeUInt32LE(0x17 << 16, 0x980 + 16 + 8), + ], + [ + "a record's kind is the unassigned Il2CppTypeEnum value 0x1a", + { metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] }, + (bytes: Buffer) => bytes.writeUInt32LE(0x1a << 16, 0x980 + 16 + 8), + ], + [ + // Three entries, one short of the four that type 3 needs. + "the loaded run is one entry short", + { metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] }, + (bytes: Buffer) => bytes.writeBigUInt64LE(0n, 0x918), + ], + [ + "the ADRP+ADD pair uses register 31", + { metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] }, + (bytes: Buffer) => { + bytes.writeUInt32LE((bytes.readUInt32LE(0xc20) | 0x1f) >>> 0, 0xc20); + bytes.writeUInt32LE((bytes.readUInt32LE(0xc24) | 0x3ff) >>> 0, 0xc24); + }, + ], +] as const)( + "keeps metadata-registration-not-found when %s, with the whole table referenced", + (_, options, patch) => { + const bytes = createIl2cppBinaryFixture(options); + patch(bytes); + let reason: string | undefined; + try { + linkWholeTable(bytes); + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + reason = cause.reason; + } + expect(reason).toBe("metadata-registration-not-found"); + }, +); + +it("does not choose a run that starts at its mapping start, where the table may continue backwards", () => { + // The table spans 0x900..0x920; the split puts its first entry in the + // previous mapping, and code loads 0x908, the first entry of the next. + const bytes = createIl2cppBinaryFixture({ + metadataRegistration: false, + splitSegmentAt: 0x908, + adrpAdd: [[0xc20, 0x908]], + }); + let reason: string | undefined; + try { + linkIl2cppBinary(bytes, metadata, { + ...facts, + returnTypes: Int32Array.from(facts.returnTypes, () => 1), + }); + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + reason = cause.reason; + } + expect(reason).toBe("metadata-registration-not-found"); +}); + +it("says why no MetadataRegistration or inferred type table was accepted", () => { + const message = (bytes: Buffer) => { + try { + linkWholeTable(bytes); + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + return cause.message; + } + throw new Error("expected a link failure"); + }; + const unusable = createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }); + unusable.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0xdead0000n, 0x800 + 56); + expect(message(unusable)).toContain( + "a MetadataRegistration counts 4 type definitions, but its type table", + ); + expect( + message(createIl2cppBinaryFixture({ metadataRegistration: false })), + ).toContain("no single code-loaded run of Il2CppType pointers"); +}); + +it("does not let a dyld-bound slot's placeholder decide the type-table run", () => { + const outcome = (placeholder: bigint) => { + // The last table slot (0x918) is bound by dyld; its stored word is only a + // placeholder. Remove the static registration and load the table by code. + const bytes = createIl2cppDyldBindFixture(placeholder, 0x918); + bytes.fill(0, 0x800, 0x880); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + try { + return linkWholeTable(bytes).types_count; + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + return cause.reason; + } + }; + // A type-shaped placeholder and a zero one give the same refusal. + expect(outcome(FIXTURE_BINARY_BASE + 0x9b0n)).toBe( + "metadata-registration-not-found", + ); + expect(outcome(0n)).toBe("metadata-registration-not-found"); +}); + +it("does not let a dyld-bound record field decide the type-table run", () => { + const outcome = (bound: boolean) => { + // The int record (0x9b0) is the table's last entry. Binding the word + // that holds its kind leaves that kind to the loader, although the + // stored placeholder still reads as a valid kind. + const kindWord = createIl2cppBinaryFixture().readBigUInt64LE(0x9b8); + const bytes = bound + ? createIl2cppDyldBindFixture(kindWord, 0x9b8) + : createIl2cppBinaryFixture(); + bytes.fill(0, 0x800, 0x880); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + try { + return linkWholeTable(bytes).types_count; + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + return cause.reason; + } + }; + expect(outcome(false)).toBe(4); + expect(outcome(true)).toBe("metadata-registration-not-found"); +}); + +it("keeps metadata-registration-not-found when x86-64 code loads an address inside the run", () => { + const image = createIl2cppBinaryFixture({ metadataRegistration: false }); + image.set([0x48, 0x8d, 0x0d], 0xc40); + image.writeInt32LE(0x908 - (0xc40 + 7), 0xc43); + let reason: string | undefined; + try { + linkWholeTable(createElfFixture(image, { machine: 62 })); + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + reason = cause.reason; + } + expect(reason).toBe("metadata-registration-not-found"); +}); + +it.each([ + ["a later page", { pages: 2, code: 0xc00, array: 0x1500 }], + ["an earlier page", { pages: 3, code: 0x2c00, array: 0x500 }], +] as const)( + "follows an arm64 ADRP page delta to a module array on %s", + (_, layout) => { + const bytes = createIl2cppBinaryFixture({ + codeRegistration: false, + pages: layout.pages, + adrpAdd: [[layout.code, layout.array]], + }); + if (layout.array !== 0x500) { + bytes.copy(bytes, layout.array, 0x500, 0x510); + bytes.fill(0, 0x500, 0x510); + } + const linked = link(bytes); + expect(linked.code_gen_modules_array).toBe( + FIXTURE_BINARY_BASE + BigInt(layout.array), + ); + expect(linked.method_addresses).toEqual(expectedAddresses); + }, +); + +it("follows a negative arm64 ADRP page delta to the type table", () => { + const linked = linkWholeTable( + createIl2cppBinaryFixture({ + metadataRegistration: false, + pages: 3, + adrpAdd: [[0x2c20, 0x900]], + }), + ); + expect(linked.types_field).toBeNull(); + expect(linked.types_count).toBe(4); +}); + +it.each([ + ["destination", 0], + ["source", 5], +] as const)( + "keeps code-registration-not-found when the ADD %s register differs from the ADRP register", + (_, shift) => { + const bytes = createIl2cppBinaryFixture({ + codeRegistration: false, + adrpAdd: [[0xc00, 0x500]], + }); + const add = bytes.readUInt32LE(0xc04); + // The fixture pair uses x8; write x9 into one register field of the ADD. + bytes.writeUInt32LE(((add & ~(0x1f << shift)) | (9 << shift)) >>> 0, 0xc04); + expect(failure(bytes).reason).toBe("code-registration-not-found"); + }, +); + +it("fails closed at the second type-table candidate when the metadata references few type indices", () => { + const bytes = createIl2cppBinaryFixture({ + metadataRegistration: false, + pages: 2, + adrpAdd: [[0xc20, 0x900]], + }); + // 65 isolated pointers to the int record, each a one-entry run. + for (let index = 0; index < 65; index++) + bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x9b0n, 0x1000 + index * 16); + const outcome = (required: typeof facts) => { + try { + return linkIl2cppBinary(bytes, metadata, required).types_count; + } catch (cause) { + if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause; + return cause.reason; + } + }; + // Fixture methods reference only type 0, so every isolated pointer is a + // candidate run, and the second one ends the search. + expect(outcome(facts)).toBe("metadata-registration-not-found"); + // Referencing type 3 requires four entries; the isolated pointers drop out. + expect( + outcome({ + ...facts, + returnTypes: Int32Array.from(facts.returnTypes, () => 3), + }), + ).toBe(4); +}); + it("refuses to choose among too many candidate module arrays", () => { const base = createIl2cppBinaryFixture({ codeRegistration: false, @@ -1066,6 +1457,31 @@ it.each< expect(genericFailure(options, version)).toEqual({ reason, kind }); }); +it("refuses generic linking when the type table was inferred", () => { + // Without a static MetadataRegistration, genericMethodTable has no anchor. + const decoded = genericFacts(31); + expect(() => + linkIl2cppBinary( + createIl2cppBinaryFixture({ + metadataRegistration: false, + adrpAdd: [[0xc20, 0x900]], + }), + decoded.metadata, + { + ...decoded.link, + returnTypes: Int32Array.from(decoded.link.returnTypes, () => 3), + }, + undefined, + { genericInstances: true }, + ), + ).toThrow( + expect.objectContaining({ + reason: "generic-methods-unsupported", + message: expect.stringContaining("no static MetadataRegistration"), + }), + ); +}); + it("keeps contradicting rows unknown and reports the record address", () => { const issues = (options: GenericMethodFixtureOptions) => { const generic = linkGeneric(options).generic_methods!; diff --git a/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts index 536b6751fc..3688136b39 100644 --- a/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts +++ b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts @@ -1,15 +1,17 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { linkIl2cppBinary } from "../../../src/il2cpp/Il2cppBinaryFile.js"; import { isMapped, openImageSteps, readPointer, readSlot, + readUInt32, } from "../../../src/il2cpp/Il2cppBinaryImage.js"; import { readIl2cppMetadataForLinking } from "../../../src/il2cpp/Il2cppMetadataFile.js"; import { createElfFixture, createIl2cppBinaryFixture, + createIl2cppTypeScanFixture, FIXTURE_BINARY_BASE, } from "../../fixtures/il2cpp/binary.js"; import { createLinkedIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js"; @@ -61,6 +63,42 @@ const withLoadRanges = ( return result; }; +it("reuses admitted mappings for repeated file-backed loader reads", () => { + const original = createIl2cppBinaryFixture(); + const image = open(createElfFixture(original, { zeroFilledTable: true })); + const mappings = vi.spyOn(image.container.mappings, Symbol.iterator); + const zeroFills = vi.spyOn(image.container.zero_fills, "some"); + try { + for (let index = 0; index < 32; index++) { + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x900n)).toBe( + original.readBigUInt64LE(0x900), + ); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x988n)).toBe( + original.readUInt32LE(0x988), + ); + expect(isMapped(image, FIXTURE_BINARY_BASE + 0x980n, 12)).toBe(true); + } + expect(mappings).not.toHaveBeenCalled(); + expect(zeroFills).not.toHaveBeenCalled(); + } finally { + mappings.mockRestore(); + zeroFills.mockRestore(); + } +}); + +it("links one code-loaded type table among short runs across many mappings", () => { + const bytes = createIl2cppTypeScanFixture(64, 128); + const snapshot = Buffer.from(bytes); + const result = linkIl2cppBinary(bytes, metadata, { + ...facts, + returnTypes: Int32Array.from(facts.returnTypes, () => 3), + }); + expect(result.types_field).toBeNull(); + expect(result.types_count).toBe(4); + expect(result.return_types.every((row) => row.name === "int")).toBe(true); + expect(bytes).toEqual(snapshot); +}); + it.each([183, 62])( "links agreeing split ELF RELA spans without changing input bytes (machine %s)", (machine) => { diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index 2f87c64627..c15d9c897b 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -643,6 +643,99 @@ it("retains both observed file identities and the selected architecture when nat } }); +it.each([ + { + architecture: "x86-64", + name: "libil2cpp.so", + binary: () => + createElfFixture( + createIl2cppBinaryFixture({ + codeRegistration: false, + codeReference: true, + }), + { machine: 62 }, + ), + named: "RIP-relative LEA/MOV", + absent: "ADRP+ADD", + }, + { + architecture: "arm64", + name: "GameAssembly.dylib", + binary: () => + createIl2cppBinaryFixture({ + codeRegistration: false, + adrpAdd: [[0xc00, 0x500]], + }), + named: "ADRP+ADD", + absent: "RIP-relative LEA/MOV", + }, +])( + "names the $architecture instruction form behind an inferred module array", + async ({ name, binary, named, absent }) => { + const root = await createTestTempDirectory("rea-il2cpp-inferred-form-"); + const path = join(root, "global-metadata.dat"), + binary_path = join(root, name); + await writeFile(path, createLinkedIl2cppMetadataFixture().bytes); + await writeFile(binary_path, binary()); + const provider = new Il2cppMetadataProvider(); + try { + const result = await provider.inspect({ path, binary_path }); + if (!result.ok) throw result.error; + const inferred = result.value.limitations.filter((line) => + line.includes("static CodeRegistration pair was not resolved"), + ); + expect(inferred).toHaveLength(1); + expect(inferred[0]).toContain(named); + expect(inferred[0]).not.toContain(absent); + } finally { + await provider.close(); + } + }, +); + +it("states that an inferred type table's count is a run length", async () => { + const root = await createTestTempDirectory("rea-il2cpp-inferred-types-"); + const path = join(root, "global-metadata.dat"), + binary_path = join(root, "GameAssembly.dylib"); + // A field typed as type 3 makes the metadata reference the whole + // four-entry table, as real builds reference most of theirs. + const fixture = createLinkedIl2cppMetadataFixture(); + const metadata = Buffer.from(fixture.bytes); + metadata.writeUInt8(3, fixture.section("fields").offset + 4); + await writeFile(path, metadata); + await writeFile( + binary_path, + createIl2cppBinaryFixture({ + metadataRegistration: false, + adrpAdd: [[0xc20, 0x900]], + }), + ); + const provider = new Il2cppMetadataProvider(); + try { + const result = await provider.inspect({ path, binary_path }); + if (!result.ok) throw result.error; + expect(result.value.binary).toMatchObject({ + types_field: null, + types_count: 4, + code_gen_modules_field: expect.any(String), + }); + const inferred = result.value.limitations.filter((line) => + line.includes( + "No static MetadataRegistration had the type-definition counts", + ), + ); + expect(inferred).toHaveLength(1); + expect(inferred[0]).toContain("ADRP+ADD"); + expect( + result.value.limitations.some((line) => + line.includes("static CodeRegistration pair was not resolved"), + ), + ).toBe(false); + } finally { + await provider.close(); + } +}); + it("reports generic method instances only when generic_instances is set", async () => { const root = await createTestTempDirectory("rea-il2cpp-provider-generic-"); const path = join(root, "global-metadata.dat"); diff --git a/tests/fixtures/il2cpp/binary.ts b/tests/fixtures/il2cpp/binary.ts index 384497377a..db9eecb78b 100644 --- a/tests/fixtures/il2cpp/binary.ts +++ b/tests/fixtures/il2cpp/binary.ts @@ -29,6 +29,10 @@ export interface Il2cppBinaryFixtureOptions { readonly duplicateCodeRegistration?: boolean; /** Write a RIP-relative `lea rax, [module array]`, as an inlined build loads it. */ readonly codeReference?: boolean; + /** Write `adrp x8, page; add x8, x8, #pageoff` at each file offset, loading the target offset. */ + readonly adrpAdd?: readonly (readonly [at: number, target: number])[]; + /** 4 KiB pages in the Mach-O image; extra pages are zero, past every table. */ + readonly pages?: number; /** Omit the MetadataRegistration type table. */ readonly metadataRegistration?: boolean; /** Write a second distinct MetadataRegistration candidate. */ @@ -131,20 +135,24 @@ const METHOD_SPECS = 0xb80; const GENERIC_METHOD_TABLE = 0xbc0; const GENERIC_METHOD_POINTERS = 0xd00; +const imageSize = (options: Il2cppBinaryFixtureOptions) => + SIZE * (options.pages ?? 1); + export const createIl2cppBinaryFixture = ( options: Il2cppBinaryFixtureOptions = {}, ): Buffer => { - const bytes = Buffer.alloc(SIZE); + const size = imageSize(options); + const bytes = Buffer.alloc(size); const address = (offset: number) => FIXTURE_BINARY_BASE + BigInt(offset); const chained = options.chainedFixups === true; const split = options.splitSegmentAt; // LC_SEGMENT_64 records, read-execute; a split gives two adjacent ones. const segments: readonly [name: string, start: number, end: number][] = split === undefined - ? [["__TEXT", 0, SIZE]] + ? [["__TEXT", 0, size]] : [ ["__TEXT", 0, split], - ["__DATA", split, SIZE], + ["__DATA", split, size], ]; const commands = segments.length + (chained ? 1 : 0); // Mach-O 64-bit header: arm64 dylib with its load commands. @@ -214,6 +222,21 @@ export const createIl2cppBinaryFixture = ( bytes.set([0x48, 0x8d, 0x05], at); bytes.writeInt32LE(MODULE_ARRAY - (at + 7), at + 3); } + for (const [at, target] of options.adrpAdd ?? []) { + const pc = address(at), + to = address(target); + const pages = Number(((to >> 12n) - (pc >> 12n)) & 0x1fffffn); + // ADRP x8: immlo in bits 29-30, immhi in bits 5-23. + bytes.writeUInt32LE( + (0x90000008 | ((pages & 3) << 29) | ((pages >> 2) << 5)) >>> 0, + at, + ); + // ADD x8, x8, #pageoff (64-bit, unshifted imm12). + bytes.writeUInt32LE( + (0x91000108 | (Number(to & 0xfffn) << 10)) >>> 0, + at + 4, + ); + } if (options.codeRegistration !== false) { bytes.writeBigUInt64LE(2n, REGISTRATION); bytes.writeBigUInt64LE(address(MODULE_ARRAY), REGISTRATION + 8); @@ -591,6 +614,43 @@ export const createElfFixture = ( return file; }; +/** A code-loaded type table plus isolated one-entry candidate runs in disjoint ELF mappings. */ +export const createIl2cppTypeScanFixture = ( + mappingCount: number, + bytesPerMapping: number, +): Buffer => { + const image = createIl2cppBinaryFixture({ + metadataRegistration: false, + adrpAdd: [[0xc20, 0x900]], + }); + const original = createElfFixture(image); + const candidates = Buffer.alloc(mappingCount * bytesPerMapping); + for (let at = 0; at < candidates.length; at += 16) + candidates.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x9b0n, at); + // The original dynamic segment and complete payload retain their addresses. + // Move only the program header table to the unmapped end of the file. + const headers: Buffer[] = [Buffer.from(original.subarray(120, 176))]; + for (let index = 0; index <= mappingCount; index++) { + const offset = + index === 0 ? 0 : original.length + (index - 1) * bytesPerMapping; + const size = index === 0 ? original.length : bytesPerMapping; + const header = Buffer.alloc(56); + header.writeUInt32LE(1); + header.writeUInt32LE(index === 0 ? 7 : 6, 4); + header.writeBigUInt64LE(BigInt(offset), 8); + header.writeBigUInt64LE(FIXTURE_BINARY_BASE + BigInt(offset), 16); + header.writeBigUInt64LE(FIXTURE_BINARY_BASE + BigInt(offset), 24); + header.writeBigUInt64LE(BigInt(size), 32); + header.writeBigUInt64LE(BigInt(size), 40); + header.writeBigUInt64LE(1n, 48); + headers.push(header); + } + const result = Buffer.concat([original, candidates, ...headers]); + result.writeBigUInt64LE(BigInt(original.length + candidates.length), 32); + result.writeUInt16LE(headers.length, 56); + return result; +}; + export interface GenericRegistrationImageOptions { /** Let the 104-byte layout bound the rows as well as the 120-byte one. */ readonly ambiguous?: boolean;