diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 64edabd7b7..c1b8424546 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -221,7 +221,13 @@ A position-independent `libil2cpp.so` stores zero in most pointer slots and keeps the value in `R_AARCH64_RELATIVE` or `R_X86_64_RELATIVE` relocations (`DT_RELA`). The reader indexes loader values by virtual address while keeping the source bytes unchanged, so addresses are the link-time addresses and RVAs are relative to -the first loaded segment. Android packed +the first loaded segment. Word-sized AArch64 ABS64/GLOB_DAT and x86-64 +absolute/GLOB_DAT pointers can also use a defined ordinary dynamic symbol from +the selected image. Symbol-table indices must be bounded by `DT_HASH` or +`DT_GNU_HASH`; undefined symbols, TLS offsets and IFUNC resolver results remain +unknown. These are static link-image derivations, not observations of runtime +symbol binding or proof that a definition cannot be interposed. Other pointer +consumers retain their own conservative resolution policy. Android packed relocations (`DT_ANDROID_RELA`) are refused. No target code is executed. The binary's `Il2CppCodeRegistration` points to one `Il2CppCodeGenModule` per diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index a60a6ac8ec..78e60f46e4 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -122,6 +122,7 @@ export function* openImageSteps(image: Buffer): Generator { zeroFills: container.zero_fills, }, IL2CPP_BINARY_BYTES, + { symbolResolution: "image-definitions" }, ); } catch (cause) { if (cause instanceof ElfPointerRelocationFailure) { diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 6f0e3fe42b..2e2d630e98 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -606,6 +606,11 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { binaryPath === undefined ? "Native code addresses and IL2CPP binary registration are not recovered without binary_path." : `Method addresses come from the binary's code-generation module methodPointers, matched by module name, method count and metadata token; ${input.generic_instances === true ? "" : "generic method instances (see generic_instances), "}adjustor thunks and invokers are not linked, no-body means an observed zero slot, and unknown means no module claimed the method or its slot has an unresolved relocation.`, + ...(linked.binary?.format === "elf" + ? [ + "ELF symbolic pointers use the selected file's defined symbols to derive link-image addresses; runtime symbol interposition, external definitions, TLS addresses and IFUNC results are not established.", + ] + : []), ...(binaryPath !== undefined && input.generic_instances === true ? [ "Generic method instances come from genericMethodTable, methodSpecs, genericInsts and genericMethodPointers, with names following Il2CppDumper's GetMethodSpecName (namespace on the declaring type, nested types joined with '.', arity cut, IntPtr/UIntPtr, no byref mark). The CodeRegistration layout is the only one of the known 27.0 to 31 layouts whose counts bound every genericMethodTable row; metadata versions 24 and 39 and builds without a static CodeRegistration are refused. A generic row is no-body for an observed zero pointer slot and unknown, with no address, exactly when an issue names its row: a contradicting method spec (its name is then null), a shared method spec, an unmapped or non-executable pointer, or an unresolved relocation.", diff --git a/src/native/metadata/ElfPointerRelocations.test.ts b/src/native/metadata/ElfPointerRelocations.test.ts index 7ac0410b79..23fb8beb08 100644 --- a/src/native/metadata/ElfPointerRelocations.test.ts +++ b/src/native/metadata/ElfPointerRelocations.test.ts @@ -1,4 +1,5 @@ import { expect, it, vi } from "vitest"; +import type { FileMapping } from "../../domain/virtualFileMapping.js"; import { ElfPointerRelocationFailure, elfAbsoluteRelocationType, @@ -141,6 +142,13 @@ const symbolicImage = ( readonly destination?: bigint; readonly records?: number; readonly symbolIndex?: number; + readonly definition?: { + readonly value: bigint; + readonly type?: number; + readonly section?: number; + }; + readonly addend?: bigint; + readonly hash?: "sysv" | "gnu" | "none"; } = {}, ): Buffer => { const records = layout.records ?? 1; @@ -152,6 +160,14 @@ const symbolicImage = ( ...(layout.symbolTags ?? [ [6n, 0x200n], [11n, 24n], + ...(layout.definition === undefined || layout.hash === "none" + ? [] + : [ + [layout.hash === "gnu" ? 0x6ffffef5n : 4n, 0x280n] as [ + bigint, + bigint, + ], + ]), ]), [0n, 0n], ]; @@ -165,8 +181,19 @@ const symbolicImage = ( (BigInt(layout.symbolIndex ?? 1) << 32n) | kind, 0x108 + record * 24, ); + bytes.writeBigInt64LE(layout.addend ?? 0n, 0x110 + record * 24); } bytes.writeBigUInt64LE(symbolSize, 0x200 + 24 + 16); + if (layout.definition !== undefined) { + bytes[0x200 + 24 + 4] = 0x10 | (layout.definition.type ?? 1); + bytes.writeUInt16LE(layout.definition.section ?? 1, 0x200 + 24 + 6); + bytes.writeBigUInt64LE(layout.definition.value, 0x200 + 24 + 8); + const words = + layout.hash === "gnu" ? [1, 1, 1, 0, 0, 0, 1, 1] : [1, 2, 1, 0, 0]; + words.forEach((value, index) => + bytes.writeUInt32LE(value, 0x280 + index * 4), + ); + } return bytes; }; @@ -192,12 +219,13 @@ const symbolicImage32 = (kind: number, symbolSize: number): Buffer => { }; const walker = - (wholeExtents: boolean) => + (wholeExtents: boolean, mappings?: readonly FileMapping[]) => ( bytes: Buffer, unsupported?: "record", machine = EM_X86_64, bits: 32 | 64 = 64, + imageDefinitions = false, ) => { const writes: ElfPointerWrite[] = []; const steps = readElfRelocationWritesSteps( @@ -207,10 +235,16 @@ const walker = little: true, machine, dynamicSegments: [{ offset: 0n, size: 0x100n }], - mappings: [{ address: 0n, offset: 0, size: bytes.length }], + mappings: mappings ?? [{ address: 0n, offset: 0, size: bytes.length }], zeroFills: [], }, - { wholeExtents, ...(unsupported === undefined ? {} : { unsupported }) }, + { + wholeExtents, + ...(unsupported === undefined ? {} : { unsupported }), + ...(imageDefinitions + ? { symbolResolution: "image-definitions" as const } + : {}), + }, ); let next = steps.next(); for (; !next.done; next = steps.next()) @@ -222,6 +256,159 @@ const walk = walker(true); /** A consumer that tracks one pointer per write. */ const walkPointers = walker(false); +it.each([ + ["AArch64 ABS64", 183, 257, 0x4020n], + ["AArch64 GLOB_DAT", 183, 1025, 0x4020n], + ["x86-64 absolute", 62, 1, 0x4020n], + ["x86-64 GLOB_DAT", 62, 6, 0x4000n], +] as const)( + "derives %s from image definitions only when requested", + (_name, machine, kind, expected) => { + const bytes = symbolicImage(BigInt(kind), 0n, { + definition: { value: 0x4000n }, + addend: 0x20n, + }); + expect(walk(bytes, undefined, machine).writes[0]?.value).toBeNull(); + const { writes, result } = walk(bytes, undefined, machine, 64, true); + expect(writes[0]).toMatchObject({ + value: expected, + symbol_index: 1, + addend: 0x20n, + }); + expect(result.pointer_coverage_known).toBe(true); + }, +); + +it.each(["sysv", "gnu"] as const)( + "bounds image definitions with the %s hash table", + (hash) => { + const bytes = symbolicImage(1025n, 0n, { + definition: { value: 0x4000n }, + hash, + }); + expect(walk(bytes, undefined, 183, 64, true).writes[0]?.value).toBe( + 0x4000n, + ); + // A mapped, plausible record beyond the declared table is not another symbol. + bytes.writeUInt16LE(1, 0x200 + 48 + 6); + bytes.writeBigUInt64LE(0x5000n, 0x200 + 48 + 8); + bytes.writeBigUInt64LE((2n << 32n) | 1025n, 0x108); + expect(walk(bytes, undefined, 183, 64, true).writes[0]?.value).toBeNull(); + }, +); + +it.each([ + [183, 257], + [62, 1], +] as const)( + "reads a 64-bit REL in-place addend for machine %d", + (machine, kind) => { + const bytes = symbolicImage(BigInt(kind), 0n, { + definition: { value: 0x4000n }, + }); + for (const [at, tag, value] of [ + [0, 17n, 0x100n], + [16, 18n, 16n], + [32, 19n, 16n], + ] as const) { + bytes.writeBigUInt64LE(tag, at); + bytes.writeBigUInt64LE(value, at + 8); + } + bytes.writeBigUInt64LE(0x20n, 0x300); + expect(walk(bytes, undefined, machine, 64, true).writes[0]).toMatchObject({ + value: 0x4020n, + addend: null, + source: { encoding: "rel" }, + }); + }, +); + +it.each([ + ["undefined", 0, 1], + ["absolute section", 0xfff1, 1], + ["common", 0xfff2, 1], + ["TLS", 1, 6], + ["IFUNC", 1, 10], +] as const)("keeps %s symbol addresses unknown", (_name, section, type) => { + const bytes = symbolicImage(1025n, 0n, { + definition: { value: 0x4000n, section, type }, + }); + expect(walk(bytes, undefined, 183, 64, true).writes[0]?.value).toBeNull(); +}); + +it("keeps an unbounded symbol table unknown without losing RELATIVE facts", () => { + const bytes = symbolicImage(1025n, 0n, { + definition: { value: 0x4000n }, + hash: "none", + records: 2, + }); + bytes.writeBigUInt64LE(1027n, 0x120); + bytes.writeBigInt64LE(0x1234n, 0x128); + expect( + walk(bytes, undefined, 183, 64, true).writes.map(({ value }) => value), + ).toEqual([null, 0x1234n]); +}); + +it.each(["unmapped hash", "truncated symbols", "conflicting hashes"] as const)( + "keeps image definitions unknown for %s", + (failure) => { + const bytes = symbolicImage(1025n, 0n, { + definition: { value: 0x4000n }, + }); + if (failure === "unmapped hash") { + bytes.writeBigUInt64LE(0x400n, 5 * 16 + 8); + } else if (failure === "truncated symbols") { + bytes.writeUInt32LE(32, 0x280 + 4); + } else { + bytes.writeBigUInt64LE(0x6ffffef5n, 6 * 16); + bytes.writeBigUInt64LE(0x2b0n, 6 * 16 + 8); + [1, 1, 1, 0, 0, 0, 2, 0, 1].forEach((value, index) => + bytes.writeUInt32LE(value, 0x2b0 + index * 4), + ); + } + expect(walk(bytes, undefined, 183, 64, true).writes[0]?.value).toBeNull(); + }, +); + +it.each([4n, 0x6ffffef5n])( + "preserves ambiguity of redeclared hash tag %s despite another hash count", + (redeclared) => { + const bytes = symbolicImage(1025n, 0n, { + definition: { value: 0x4000n }, + records: 2, + symbolTags: [ + [6n, 0x200n], + [11n, 24n], + [4n, 0x280n], + [0x6ffffef5n, 0x2b0n], + [redeclared, redeclared === 4n ? 0x280n : 0x2b0n], + ], + }); + // The classic table declares two symbols, the GNU table three. + [1, 1, 1, 0, 0, 0, 2, 0, 1].forEach((value, index) => + bytes.writeUInt32LE(value, 0x2b0 + index * 4), + ); + bytes.writeBigUInt64LE(1027n, 0x120); + bytes.writeBigInt64LE(0x1234n, 0x128); + expect( + walk(bytes, undefined, 183, 64, true).writes.map(({ value }) => value), + ).toEqual([null, 0x1234n]); + }, +); + +it("requires the full hash table mapping including skipped words", () => { + const bytes = symbolicImage(1025n, 0n, { + definition: { value: 0x4000n }, + }); + const acrossGap = walker(true, [ + { address: 0n, offset: 0, size: 0x288 }, + { address: 0x28cn, offset: 0x28c, size: bytes.length - 0x28c }, + ]); + expect( + acrossGap(bytes, undefined, 183, 64, true).writes[0]?.value, + ).toBeNull(); +}); + it.each([ ["an x86-64 COPY", symbolicImage(5n, 8n), [62, 64, 5]], ["an x86-64 TLS descriptor", symbolicImage(36n, 0n), [62, 64, 36]], diff --git a/src/native/metadata/ElfPointerRelocations.ts b/src/native/metadata/ElfPointerRelocations.ts index f470afe97f..f18fdc415f 100644 --- a/src/native/metadata/ElfPointerRelocations.ts +++ b/src/native/metadata/ElfPointerRelocations.ts @@ -5,6 +5,12 @@ import type { import { readElfDynamicEntry } from "../../domain/elfDynamicEntry.js"; import { readElfRelocationEntry } from "../../domain/elfRelocation.js"; import { readElfSymbol } from "../../domain/elfSymbol.js"; +import { + readElfHashSymbolCount, + readElfGnuHashSymbolCount, + type ElfHashWordReader, +} from "../../domain/elfHashSymbolCount.js"; +import { err, ok } from "../../domain/result.js"; import { BinaryMappingFailure, createFileMappingIndex, @@ -126,7 +132,9 @@ const DT_RELRSZ = 35n, DT_RELR = 36n, DT_RELRENT = 37n; const DT_SYMTAB = 6n, - DT_SYMENT = 11n; + DT_SYMENT = 11n, + DT_HASH = 4n, + DT_GNU_HASH = 0x6ffffef5n; const PACKED_TAGS = [0x6000000fn, 0x60000010n, 0x60000011n, 0x60000012n]; const ANDROID_RELR_TAGS = [0x6fffe000n, 0x6fffe001n, 0x6fffe003n]; const UNIQUE_TAGS = new Set([ @@ -145,9 +153,9 @@ const UNIQUE_TAGS = new Set([ ...PACKED_TAGS, ...ANDROID_RELR_TAGS, ]); -// Only COPY extents read the dynamic symbol table, so a redeclared tag leaves -// those extents unknown without rejecting unambiguous relocation tables. -const SYMBOL_TABLE_TAGS = new Set([DT_SYMTAB, DT_SYMENT]); +// Ambiguous symbol declarations leave symbolic writes unknown without +// rejecting independently decoded relative relocations. +const SYMBOL_TABLE_TAGS = new Set([DT_SYMTAB, DT_SYMENT, DT_HASH, DT_GNU_HASH]); const malformed = (message: string): never => { throw new ElfPointerRelocationFailure("malformed-container", message); }; @@ -279,15 +287,22 @@ export const elfRelocationWidth = ( ): number | undefined => type === 0 ? 0 : RELOCATION_KINDS.get(machine)?.widths.get(type); +type DynamicTags = { + readonly values: ReadonlyMap; + /** An omitted value can mean an absent or an ambiguous declaration. */ + readonly ambiguous: ReadonlySet; +}; + function* dynamicTags( bytes: Buffer, input: ElfPointerRelocationInput, -): Generator> { +): Generator { yield; if (input.dynamicSegments.length > 1) malformed("multiple PT_DYNAMIC segments"); const declaration = input.dynamicSegments[0]; - if (declaration === undefined) return new Map(); + if (declaration === undefined) + return { values: new Map(), ambiguous: new Set() }; const width = input.bits / 4; if (declaration.size > 16n * 1024n * 1024n) throw new ElfPointerRelocationFailure( @@ -317,7 +332,7 @@ function* dynamicTags( const { tag, value } = parsed.value; if (tag === 0n) { for (const symbolTag of redeclared) tags.delete(symbolTag); - return tags; + return { values: tags, ambiguous: redeclared }; } if (SYMBOL_TABLE_TAGS.has(tag)) { if (tags.has(tag)) redeclared.add(tag); @@ -343,6 +358,11 @@ export interface ElfRelocationStreamResult { type RelocationOptions = { readonly unsupported?: "reject" | "record"; readonly requireDynamic?: boolean; + /** + * Static link-image arithmetic using this file's definitions, not proof of + * runtime symbol binding. Unset, symbolic pointer values remain unknown. + */ + readonly symbolResolution?: "image-definitions"; /** * Yield COPY and TLS descriptor records as writes of unknown value over * their whole extent, which can exceed one pointer. Unset, they stay @@ -364,6 +384,7 @@ export function* readElfRelocationWritesSteps( "ELF relative pointers require a matching AArch64, x86-64, ARM or i386 image", ); const relative = kinds.relative; + const absolute = kinds.absolute; const wholeExtents = options.wholeExtents === true; const copy = wholeExtents ? kinds.copy : undefined; const unknownWidths = kinds.widths; @@ -372,8 +393,11 @@ export function* readElfRelocationWritesSteps( "unsupported-container", "ELF image has no PT_DYNAMIC segment", ); - const tags = yield* dynamicTags(bytes, input); - const word = input.bits / 8; + const { values: tags, ambiguous: ambiguousTags } = yield* dynamicTags( + bytes, + input, + ); + const word = input.bits === 64 ? 8 : 4; const index = createFileMappingIndex(input.mappings, input.zeroFills); const fileOffset = (address: bigint, width: number): number | null => { try { @@ -438,15 +462,7 @@ export function* readElfRelocationWritesSteps( tags.get(DT_SYMENT) === BigInt(symbolBytes) ? tags.get(DT_SYMTAB) : undefined; - /** - * Bytes a COPY relocation can write: the loader copies at most the size this - * image's own dynamic symbol records. Undefined when that size is unreadable - * or the span leaves the address space. - */ - const copyWidth = ( - destination: bigint, - symbolIndex: number, - ): number | undefined => { + const dynamicSymbol = (symbolIndex: number) => { if (symbolTable === undefined || symbolIndex === 0) return undefined; let at: number; try { @@ -458,13 +474,99 @@ export function* readElfRelocationWritesSteps( if (cause instanceof BinaryMappingFailure) return undefined; throw cause; } - const symbol = readElfSymbol( + const parsed = readElfSymbol( bytes.subarray(at, at + symbolBytes), input.bits, input.little, ); - if (!symbol.ok) return undefined; - const { size } = symbol.value; + return parsed.ok ? parsed.value : undefined; + }; + const hashWords = + (address: bigint): ElfHashWordReader => + (offset) => { + try { + // Hash walks skip bloom/bucket words; their whole prefix must be mapped. + const at = index.offset(address, offset + 4) + offset; + return ok( + input.little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at), + ); + } catch (cause) { + if (cause instanceof BinaryMappingFailure) return err(cause.message); + throw cause; + } + }; + function* countSymbols(): Generator { + if (ambiguousTags.has(DT_HASH) || ambiguousTags.has(DT_GNU_HASH)) + return undefined; + const maximum = Math.floor(bytes.length / 4); + let count: number | undefined; + const classic = tags.get(DT_HASH); + if (classic !== undefined) { + const parsed = readElfHashSymbolCount(hashWords(classic), maximum); + if (!parsed.ok) return undefined; + count = parsed.value; + } + const gnu = tags.get(DT_GNU_HASH); + if (gnu !== undefined) { + const steps = readElfGnuHashSymbolCount(hashWords(gnu), word, maximum); + let next = steps.next(); + while (!next.done) { + yield; + next = steps.next(); + } + if (!next.value.ok) return undefined; + const value = next.value.value; + if (value !== null) { + if (count !== undefined && count !== value) return undefined; + count = value; + } + } + // Mapping a complete declared table bounds every later indexed read. + if (symbolTable === undefined || count === undefined || count === 0) + return undefined; + try { + index.offset(symbolTable, count * symbolBytes); + } catch (cause) { + if (cause instanceof BinaryMappingFailure) return undefined; + throw cause; + } + return count; + } + let symbolCountRead = false; + let symbolCount: number | undefined; + function* symbolLinkValue( + symbolIndex: number, + ): Generator { + if (symbolIndex === 0) return 0n; + if (!symbolCountRead) { + symbolCountRead = true; + symbolCount = yield* countSymbols(); + } + if (symbolCount === undefined || symbolIndex >= symbolCount) + return undefined; + const symbol = dynamicSymbol(symbolIndex); + if (symbol === undefined) return undefined; + const { section_index, type, value } = symbol; + // Undefined/reserved sections, TLS offsets and IFUNC resolvers do not + // supply an ordinary link-image address for this file. + return section_index !== 0 && + section_index < 0xff00 && + (type === 0 || type === 1 || type === 2) + ? value + : undefined; + } + /** + * Bytes a COPY relocation can write: the loader copies at most the size this + * image's own dynamic symbol records. Undefined when that size is unreadable + * or the span leaves the address space. + */ + const copyWidth = ( + destination: bigint, + symbolIndex: number, + ): number | undefined => { + const symbol = dynamicSymbol(symbolIndex); + if (symbol === undefined) return undefined; + const { size } = symbol; return size <= BigInt(Number.MAX_SAFE_INTEGER) && destination + size <= 1n << BigInt(input.bits) ? Number(size) @@ -547,6 +649,31 @@ export function* readElfRelocationWritesSteps( continue; } if (kind !== relative || symbolIndex !== 0) { + const globalData = input.machine === 183 ? 1025 : 6; + if ( + options.symbolResolution === "image-definitions" && + input.bits === 64 && + (kind === absolute || kind === globalData) + ) { + const value = yield* symbolLinkValue(symbolIndex); + if (value !== undefined) { + // AArch64 ABS64/GLOB_DAT and x86-64 R_X86_64_64 use S + A. + // R_X86_64_GLOB_DAT uses S, ignoring even a nonzero addend. + const slot = + explicitAddend === undefined ? target(destination, word) : null; + const addend = + input.machine === 62 && kind === globalData + ? 0n + : (explicitAddend ?? (slot === null ? 0n : rawWord(slot))); + yield write( + destination, + word, + BigInt.asUintN(input.bits, value + addend), + record, + ); + continue; + } + } const width = unknownWidths.get(kind); if (width === undefined || (width > word && !wholeExtents)) { unsupported(`ELF relocation kind ${kind} is not decoded`); @@ -900,6 +1027,7 @@ export function* readElfPointerValuesSteps( bytes: Buffer, input: ElfPointerRelocationInput, maximumIndexBytes: number, + options: Pick = {}, ): Generator { const index = new ElfVirtualWriteIndex( input.bits === 64 ? 8 : 4, @@ -909,6 +1037,7 @@ export function* readElfPointerValuesSteps( const zeroFill = new Set(); const steps = readElfRelocationWritesSteps(bytes, input, { requireDynamic: true, + ...options, }); let next = steps.next(); while (!next.done) { diff --git a/tests/boundary/cli/il2cppElfRelocations.test.ts b/tests/boundary/cli/il2cppElfRelocations.test.ts new file mode 100644 index 0000000000..1d94191c33 --- /dev/null +++ b/tests/boundary/cli/il2cppElfRelocations.test.ts @@ -0,0 +1,154 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { il2cppMetadataSchema } from "../../../src/domain/il2cpp/il2cppMetadata.js"; +import { + createElfFixture, + createIl2cppBinaryFixture, + FIXTURE_BINARY_BASE, + FIXTURE_METHOD_ADDRESSES, +} from "../../fixtures/il2cpp/binary.js"; +import { createLinkedIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const report = (value: unknown) => + il2cppMetadataSchema.parse( + toolContract("inspect_il2cpp_metadata").outputSchema.parse(value) + .normalized_result, + ); + +cliTest( + "links ELF metadata past ABS64 and GLOB_DAT records through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-il2cpp-elf-symbols-"); + const metadataPath = join(root, "global-metadata.dat"); + await writeFile(metadataPath, createLinkedIl2cppMetadataFixture().bytes); + const { call } = await connectLocalToolsMcp(); + for (const machine of [183, 62]) { + const bytes = createElfFixture(createIl2cppBinaryFixture(), { + machine, + symbolDefinition: { value: FIXTURE_BINARY_BASE + 0xe00n }, + unresolvedRelocations: [ + ...[0xe00, 0xe08, 0xe10].map((offset) => ({ + offset, + kind: machine === 183 ? 257 : 1, + })), + ...[0xe20, 0xe28, 0xe30].map((offset) => ({ + offset, + kind: machine === 183 ? 1025 : 6, + })), + ], + }); + const binaryPath = join(root, `libil2cpp-${machine}.so`); + await writeFile(binaryPath, bytes); + const result = await cli.run({ + arguments: [ + "inspect-il2cpp-metadata", + metadataPath, + "--binary", + binaryPath, + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const linked = report(result.json); + expect(linked.method_addresses.map(({ address }) => address)).toEqual( + FIXTURE_METHOD_ADDRESSES.map((value) => + typeof value === "bigint" ? `0x${value.toString(16)}` : null, + ), + ); + expect(linked.binary?.artifact.sha256).toBe( + createHash("sha256").update(bytes).digest("hex"), + ); + expect(linked.limitations).toContainEqual( + expect.stringContaining("runtime symbol interposition"), + ); + + const mcp = await call("inspect_il2cpp_metadata", { + path: metadataPath, + binary_path: binaryPath, + detail: "complete", + }); + expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true); + expect(report(mcp.structuredContent)).toEqual(linked); + expect((await readFile(binaryPath)).equals(bytes)).toBe(true); + } + + // A declared but undefined symbol still cannot disprove a competing module. + const unresolved = createElfFixture(createIl2cppBinaryFixture(), { + symbolDefinition: { value: FIXTURE_BINARY_BASE + 0xe00n, section: 0 }, + unresolvedRelocations: [0xe00, 0xe08, 0xe10].map((offset) => ({ + offset, + kind: 1025, + })), + }); + const unresolvedPath = join(root, "unresolved.so"); + await writeFile(unresolvedPath, unresolved); + const refused = await cli.run({ + arguments: [ + "inspect-il2cpp-metadata", + metadataPath, + "--binary", + unresolvedPath, + "--json", + ], + }); + const partial = { + inventory_status: "not-returned-as-complete", + selected_binary: { + path: unresolvedPath, + sha256: createHash("sha256").update(unresolved).digest("hex"), + }, + }; + expect(refused.exitCode).toBe(1); + expect(refused.json).toMatchObject({ + code: "invalid_request", + details: { partial_observation: { normalized_result: partial } }, + }); + const mcpRefused = await call("inspect_il2cpp_metadata", { + path: metadataPath, + binary_path: unresolvedPath, + detail: "complete", + }); + expect(mcpRefused.isError).toBe(true); + const mcpError = parseMcpToolError(mcpRefused).error; + expect(mcpError.code).toBe("invalid_request"); + const reference = z + .object({ + partial_observation: z.object({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }), + }) + .parse(mcpError.details).partial_observation; + const retainedReply = await call("get_evidence_bundle", { + detail: "complete", + }); + expect(retainedReply.isError).not.toBe(true); + const bundle = toolContract("get_evidence_bundle").outputSchema.parse( + retainedReply.structuredContent, + ).result; + if ("kind" in bundle) + throw new Error("Expected complete retained Evidence"); + const retained = parseEvidence( + bundle.records.find( + ({ evidence_id }) => evidence_id === reference.evidence_id, + ), + ); + expect(retained.normalized_result).toMatchObject(partial); + const metadataOnly = await call("inspect_il2cpp_metadata", { + path: metadataPath, + detail: "complete", + }); + expect(metadataOnly.isError).not.toBe(true); + expect(report(metadataOnly.structuredContent).binary).toBeNull(); + }, +); diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index f74aab543f..566a9837af 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -54,6 +54,7 @@ const expectedAddress = (value: bigint | "no-body" | "unknown") => } : { state: value, address: null, rva: null }; const expectedAddresses = FIXTURE_METHOD_ADDRESSES.map(expectedAddress); + // The provider selects a universal slice before linking, as here. const link = (bytes: Buffer, architecture?: Il2cppBinaryArchitecture) => linkIl2cppBinary( @@ -66,6 +67,26 @@ const link = (bytes: Buffer, architecture?: Il2cppBinaryArchitecture) => metadata, facts, ); +it.each([183, 62])( + "links past unrelated ABS64 and GLOB_DAT records on ELF machine %d", + (machine) => { + const bytes = createElfFixture(createIl2cppBinaryFixture(), { + machine, + symbolDefinition: { value: FIXTURE_BINARY_BASE + 0xe00n }, + unresolvedRelocations: [ + ...[0xe00, 0xe08, 0xe10].map((offset) => ({ + offset, + kind: machine === 183 ? 257 : 1, + })), + ...[0xe20, 0xe28, 0xe30].map((offset) => ({ + offset, + kind: machine === 183 ? 1025 : 6, + })), + ], + }); + expect(link(bytes).method_addresses).toEqual(expectedAddresses); + }, +); // 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 = { diff --git a/tests/fixtures/il2cpp/binary.ts b/tests/fixtures/il2cpp/binary.ts index a8b5a33558..aecbf21457 100644 --- a/tests/fixtures/il2cpp/binary.ts +++ b/tests/fixtures/il2cpp/binary.ts @@ -509,6 +509,12 @@ export interface ElfFixtureOptions { readonly zeroFilledTable?: boolean; /** Declare DT_SYMTAB / DT_SYMENT with dynamic symbol 1 of this `st_size`, which sizes a COPY. */ readonly symbolSize?: bigint; + /** A defined ordinary dynamic symbol for static link-image relocation tests. */ + readonly symbolDefinition?: { + readonly value: bigint; + readonly type?: number; + readonly section?: number; + }; } /** A valid large RELA image; extra file-backed pointers do not add IL2CPP registrations. */ @@ -562,11 +568,19 @@ export const createLargeElfRelativeFixture = ( * base: every pointer slot into the image is zeroed on disk and restored by * an R_AARCH64_RELATIVE entry, as a position-independent libil2cpp.so stores it. */ -/** The null symbol, then symbol 1 with only its size set; empty when no size is asked for. */ -const dynamicSymbols = (size: bigint | undefined): Buffer => { - if (size === undefined) return Buffer.alloc(0); +/** The null symbol, then symbol 1 with the requested COPY size or definition. */ +const dynamicSymbols = ( + size: bigint | undefined, + definition: ElfFixtureOptions["symbolDefinition"], +): Buffer => { + if (size === undefined && definition === undefined) return Buffer.alloc(0); const symbols = Buffer.alloc(48); - symbols.writeBigUInt64LE(size, 24 + 16); + symbols.writeBigUInt64LE(size ?? 0n, 24 + 16); + if (definition !== undefined) { + symbols[24 + 4] = 0x10 | (definition.type ?? 1); + symbols.writeUInt16LE(definition.section ?? 1, 24 + 6); + symbols.writeBigUInt64LE(definition.value, 24 + 8); + } return symbols; }; @@ -605,14 +619,24 @@ export const createElfFixture = ( 0n, relocation.kind, ]); - const symbols = dynamicSymbols(options.symbolSize); + const symbols = dynamicSymbols(options.symbolSize, options.symbolDefinition); + const hash = Buffer.alloc(options.symbolDefinition === undefined ? 0 : 20); + if (hash.length !== 0) + [1, 2, 1, 0, 0].forEach((value, index) => + hash.writeUInt32LE(value, index * 4), + ); const tagCount = 4 + (options.duplicateRela === true ? 1 : 0) + (options.androidPacked === true ? 1 : 0) + - symbolTableTags(symbols, 0n).length; + symbolTableTags(symbols, 0n).length + + (hash.length === 0 ? 0 : 1); const fileSize = - SIZE + relocations.length * 24 + tagCount * 16 + symbols.length; + SIZE + + relocations.length * 24 + + tagCount * 16 + + symbols.length + + hash.length; if (options.zeroFilledTable === true) { // The second module record's methodPointers field (MODULES + 0x40 + 16). const slot = FIXTURE_BINARY_BASE + BigInt(0x400 + 0x40 + 16); @@ -642,8 +666,16 @@ export const createElfFixture = ( : []), ...symbolTableTags( symbols, - FIXTURE_BINARY_BASE + BigInt(fileSize - symbols.length), + FIXTURE_BINARY_BASE + BigInt(fileSize - symbols.length - hash.length), ), + ...(hash.length === 0 + ? [] + : [ + [4n, FIXTURE_BINARY_BASE + BigInt(fileSize - hash.length)] as [ + bigint, + bigint, + ], + ]), [0n, 0n], ]; const dynamic = Buffer.alloc(tags.length * 16); @@ -651,7 +683,7 @@ export const createElfFixture = ( dynamic.writeBigInt64LE(tag, index * 16); dynamic.writeBigUInt64LE(value, index * 16 + 8); }); - const file = Buffer.concat([body, rela, dynamic, symbols]); + const file = Buffer.concat([body, rela, dynamic, symbols, hash]); if (file.length !== fileSize) throw new Error("ELF fixture size drifted"); const withDynamic = options.dynamic !== false; // ELF64 header: little-endian shared object, no section headers.