From 8052f8daf0b4873a0a0e0d62827a9dca1eca7df7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:13:18 +0800 Subject: [PATCH] fix(elf): preserve virtual relocation identity (#9990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(elf): keep compressed storage out of decoded metadata facts * fix(elf): preserve virtual relocation identity Keep linked initializer backing and relocation values in their owning virtual or ET_REL section coordinates. Replace the IL2CPP physical-copy materializer with compact virtual value facts while preserving file-backed capacity. Reuse initializer repairs and original regressions from ShuaixinHuang PRs repair retains independent alias values instead of blanket alias refusal. Co-authored-by: 帅鑫 Huang <1836656514@qq.com> * fix(elf): admit compressed string tables the profile does not read SHF_COMPRESSED string tables are rejected only when they name sections or symbols. An unreferenced string table is not decoded, so it no longer makes an otherwise analyzable ELF unsupported. * fix(il2cpp): admit virtual pointer workspace before retention * fix(elf): reject null relocation target sections * fix(il2cpp): avoid decoding unrelated Mach-O words * fix(il2cpp): preserve uncertainty in candidate scans * fix(il2cpp): bound uncertain array candidates * fix(il2cpp): preserve unknown registration candidates * fix(il2cpp): classify type kinds before bound data * fix(il2cpp): bind candidate counts to matching names --------- Co-authored-by: Cursor Agent Co-authored-by: 帅鑫 Huang <1836656514@qq.com> --- docs/guides/native/native-initializers.md | 11 +- docs/il2cpp-metadata.md | 8 +- src/il2cpp/ElfRelocations.ts | 102 ----- src/il2cpp/Il2cppBinaryFormat.ts | 3 + src/il2cpp/Il2cppBinaryImage.ts | 88 ++-- src/il2cpp/Il2cppBinaryRegistration.ts | 408 +++++++++++++++--- src/il2cpp/Il2cppMetadataProvider.ts | 11 +- .../ElfInitializerRelocationCoverage.test.ts | 94 ++++ .../initializers/ElfInitializerSymbols.ts | 133 +++--- src/native/initializers/ElfInitializers.ts | 92 ++-- .../NativeInitializers.fixture.ts | 59 +++ .../NativeInitializersParsers.test.ts | 277 ++++++++++++ .../metadata/ElfPointerRelocations.test.ts | 66 ++- src/native/metadata/ElfPointerRelocations.ts | 343 +++++++++++---- .../acceptance/nativeInitializersCli.test.ts | 72 +++- .../boundary/il2cpp/il2cppBinaryFile.test.ts | 238 ++++++++++ .../il2cpp/il2cppMappingOwnership.test.ts | 146 ++++++- .../mcp/nativeInitializersMcp.test.ts | 75 ++++ 18 files changed, 1807 insertions(+), 419 deletions(-) delete mode 100644 src/il2cpp/ElfRelocations.ts create mode 100644 src/native/initializers/ElfInitializerRelocationCoverage.test.ts diff --git a/docs/guides/native/native-initializers.md b/docs/guides/native/native-initializers.md index 5f82d1edc5..002b871be4 100644 --- a/docs/guides/native/native-initializers.md +++ b/docs/guides/native/native-initializers.md @@ -54,7 +54,11 @@ Each `entries[]` item reports: limitation says so. - `value_va`, `value_file_offset` and `section`: the resolved target address, its file offset and its containing section. These are null when the value - was not established. A file offset is also null when load segments map the + was not established. In a linked ELF, file offsets come only from `PT_LOAD`: + an unmapped or zero-filled target keeps its observed address but has a null + file offset, even if a section header declares bytes at that address. ET_REL + targets retain their relocation symbol's section-relative coordinates. + A file offset is also null when load segments map the address ambiguously (overlapping segments with different file offsets, or file and zero-fill memory), and the report is then `incomplete`. - `symbol`: a defined symbol whose value equals the target exactly, or the @@ -98,6 +102,11 @@ table reaches only the section named by its `sh_info`. In a linked image, a relo linker, so the slot's stored word is used rather than applying the record again. +Relocation ownership follows virtual addresses in linked images and section +identity plus offset in relocatable objects. Distinct slots that share source +file bytes keep independent values and overlap checks; a write to one does not +relocate or invalidate the other. + An absolute relocation against an `STT_GNU_IFUNC` symbol is `unresolved`: the symbol value is the resolver, and the loader stores whatever the resolver returns. diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 26820afbad..5c0aff16d0 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -213,8 +213,8 @@ rejects it. The file is read, never loaded or executed. 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 writes those values into a private copy before -searching it, so addresses are the link-time addresses and RVAs are relative to +(`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 relocations (`DT_ANDROID_RELA`) are refused. No target code is executed. @@ -451,11 +451,11 @@ The builds checked are listed after the module search rules above. | `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. | -Format and selection failures name `binary_path`; capacity refusals remain typed resource errors. Failed linking preserves the metadata source identity, validated counts, selected binary path/architecture and its SHA-256 when observed, plus prior producer observations. It never publishes an incomplete native table as complete. The binary input is bounded to 1 GiB. +Format and selection failures name `binary_path`; capacity refusals remain typed resource errors. Failed linking preserves the metadata source identity, validated counts, selected binary path/architecture and its SHA-256 when observed, plus prior producer observations. It never publishes an incomplete native table as complete. The binary input is bounded to 1 GiB. The virtual-pointer index has the same 1 GiB estimated representation allowance, reserving 2 KiB per distinct virtual destination before insertion for retained Maps and finish-time lookup storage. Repeated declarations of one destination share that reservation. This conservative allocation accounting is separate from actual process RSS and the report allowance; exhaustion returns a typed `native-index-capacity` refusal before another destination is retained. Scans yield cooperatively in bounded batches. Name-address retention and reverse-trie construction are each limited to 65,536 entries/nodes and fallback module-array candidates to 64 (`native-index-capacity`); exceeding capacity does not prove absence. Rendered type text has a 64 MiB UTF-8 cache-workspace guard, separate from the 64 MiB encoded-report admission, and 64 nested rendering frames (`native-type-text-capacity` / `type-nesting-capacity`). Genuine cycles are malformed; unsupported type kinds are explicit coverage failures. Native row/text admission runs before allocation or retention. Unused generic parameter names are read on demand from the same stable metadata snapshot. There is no cross-inspection file cache; validated immutable Evidence is reused for session views. -ELF RELATIVE writes into zero-fill are kept as a private virtual-pointer overlay. Unresolved supported-width symbol relocations, including RELA PLT entries, remain unknown only for the bytes their ELF ABI relocation type covers; an adjacent 32-bit relocation does not make a separate 64-bit method slot unknown. Stored zeros never establish no-body. COPY, TLS descriptor, REL, packed APS2 and other unsupported relocation encodings fail closed. Input bytes are unchanged. +ELF RELATIVE writes use a virtual-pointer overlay for both file-backed and zero-fill locations. Distinct virtual addresses that share source file bytes retain independent values; an untouched alias keeps its stored bytes. Unresolved supported-width symbol relocations, including RELA PLT entries, remain unknown only for the bytes their ELF ABI relocation type covers; an adjacent 32-bit relocation does not make a separate 64-bit method slot unknown. Stored zeros never establish no-body. COPY, TLS descriptor, REL, packed APS2 and other unsupported relocation encodings fail closed. Input bytes are unchanged. ELF relocation tables and file-backed destinations may span adjacent load ranges when every range agrees on the file translation. Any overlapping zero-fill declaration makes those bytes ambiguous; REA refuses the table or relocation destination instead of choosing the stored file bytes. Missing file bytes remain distinct from conflicting owners: supported unresolved relocations outside file-backed memory retain their unknown overlay, while ambiguous destinations refuse linking. Registration coverage uses the same file/zero-fill ownership checks as pointer reads. diff --git a/src/il2cpp/ElfRelocations.ts b/src/il2cpp/ElfRelocations.ts deleted file mode 100644 index 5659360d51..0000000000 --- a/src/il2cpp/ElfRelocations.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { - FileMapping, - ZeroFillMapping, -} from "../domain/virtualFileMapping.js"; -import { - readElfRelocationWritesSteps, - ElfPointerRelocationFailure, -} from "../native/metadata/ElfPointerRelocations.js"; -import type { BinaryContainer } from "../native/metadata/BinaryContainer.js"; -import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js"; - -/** Materialize the shared loader facts only for IL2CPP's existing private reader. */ -export function* applyElfRelativeRelocationsSteps( - image: Buffer, - elf: Extract["elf"], - mappings: readonly FileMapping[], - zeroFills: readonly ZeroFillMapping[], -): Generator< - void, - { - readonly bytes: Buffer; - readonly unresolved: ReadonlyMap; - readonly zeroFillPointers: ReadonlyMap; - } -> { - try { - const steps = readElfRelocationWritesSteps( - image, - { - bits: 64, - little: true, - machine: elf.machine, - dynamicSegments: elf.dynamicSegments, - mappings, - zeroFills, - }, - { requireDynamic: true }, - ); - const bytes = Buffer.from(image), - zeroFillPointers = new Map(), - unresolved = new Map(); - // One bit per original file offset replaces per-relocation objects. Reads - // of implicit addends continue using image, never this mutable private copy. - const starts = Buffer.alloc(Math.ceil(image.length / 8)); - const wasWritten = (offset: number) => - offset >= 0 && - offset < image.length && - ((starts[Math.floor(offset / 8)] ?? 0) & (1 << (offset % 8))) !== 0; - const admitState = () => { - if (unresolved.size + zeroFillPointers.size >= 65_536) - throw new Il2cppBinaryFormatFailure( - "native-index-capacity", - "ELF unresolved and zero-fill overlay exceeds 65536 entries", - ); - }; - const unknown = (address: bigint, width: number) => { - if (!unresolved.has(address)) admitState(); - unresolved.set(address, Math.max(unresolved.get(address) ?? 0, width)); - }; - let next = steps.next(); - while (!next.done) { - const write = next.value; - if (write === undefined) { - yield; - next = steps.next(); - continue; - } - const at = write.file_offset; - if (write.value === null) { - unknown(write.address, write.width); - if (at !== null) bytes.fill(0, at, at + write.width); - } else if (at === null) { - for (let delta = -7; delta <= 7; delta++) { - const previous = zeroFillPointers.get(write.address + BigInt(delta)); - if ( - previous !== undefined && - (delta !== 0 || previous !== write.value) - ) - unknown(write.address, 8); - } - if (!zeroFillPointers.has(write.address)) admitState(); - zeroFillPointers.set(write.address, write.value); - } else { - for (let delta = -7; delta <= 7; delta++) - if ( - wasWritten(at + delta) && - (delta !== 0 || bytes.readBigUInt64LE(at) !== write.value) - ) - unknown(write.address, 8); - const byte = Math.floor(at / 8); - starts[byte] = (starts[byte] ?? 0) | (1 << (at % 8)); - bytes.writeBigUInt64LE(write.value, at); - } - next = steps.next(); - } - return { bytes, unresolved, zeroFillPointers }; - } catch (cause) { - if (cause instanceof ElfPointerRelocationFailure) - throw new Il2cppBinaryFormatFailure(cause.reason, cause.message); - throw cause; - } -} diff --git a/src/il2cpp/Il2cppBinaryFormat.ts b/src/il2cpp/Il2cppBinaryFormat.ts index 0cafed08df..41c387a58c 100644 --- a/src/il2cpp/Il2cppBinaryFormat.ts +++ b/src/il2cpp/Il2cppBinaryFormat.ts @@ -1,3 +1,6 @@ +/** Existing selected-binary allowance also bounds its retained pointer workspace. */ +export const IL2CPP_BINARY_BYTES = 1024 * 1024 * 1024; + export const IL2CPP_BINARY_FAILURE_REASONS = { unsupported: [ "unsupported-container", diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index b7b32a292d..e83a01b110 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -5,6 +5,7 @@ import { import { binaryMetadataArchitecture } from "../domain/binaryMetadataArchitecture.js"; import { BinaryFormatFailure, + BinaryMappingFailure, BinaryResourceFailure, createFileMappingIndex, mappedFileOffset, @@ -21,8 +22,15 @@ import { parseMachoLayout, UnsupportedMachoMetadataFormatError, } from "../native/AppleMachoSelection.js"; -import { applyElfRelativeRelocationsSteps } from "./ElfRelocations.js"; -import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js"; +import { + readElfPointerValuesSteps, + ElfPointerRelocationFailure, + type ElfVirtualPointerWrites, +} from "../native/metadata/ElfPointerRelocations.js"; +import { + Il2cppBinaryFormatFailure, + IL2CPP_BINARY_BYTES, +} from "./Il2cppBinaryFormat.js"; const MACHO_CPU = new Map([ [0x01000007, "amd64"], [0x0100000c, "arm64"], @@ -83,8 +91,8 @@ export interface NativeImage { /** 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; + /** ELF loader writes retain virtual identity; source bytes stay unchanged. */ + readonly elfPointerOverlay?: ElfVirtualPointerWrites; /** Mach-O loader fixups shared by every native pointer consumer. */ readonly machoPointerFixups?: PointerFixups; } @@ -93,7 +101,7 @@ export function* openImageSteps( bytes: Buffer, architecture: string | undefined, ): Generator { - let image = selectImage(bytes, architecture); + const image = selectImage(bytes, architecture); let container: BinaryContainer; try { container = readBinaryContainer(image); @@ -117,27 +125,28 @@ export function* openImageSteps( "unsupported-container", `only 64-bit little-endian images are supported, not ${container.bits}-bit ${container.byte_order}-endian`, ); - let relocated: - | { - bytes: Buffer; - unresolved: ReadonlyMap; - zeroFillPointers: ReadonlyMap; - } - | undefined; + let elfPointerOverlay: ElfVirtualPointerWrites | undefined; try { if (container.format === "elf") - relocated = yield* applyElfRelativeRelocationsSteps( + elfPointerOverlay = yield* readElfPointerValuesSteps( image, - container.elf, - container.mappings, - container.zero_fills, + { + bits: 64, + little: true, + machine: container.elf.machine, + dynamicSegments: container.elf.dynamicSegments, + mappings: container.mappings, + zeroFills: container.zero_fills, + }, + IL2CPP_BINARY_BYTES, ); } catch (cause) { + if (cause instanceof ElfPointerRelocationFailure) + throw new Il2cppBinaryFormatFailure(cause.reason, cause.message); if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure("malformed-container", cause.message); throw cause; } - if (relocated !== undefined) image = relocated.bytes; if (container.format === "macho" && container.macho_chained_fixups === true) throw new Il2cppBinaryFormatFailure( "unsupported-pointer-encoding", @@ -195,8 +204,7 @@ export function* openImageSteps( container.zero_fills, ), imageBase, - unresolvedPointerSlots: relocated?.unresolved ?? new Map(), - zeroFillPointers: relocated?.zeroFillPointers ?? new Map(), + ...(elfPointerOverlay === undefined ? {} : { elfPointerOverlay }), ...(machoPointerFixups === undefined ? {} : { machoPointerFixups }), }; } @@ -217,14 +225,13 @@ export const readUInt32 = ( image: NativeImage, address: bigint, ): number | "unknown" | null => { - if ( - hasUnresolvedElfBytes(image, address, 4) || - image.machoPointerFixups?.hasUnresolvedLegacyBytes(address, 4) === true - ) + if (image.machoPointerFixups?.hasUnresolvedLegacyBytes(address, 4) === true) return "unknown"; - const virtual = image.zeroFillPointers.get(address); - if (virtual !== undefined) return Number(virtual & 0xffffffffn); try { + if (image.elfPointerOverlay?.hasWrites(address, 4) === true) { + const value = image.elfPointerOverlay.unsignedAt(address, 4); + return value === undefined ? "unknown" : Number(value); + } return image.bytes.readUInt32LE(image.fileMappings.offset(address, 4)); } catch (cause) { if (cause instanceof BinaryFormatFailure) return null; @@ -314,34 +321,20 @@ export const isExecutable = (image: NativeImage, address: bigint): boolean => { } }; -const hasUnresolvedElfBytes = ( - image: NativeImage, - address: bigint, - size = 8, -): boolean => { - if (image.unresolvedPointerSlots.size === 0) return false; - for (let delta = -7; delta < size; delta++) { - const width = image.unresolvedPointerSlots.get(address + BigInt(delta)); - if (width !== undefined && delta + width > 0) return true; - } - return false; -}; - export const readSlot = ( image: NativeImage, address: bigint, ): bigint | "unknown" | null => { - if (hasUnresolvedElfBytes(image, address)) return "unknown"; try { - const virtual = image.zeroFillPointers.get(address); - let raw: bigint | null; - if (virtual !== undefined) raw = virtual; - 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 (image.elfPointerOverlay !== undefined) { + const value = image.elfPointerOverlay.unsignedAt(address, 8); + if (value !== undefined) return value; + if (image.elfPointerOverlay.hasWrites(address, 8)) return "unknown"; } + // A successful indexed read already excludes zero-fill aliases. Only a + // non-file-backed slot needs the zero-fill ownership walk. + let 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); @@ -356,6 +349,7 @@ export const readSlot = ( } return raw; } catch (cause) { + if (cause instanceof BinaryMappingFailure) return null; if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure("malformed-container", cause.message); throw cause; diff --git a/src/il2cpp/Il2cppBinaryRegistration.ts b/src/il2cpp/Il2cppBinaryRegistration.ts index ed14a452e3..cacec8df0f 100644 --- a/src/il2cpp/Il2cppBinaryRegistration.ts +++ b/src/il2cpp/Il2cppBinaryRegistration.ts @@ -23,6 +23,72 @@ import type { Il2cppCodeGenModule } from "./Il2cppBinaryFile.js"; const TOTAL_NAME_OCCURRENCES = 65536; const SCAN_BYTES = 65536; +/** Index candidates by untouched bytes, retaining bounded representation space. */ +const unresolvedPointerMatcher = ( + image: NativeImage, + candidates: Iterable, + candidateCounts?: (candidate: bigint) => Iterable, +): (( + address: bigint, + raw: bigint, + count?: number, +) => number | null | undefined) => { + const fixups = image.machoPointerFixups; + if (fixups === undefined) return () => undefined; + const values = [...candidates]; + const masks = new Map< + bigint, + Map; minimum: number }> | null + >(); + const maximumEntries = 8 * TOTAL_NAME_OCCURRENCES; + let indexedEntries = 0; + return (address, raw, count) => { + if (!fixups.hasUnresolvedLegacyBytes(address, 8)) return undefined; + let knownMask = 0n; + for (let byte = 0; byte < 8; byte++) + if (!fixups.hasUnresolvedLegacyBytes(address + BigInt(byte), 1)) + knownMask |= 0xffn << BigInt(byte * 8); + let matches = masks.get(knownMask); + if (matches === undefined) { + // Beyond the retained index budget applicability remains unknown. + const index = new Map; minimum: number }>(); + let entries = 0; + for (const candidate of values) { + const key = candidate & knownMask; + let candidateSet = index.get(key); + if (candidateSet === undefined) { + candidateSet = { + counts: new Set(), + minimum: Number.POSITIVE_INFINITY, + }; + index.set(key, candidateSet); + entries++; + } + for (const value of candidateCounts?.(candidate) ?? []) + if (!candidateSet.counts.has(value)) { + candidateSet.counts.add(value); + candidateSet.minimum = Math.min(candidateSet.minimum, value); + entries++; + } + if (entries > maximumEntries - indexedEntries) { + masks.set(knownMask, null); + return null; + } + } + matches = index; + indexedEntries += entries; + masks.set(knownMask, matches); + } + if (matches === null) return null; + const candidateSet = matches.get(raw & knownMask); + if (candidateSet === undefined) return undefined; + if (candidateCounts === undefined) return null; + if (count !== undefined) + return candidateSet.counts.has(count) ? count : undefined; + return candidateSet.minimum; + }; +}; + interface NameTrie { readonly next: Map; readonly owners: number[]; @@ -90,6 +156,8 @@ function* nameAddresses( if (node === undefined) break; if (node.owners.length === 0) continue; const address = mapping.address + BigInt(at); + if (image.elfPointerOverlay?.hasWrites(address, chunk + end - at + 1)) + continue; if (!found.has(address) && found.size >= TOTAL_NAME_OCCURRENCES) throw new Il2cppBinaryFormatFailure( "native-index-capacity", @@ -133,18 +201,73 @@ export function* findModules( metadata.images.map((entry) => entry.name), ); const counts = yield* imageMethodCounts(metadata); + const countSet = new Set(counts); + const minimumCount = counts.reduce( + (minimum, count) => Math.min(minimum, count), + Number.POSITIVE_INFINITY, + ); const candidates = new Map(); + const unresolvedName = unresolvedPointerMatcher(image, names.keys(), (name) => + (names.get(name) ?? []).map((index) => counts[index]!), + ); for (const mapping of image.container.mappings) { const start = Number((8n - (mapping.address % 8n)) % 8n); for (let at = start; at + 24 <= mapping.size; at += 8) { if ((at & 0xffff) < 8) yield; const offset = mapping.offset + at; - const target = image.bytes.readBigUInt64LE(offset); + const address = mapping.address + BigInt(at); + const raw = image.bytes.readBigUInt64LE(offset); + const elfSlot = + image.elfPointerOverlay === undefined + ? undefined + : readSlot(image, address); + const unresolvedLegacy = + image.machoPointerFixups?.hasUnresolvedLegacyBytes(address, 8) === true; + if (elfSlot === "unknown" || unresolvedLegacy) { + const count = readUInt32(image, address + 8n); + let plausibleCount = + count === "unknown" + ? minimumCount + : count !== null && countSet.has(count) + ? count + : Number.POSITIVE_INFINITY; + if (elfSlot !== "unknown" && Number.isFinite(plausibleCount)) { + const matched = unresolvedName( + address, + raw, + count === "unknown" || count === null ? undefined : count, + ); + plausibleCount = + matched === undefined + ? Number.POSITIVE_INFINITY + : (matched ?? plausibleCount); + } + if (Number.isFinite(plausibleCount)) { + const pointers = readSlot(image, address + 16n); + if ( + pointers === "unknown" || + (typeof pointers === "bigint" && + hasPointerArrayCoverage(image, pointers, plausibleCount)) + ) + throw new Il2cppBinaryFormatFailure( + "code-gen-modules-not-found", + "A loader-owned module name pointer could match an additional module record", + ); + } + } + if (unresolvedLegacy) continue; + // Non-ELF images retain their stored bytes. Nominate before decoding so + // unrelated partial fixups do not invalidate the module search. + if (image.elfPointerOverlay === undefined && !names.has(raw)) continue; + const target = + elfSlot === undefined + ? readPointer(image, address) + : typeof elfSlot === "bigint" + ? elfSlot + : null; + if (target === null) continue; const indices = names.get(target); if (indices === undefined) continue; - const address = mapping.address + BigInt(at); - // Matching stored bytes nominate a candidate, not a resolved pointer. - if (readPointer(image, address) !== target) continue; let recordOffset: number; try { recordOffset = mappedFileOffset( @@ -170,10 +293,20 @@ export function* findModules( for (const index of indices) { if (++work % 256 === 0) yield; const count = readUInt32(image, address + 8n); - if (count !== counts[index]) continue; - const pointers = readPointer(image, address + 16n); + if (count !== "unknown" && count !== counts[index]) continue; + const pointers = readSlot(image, address + 16n); + if (pointers === "unknown") + throw new Il2cppBinaryFormatFailure( + "code-gen-modules-not-found", + "A matching module record has an unresolved method pointer array", + ); if (pointers === null) continue; - if (!hasPointerArrayCoverage(image, pointers, count)) continue; + if (!hasPointerArrayCoverage(image, pointers, counts[index]!)) continue; + if (count === "unknown") + throw new Il2cppBinaryFormatFailure( + "code-gen-modules-not-found", + "A matching module record has an unresolved method count", + ); const list = candidates.get(index) ?? []; if (list.some((candidate) => candidate.address === address)) continue; if (list.length !== 0) @@ -223,22 +356,41 @@ export function* findCodeRegistration( const start = Number((8n - (mapping.address % 8n)) % 8n); for (let at = start; at + 16 <= mapping.size; at += 8) { if ((at & 0xffff) < 8) yield; - const offset = mapping.offset + at; // The count owns four bytes; the following four only align its pointer. - if (image.bytes.readUInt32LE(offset) !== modules.length) continue; const field = mapping.address + BigInt(at); - if (readUInt32(image, field) !== modules.length) continue; - const array = readPointer(image, field + 8n); + const count = readUInt32(image, field); + if (count !== "unknown" && count !== modules.length) continue; + const arraySlot = readSlot(image, field + 8n); + if (arraySlot === "unknown") + throw new Il2cppBinaryFormatFailure( + "code-registration-not-found", + "A possible CodeRegistration has an unresolved module array", + ); + const array = arraySlot; if (array === null || !isMapped(image, array, modules.length * 8)) continue; const seen = new Set(); + let uncertain = count === "unknown"; + let viable = true; for (let slot = 0; slot < modules.length; slot++) { if (slot % 256 === 0) yield; - const pointer = readPointer(image, array + BigInt(slot * 8)); - if (pointer === null || !expected.has(pointer)) break; + const pointer = readSlot(image, array + BigInt(slot * 8)); + if (pointer === "unknown") { + uncertain = true; + continue; + } + if (pointer === null || !expected.has(pointer) || seen.has(pointer)) { + viable = false; + break; + } seen.add(pointer); } - if (seen.size === modules.length) { + if (viable && uncertain) + throw new Il2cppBinaryFormatFailure( + "code-registration-not-found", + "A possible CodeRegistration has unresolved candidate fields", + ); + if (viable && seen.size === modules.length) { // Exact load aliases can visit one virtual registration field twice. if (matchedFields.has(field)) continue; matchedFields.add(field); @@ -286,16 +438,83 @@ function* findModuleArrayByCode( expected: ReadonlySet, ): Generator { const arrays = new Set(); + const unresolvedModule = unresolvedPointerMatcher(image, expected); + let expectedXor = 0n; + for (const candidate of expected) expectedXor ^= candidate; for (const mapping of image.container.mappings) { const start = Number((8n - (mapping.address % 8n)) % 8n); let runStart = start; const lastPosition = new Map(); + const possible = new Map(); + const occurrences = new Map(); + const unknownPositions = new Set(); + let possibleXor = 0n; + let duplicateValues = 0; for (let at = start; at + 8 <= mapping.size; at += 8) { if ((at & 0xffff) < 8) yield; const raw = image.bytes.readBigUInt64LE(mapping.offset + at); - const value = expected.has(raw) - ? readPointer(image, mapping.address + BigInt(at)) - : null; + const address = mapping.address + BigInt(at); + const elfSlot = + image.elfPointerOverlay === undefined + ? undefined + : readSlot(image, address); + const unknown = + elfSlot === "unknown" || unresolvedModule(address, raw) !== undefined; + const value = + !unknown && (image.elfPointerOverlay !== undefined || expected.has(raw)) + ? elfSlot === undefined + ? readPointer(image, address) + : typeof elfSlot === "bigint" + ? elfSlot + : null + : null; + if (!unknown && (value === null || !expected.has(value))) { + possible.clear(); + occurrences.clear(); + unknownPositions.clear(); + possibleXor = 0n; + duplicateValues = 0; + } else { + const candidate = unknown ? null : value; + possible.set(at, candidate); + if (candidate === null) unknownPositions.add(at); + else { + possibleXor ^= candidate; + const count = (occurrences.get(candidate) ?? 0) + 1; + occurrences.set(candidate, count); + if (count === 2) duplicateValues++; + } + const expired = at - expected.size * 8; + if (possible.has(expired)) { + const previous = possible.get(expired)!; + possible.delete(expired); + if (previous === null) unknownPositions.delete(expired); + else { + possibleXor ^= previous; + const count = occurrences.get(previous)!; + if (count === 2) duplicateValues--; + if (count === 1) occurrences.delete(previous); + else occurrences.set(previous, count - 1); + } + } + if ( + possible.size === expected.size && + unknownPositions.size > 0 && + duplicateValues === 0 + ) { + if (unknownPositions.size === 1) { + const unknownAt = unknownPositions.values().next().value!; + if ( + image.elfPointerOverlay !== undefined || + unresolvedPointerMatcher(image, [expectedXor ^ possibleXor])( + mapping.address + BigInt(unknownAt), + image.bytes.readBigUInt64LE(mapping.offset + unknownAt), + ) !== undefined + ) + return null; + } else return null; + } + } if (value === null || !expected.has(value)) { runStart = at + 8; lastPosition.clear(); @@ -331,16 +550,28 @@ function* ripRelativeReferences( const referenced = new Set(); for (const mapping of image.container.executable_mappings ?? []) { const bytes = image.bytes; + const loaded = + image.elfPointerOverlay?.hasWrites(mapping.address, mapping.size) === + true; const end = mapping.offset + mapping.size - 7; for (let at = mapping.offset; at <= end; at++) { if ((at & 0xffff) < 8) yield; - const rex = bytes[at]!; + const address = mapping.address + BigInt(at - mapping.offset); + const prefix = loaded + ? readUInt32(image, address) + : bytes.readUInt32LE(at); + if (typeof prefix !== "number") continue; + const rex = prefix & 0xff; if ((rex & 0xfb) !== 0x48) continue; // REX.W, optionally REX.R - const opcode = bytes[at + 1]!; + const opcode = (prefix >>> 8) & 0xff; if (opcode !== 0x8d && opcode !== 0x8b) continue; - if ((bytes[at + 2]! & 0xc7) !== 0x05) continue; // mod 00, rm 101: RIP + disp32 + if (((prefix >>> 16) & 0xc7) !== 0x05) continue; // mod 00, rm 101: RIP + disp32 const next = mapping.address + BigInt(at - mapping.offset + 7); - const target = next + BigInt(bytes.readInt32LE(at + 3)); + const displacement = loaded + ? readUInt32(image, address + 3n) + : bytes.readUInt32LE(at + 3); + if (typeof displacement !== "number") continue; + const target = next + BigInt(displacement | 0); if (arrays.has(target)) referenced.add(target); } } @@ -361,12 +592,22 @@ function* arm64PageReferences( const referenced = new Set(); const bytes = image.bytes; for (const mapping of image.container.executable_mappings ?? []) { + const loaded = + image.elfPointerOverlay?.hasWrites(mapping.address, mapping.size) === + true; 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); + const address = mapping.address + BigInt(at); + const adrp = loaded + ? readUInt32(image, address) + : bytes.readUInt32LE(mapping.offset + at); + if (typeof adrp !== "number") continue; if ((adrp & 0x9f000000) >>> 0 !== 0x90000000) continue; - const add = bytes.readUInt32LE(mapping.offset + at + 4); + const add = loaded + ? readUInt32(image, address + 4n) + : bytes.readUInt32LE(mapping.offset + at + 4); + if (typeof add !== "number") continue; 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. @@ -409,37 +650,49 @@ export function* findMetadataRegistration( // 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); // The fields used below end at byte 112. The full 128-byte registration // must still have loader coverage, including any declared zero-fill tail. for (let at = start; at + 112 <= mapping.size; at += 8) { if ((at & 0xffff) < 8) yield; - const base = mapping.offset + at; - if (low(base + 80) !== typeDefinitions) continue; - if (low(base + 96) !== typeDefinitions) continue; const address = mapping.address + BigInt(at); + const fieldCount = readUInt32(image, address + 80n); + if (fieldCount !== "unknown" && fieldCount !== typeDefinitions) continue; + const sizeCount = readUInt32(image, address + 96n); + if (sizeCount !== "unknown" && sizeCount !== typeDefinitions) continue; if (!hasPointerArrayCoverage(image, address, 16)) continue; - if (readUInt32(image, address + 80n) !== typeDefinitions) continue; - if (readUInt32(image, address + 96n) !== typeDefinitions) continue; - countsMatched = true; + if (fieldCount !== "unknown" && sizeCount !== "unknown") + countsMatched = true; const count = readUInt32(image, address + 48n); - const types = readPointer(image, address + 56n); - const fieldOffsets = readPointer(image, address + 88n); - const typeSizes = readPointer(image, address + 104n); + const types = readSlot(image, address + 56n); + const fieldOffsets = readSlot(image, address + 88n); + const typeSizes = readSlot(image, address + 104n); if ( - typeof count !== "number" || - count === 0 || - count > 0x7fffffff || + count === null || + (typeof count === "number" && (count === 0 || count > 0x7fffffff)) || types === null || fieldOffsets === null || typeSizes === null || - !isMapped(image, fieldOffsets, 8) || - !isMapped(image, typeSizes, 8) || - !isMapped(image, types, count * 8) + (typeof fieldOffsets === "bigint" && + !isMapped(image, fieldOffsets, 8)) || + (typeof typeSizes === "bigint" && !isMapped(image, typeSizes, 8)) || + (typeof types === "bigint" && + !isMapped(image, types, typeof count === "number" ? count * 8 : 8)) ) continue; + if ( + fieldCount === "unknown" || + sizeCount === "unknown" || + count === "unknown" || + types === "unknown" || + fieldOffsets === "unknown" || + typeSizes === "unknown" + ) + throw new Il2cppBinaryFormatFailure( + "metadata-registration-not-found", + "A possible MetadataRegistration has unresolved candidate fields", + ); const field = mapping.address + BigInt(at + 48); // Exact load aliases can visit one virtual registration field twice. if (matchedFields.has(field)) continue; @@ -503,7 +756,7 @@ function* findTypeTableByCode( typeDefinitions: number, requiredTypes: number, ): Generator { - const { bytes, container } = image; + const { 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 @@ -547,25 +800,15 @@ function* findTypeTableByCode( 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. + // 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 (target === 0n || (target & 7n) !== 0n) return "other"; if (recordOffset(target) < 0) return "other"; const attributes = readUInt32(image, target + 8n); if (attributes === "unknown") return "unknown"; @@ -592,24 +835,61 @@ function* findTypeTableByCode( const start = Number((8n - (mapping.address % 8n)) % 8n); let runStart = -1; let startUnknown = false; + let possibleLength = 0; + let possibleUnknown = 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); + const raw = image.bytes.readBigUInt64LE(mapping.offset + at); + let state: "type" | "other" | "unknown"; + if (image.elfPointerOverlay === undefined) { + if ( + image.machoPointerFixups?.hasUnresolvedLegacyBytes(address, 8) === + true + ) + state = "unknown"; + else { + const rawState = recordState(raw); + if (rawState !== "type") state = rawState; + else { + const slot = readSlot(image, address); + state = + slot === raw + ? rawState + : typeof slot === "bigint" + ? recordState(slot) + : slot === "unknown" + ? "unknown" + : "other"; } - continue; } - endUnknown = slot === "unknown" || state === "unknown"; - } else if (runStart >= 0) endUnknown = unresolved(address); + } else { + const slot = readSlot(image, address); + state = + typeof slot === "bigint" + ? recordState(slot) + : slot === "unknown" + ? "unknown" + : "other"; + } + if (state === "other") { + possibleLength = 0; + possibleUnknown = false; + } else { + possibleLength += 1; + possibleUnknown ||= state === "unknown"; + if (possibleUnknown && possibleLength >= requiredTypes) return null; + } + if (state === "type") { + if (runStart < 0) { + runStart = at; + startUnknown = at === start || unresolved(address - 8n); + } + continue; + } + endUnknown = state === "unknown"; } if (runStart >= 0 && (at - runStart) / 8 >= requiredTypes) { runs.set( diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 996e0a480b..6ae3c3e8cd 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -1,6 +1,9 @@ import { IL2CPP_METADATA_PROVIDER_IDENTITY } from "./Il2CppMetadataProviderIdentity.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; -import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js"; +import { + Il2cppBinaryFormatFailure, + IL2CPP_BINARY_BYTES, +} from "./Il2cppBinaryFormat.js"; import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js"; import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; @@ -59,8 +62,6 @@ import { const OPERATION = "inspect_il2cpp_metadata"; const INPUT_BYTES = 256 * 1024 * 1024; -// GameAssembly binaries of large games reach several hundred megabytes. -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. */ @@ -523,7 +524,7 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { try { const binary = await this.#readArtifact( binaryPath, - BINARY_BYTES, + IL2CPP_BINARY_BYTES, options?.signal, (artifact) => { observed.binary = artifact; @@ -556,7 +557,7 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { cause, binaryPath, "binary_path", - BINARY_BYTES, + IL2CPP_BINARY_BYTES, input.path, ); failure.partialObservation = metadataSourceEvidence( diff --git a/src/native/initializers/ElfInitializerRelocationCoverage.test.ts b/src/native/initializers/ElfInitializerRelocationCoverage.test.ts new file mode 100644 index 0000000000..f1105f1afa --- /dev/null +++ b/src/native/initializers/ElfInitializerRelocationCoverage.test.ts @@ -0,0 +1,94 @@ +import { expect, it } from "vitest"; +import { sectionRelocations } from "./ElfInitializerSymbols.js"; +import { InitializerNotes } from "./initializerNotes.js"; +import type { NativeSection } from "../metadata/NativeSectionDirectory.js"; + +const section = (changes: Partial = {}): NativeSection => ({ + index: 0, + name: null, + name_state: "unavailable", + header_offset: 0, + offset: 0n, + size: 24n, + address: 0n, + type: 1, + flags: 0n, + link: 0, + info: 0, + entry_size: 0n, + alignment: 1n, + file_backed: true, + compressed: false, + ...changes, +}); + +it.each([ + ["compressed", { compressed: true }], + ["unexpected record size", { entry_size: 8n }], + ["unreadable target", {}], +] as const)( + "does not certify raw initializer values for %s relocations with an unknown target", + async (_case, changes) => { + const target = section(); + const unrelated = section(); + const relocation = section({ type: 4, info: 42, ...changes }); + const result = await sectionRelocations({ + bytes: Buffer.alloc(32), + bits: 64, + little: true, + machine: 62, + sections: [target, unrelated, relocation], + relocatable: true, + wanted: [{ va: 0n, sectionIndex: 0 }], + tables: new Map(), + notes: new InitializerNotes(), + fileOffset: () => null, + }); + expect(result.complete).toBe(false); + }, +); + +it("keeps an unrelated known section from invalidating a wanted slot", async () => { + const sections = [ + section(), + section(), + section({ type: 4, info: 1, compressed: true }), + ]; + const result = await sectionRelocations({ + bytes: Buffer.alloc(32), + bits: 64, + little: true, + machine: 62, + sections, + relocatable: true, + wanted: [{ va: 0n, sectionIndex: 0 }], + tables: new Map(), + notes: new InitializerNotes(), + fileOffset: () => null, + }); + expect(result.complete).toBe(true); +}); + +it.each([true, false])( + "keeps coverage unknown for zero sh_info with compressed=%s", + async (compressed) => { + const sections = [ + section({ type: 0, file_backed: false }), + section(), + section({ type: 4, info: 0, compressed }), + ]; + const result = await sectionRelocations({ + bytes: Buffer.alloc(32), + bits: 64, + little: true, + machine: 62, + sections, + relocatable: true, + wanted: [{ va: 0n, sectionIndex: 1 }], + tables: new Map(), + notes: new InitializerNotes(), + fileOffset: () => null, + }); + expect(result.complete).toBe(false); + }, +); diff --git a/src/native/initializers/ElfInitializerSymbols.ts b/src/native/initializers/ElfInitializerSymbols.ts index bb44171cd2..3f19b46f24 100644 --- a/src/native/initializers/ElfInitializerSymbols.ts +++ b/src/native/initializers/ElfInitializerSymbols.ts @@ -337,12 +337,27 @@ export const relocationFromEntry = ( }; }; +/** A slot's relocation identity is its virtual address, or its section and address in ET_REL. */ +export interface ElfInitializerSlot { + readonly va: bigint; + readonly sectionIndex: number | null; +} + +/** One coordinate rule shared by relocation production and slot lookup. */ +export const elfInitializerSlotKey = ( + slot: ElfInitializerSlot, + relocatable: boolean, +): string => + relocatable + ? sectionSymbolKey(slot.sectionIndex ?? -1, slot.va) + : slot.va.toString(16); + interface SectionRelocInput extends ImageFacts { readonly sections: readonly NativeSection[]; /** ET_REL: `r_offset` is relative to the section named by `sh_info`. */ readonly relocatable: boolean; - /** File offsets of slots not covered by dynamic relocations. */ - readonly wanted: ReadonlySet; + /** Slots not covered by dynamic relocations; source offsets do not establish write ownership. */ + readonly wanted: readonly ElfInitializerSlot[]; readonly tables: ReadonlyMap; readonly notes: InitializerNotes; /** File offset of `width` bytes at a linked-image address. */ @@ -379,15 +394,15 @@ const recordTarget = ( /** Index of the first wanted slot whose word could overlap a write starting at `at`. */ const firstOverlap = ( - wanted: readonly number[], - at: number, + wanted: readonly ElfInitializerSlot[], + at: bigint, word: number, ): number => { let low = 0; let high = wanted.length; while (low < high) { const mid = (low + high) >>> 1; - if ((wanted[mid] ?? at) <= at - word) low = mid + 1; + if ((wanted[mid]?.va ?? at) <= at - BigInt(word)) low = mid + 1; else high = mid; } return low; @@ -395,9 +410,9 @@ const firstOverlap = ( /** Section relocations found for wanted slots, and whether every applicable record was read. */ export interface SectionRelocationScan { - readonly found: ReadonlyMap; + readonly found: ReadonlyMap; /** Slots a narrower, offset or repeated record overlaps; their value is unknown. */ - readonly clobbered: ReadonlySet; + readonly clobbered: ReadonlySet; /** * False when a relocation section was skipped, ended early or lies past the * record budget, so an unmatched slot may still have an unread relocation. @@ -407,32 +422,6 @@ export interface SectionRelocationScan { const SHF_ALLOC = 2n; -/** - * Whether records in `section` could reach a wanted slot. ET_REL records only - * reach the section named by `sh_info`; a linked image's records may reach any - * address. - */ -const reachesWanted = ( - input: SectionRelocInput, - wanted: readonly number[], - section: NativeSection, -): boolean => { - if (!input.relocatable) return true; - const target = input.sections[section.info]; - if (target === undefined) return true; - if (!target.file_backed) return false; - const from = Number(target.offset); - const to = from + Number(target.size); - let low = 0; - let high = wanted.length; - while (low < high) { - const mid = (low + high) >>> 1; - if ((wanted[mid] ?? to) < from) low = mid + 1; - else high = mid; - } - return (wanted[low] ?? to) < to; -}; - /** Relocation sections this reader applies: every one in ET_REL, SHF_ALLOC ones otherwise. */ const applicable = ( input: SectionRelocInput, @@ -446,21 +435,48 @@ const applicable = ( /** * Section REL/RELA records for slots that dynamic tables do not cover, keyed by - * slot file offset. In a linked image, a relocation section without SHF_ALLOC + * slot identity. In a linked image, a relocation section without SHF_ALLOC * is a link-time record the static linker already applied (for example from * `--emit-relocs`), so the slot already holds its result and is not reapplied. */ export const sectionRelocations = async ( input: SectionRelocInput, ): Promise => { - const found = new Map(); - const clobbered = new Set(); - if (input.wanted.size === 0) return { found, clobbered, complete: true }; - const wanted = [...input.wanted].sort((left, right) => left - right); + const found = new Map(); + const clobbered = new Set(); + if (input.wanted.length === 0) return { found, clobbered, complete: true }; + // Linked writes use virtual addresses; ET_REL writes additionally belong to + // sh_info's section, even when two sections reuse the same file bytes. + const bySection = new Map>(); + for (const slot of input.wanted) { + const key = input.relocatable ? slot.sectionIndex : null; + const slots = bySection.get(key) ?? new Map(); + slots.set(slot.va, slot); + bySection.set(key, slots); + } + const sorted = new Map( + [...bySection].map(([section, slots]) => [ + section, + [...slots.values()].sort((left, right) => + left.va < right.va ? -1 : left.va > right.va ? 1 : 0, + ), + ]), + ); + const wantedBy = (section: NativeSection): readonly ElfInitializerSlot[] => + sorted.get(input.relocatable ? section.info : null) ?? []; + const targetSection = (section: NativeSection): NativeSection | undefined => { + if (!input.relocatable || section.info === 0) return undefined; + const target = input.sections[section.info]; + return target?.type === 0 ? undefined : target; + }; const word = wordOf(input.bits); let complete = true; const skip = (section: NativeSection): void => { - if (reachesWanted(input, wanted, section)) complete = false; + if ( + (input.relocatable && targetSection(section) === undefined) || + wantedBy(section).length > 0 + ) + complete = false; }; // One budget across every header, so many headers over one range cannot multiply the work. let budget = SECTION_RELOCATION_RECORD_SCAN; @@ -484,11 +500,13 @@ export const sectionRelocations = async ( continue; } const symbols = input.tables.get(section.link); - const target = input.relocatable ? input.sections[section.info] : undefined; + const target = targetSection(section); + const wanted = wantedBy(section); if (input.relocatable && target === undefined) { input.notes.add( "A relocation section names a target section that does not exist, so its records were not applied.", ); + skip(section); continue; } const start = Number(section.offset); @@ -520,13 +538,18 @@ export const sectionRelocations = async ( // An unknown type is assumed to write a whole word, so it still marks what it may touch. const span = elfRelocationWidth(input.machine, parsed.value.type) ?? word; if (span === 0) continue; - const written = recordTarget(input, target, parsed.value.offset, 1); - if (written === null) { + if (recordTarget(input, target, parsed.value.offset, 1) === null) { skip(section); continue; } - const fullWrite = - recordTarget(input, target, parsed.value.offset, span) !== null; + const written = parsed.value.offset + (target?.address ?? 0n); + const writtenFileOffset = recordTarget( + input, + target, + parsed.value.offset, + span, + ); + const fullWrite = writtenFileOffset !== null; if (!fullWrite) { complete = false; input.notes.add( @@ -539,26 +562,32 @@ export const sectionRelocations = async ( next += 1 ) { const slot = wanted[next]; - if (slot === undefined || slot >= written + span) break; - if (clobbered.has(slot)) continue; + if (slot === undefined || slot.va >= written + BigInt(span)) break; + const key = elfInitializerSlotKey(slot, input.relocatable); + if (clobbered.has(key)) continue; if ( fullWrite && - slot === written && + slot.va === written && span === word && - !found.has(slot) + !found.has(key) ) { const inplace = encoding === "rel" - ? readPointerWord(input.bytes, slot, input.bits, input.little) + ? readPointerWord( + input.bytes, + writtenFileOffset, + input.bits, + input.little, + ) : null; found.set( - slot, + key, relocationFromEntry(input, parsed.value, symbols, inplace), ); continue; } - found.delete(slot); - clobbered.add(slot); + found.delete(key); + clobbered.add(key); } } if (stopped) { diff --git a/src/native/initializers/ElfInitializers.ts b/src/native/initializers/ElfInitializers.ts index e41758e2a6..79263f7e02 100644 --- a/src/native/initializers/ElfInitializers.ts +++ b/src/native/initializers/ElfInitializers.ts @@ -27,12 +27,14 @@ import { readElfDirectoryFromBuffer } from "../metadata/NativeSectionDirectory.j import type { NativeSection } from "../metadata/NativeSectionDirectory.js"; import { loadElfSymbolIndex, + elfInitializerSlotKey, readPointerWord, relocationFromEntry, sectionRelocations, sectionSymbolKey, type ElfInitializerSymbol, type SlotRelocation, + type ElfInitializerSlot, } from "./ElfInitializerSymbols.js"; import { finishInitializerReport, @@ -77,7 +79,7 @@ interface DynTag { readonly valueOffset: number; } -interface ArraySlot { +interface ArraySlot extends ElfInitializerSlot { readonly mappingMismatch?: boolean; readonly va: bigint; readonly fileOffset: number | null; @@ -193,14 +195,13 @@ const readDynamicTags = ( /** * File offset of `size` bytes at `address`: a whole slot, or a target's first - * byte. `mappings` is built once per image, so each lookup is logarithmic. Only - * a genuinely unmapped span falls back to section headers; an ambiguous one - * stays unknown and makes the report incomplete. + * byte. Only load mappings establish linked addresses: section declarations + * cannot supply backing for an unmapped or zero-filled address. Ambiguous + * mappings stay unknown and make the report incomplete. */ const fileOffsetFor = ( scan: ElfScan, mappings: FileMappingIndex, - index: ElfSectionIndex, address: bigint, size: number, ): number | null => { @@ -208,11 +209,8 @@ const fileOffsetFor = ( return mappings.offset(address, size); } catch (cause) { if (!(cause instanceof BinaryMappingFailure)) throw cause; - if (cause.reason === "ambiguous") { - note(scan, AMBIGUOUS_MAPPING); - return null; - } - return index.fileOffsetAt(address, size); + if (cause.reason === "ambiguous") note(scan, AMBIGUOUS_MAPPING); + return null; } }; @@ -234,22 +232,16 @@ const vaForFile = ( return null; }; -/** Address lookups over the section headers, built once per image. */ +/** Section-name labels, independent of load-address translation. */ interface ElfSectionIndex { /** Name of the smallest SHF_ALLOC section containing `address`; earlier headers win ties. */ readonly nameAt: (address: bigint) => string | null; - /** - * File offset of `width` bytes at `address` in the first file-backed section, - * in header order, that covers `address`. Null when that section ends before - * the span does. - */ - readonly fileOffsetAt: (address: bigint, width: number) => number | null; } const indexSections = (sections: readonly NativeSection[]): ElfSectionIndex => { - const ranges = (keep: (section: NativeSection) => boolean) => + const named = buildAddressRangeIndex( sections.flatMap((section) => - keep(section) + section.size !== 0n && (section.flags & 2n) !== 0n ? [ { start: section.address, @@ -258,9 +250,7 @@ const indexSections = (sections: readonly NativeSection[]): ElfSectionIndex => { }, ] : [], - ); - const named = buildAddressRangeIndex( - ranges((section) => section.size !== 0n && (section.flags & 2n) !== 0n), + ), (left, right) => left.size < right.size ? -1 @@ -268,24 +258,11 @@ const indexSections = (sections: readonly NativeSection[]): ElfSectionIndex => { ? 1 : left.index - right.index, ); - const backed = buildAddressRangeIndex( - ranges((section) => section.file_backed && !section.compressed), - (left, right) => left.index - right.index, - ); return { nameAt(address) { const name = named.at(address)?.value.name; return name == null || name.length === 0 ? null : name; }, - fileOffsetAt(address, width) { - const section = backed.at(address)?.value; - if ( - section === undefined || - address + BigInt(width) > section.address + section.size - ) - return null; - return Number(section.offset) + Number(address - section.address); - }, }; }; @@ -296,7 +273,8 @@ const slotsFromSpan = ( label: string, locate: (address: bigint) => number | null, sectionName: string | null, -): Slot[] => { + sectionIndex: number | null = null, +): ArraySlot[] => { const stride = BigInt(wordOf(scan.bits)); if (byteSize < 0n || byteSize % stride !== 0n) note( @@ -315,7 +293,7 @@ const slotsFromSpan = ( scan, `${label} declares ${declared.toString()} slots. Scanning stopped at the ${String(MAX_NATIVE_INITIALIZER_ENTRIES)}-slot budget, so later slots were not read.`, ); - const slots: Slot[] = []; + const slots: ArraySlot[] = []; let missed = false; for (let index = 0; index < count; index += 1) { const va = base + BigInt(index) * stride; @@ -324,7 +302,7 @@ const slotsFromSpan = ( missed = true; continue; } - slots.push({ va, fileOffset, sectionName }); + slots.push({ va, fileOffset, sectionName, sectionIndex }); } if (missed) note( @@ -397,6 +375,7 @@ const sectionSource = ( label, withinSection(section, wordOf(scan.bits)), section.name, + section.index, ), }; }; @@ -857,8 +836,8 @@ const collectEntries = ( scan: ElfScan, sources: readonly Source[], loaded: WriteLoad, - sectionRels: ReadonlyMap, - sectionClobbered: ReadonlySet, + sectionRels: ReadonlyMap, + sectionClobbered: ReadonlySet, symbols: readonly ElfInitializerSymbol[] | undefined, symbolsUnavailable: boolean, names: SymbolNames, @@ -895,11 +874,10 @@ const collectEntries = ( slot, scan.relocatable ? undefined : loaded.writes.get(slot.va), (!scan.relocatable && loaded.clobbered.has(slot.va)) || - (slot.fileOffset !== null && - sectionClobbered.has(slot.fileOffset)), - slot.fileOffset === null - ? undefined - : sectionRels.get(slot.fileOffset), + sectionClobbered.has( + elfInitializerSlotKey(slot, scan.relocatable), + ), + sectionRels.get(elfInitializerSlotKey(slot, scan.relocatable)), symbols, symbolsUnavailable, trustRaw, @@ -995,9 +973,9 @@ export const readElfInitializers = async ( const index = indexSections(directory.sections); const mappings = createFileMappingIndex(image.mappings, image.zero_fills); const locate = (address: bigint): number | null => - fileOffsetFor(scan, mappings, index, address, wordOf(scan.bits)); + fileOffsetFor(scan, mappings, address, wordOf(scan.bits)); const locateTarget = (address: bigint): number | null => - fileOffsetFor(scan, mappings, index, address, 1); + fileOffsetFor(scan, mappings, address, 1); const declaredSources = collectSources( scan, tags, @@ -1039,16 +1017,14 @@ export const readElfInitializers = async ( assertArtifactReadActive(signal); const symbols = loadElfSymbolIndex(scan, directory.sections, scan.notes); if (symbols.incomplete) scan.incomplete = true; - const uncovered = new Set( - sources.flatMap((source) => - source.slots.flatMap((slot) => - slot.direct === undefined && - slot.fileOffset !== null && - (scan.relocatable || - (!loaded.writes.has(slot.va) && !loaded.clobbered.has(slot.va))) - ? [slot.fileOffset] - : [], - ), + const uncovered = sources.flatMap((source) => + source.slots.flatMap((slot) => + slot.direct === undefined && + slot.fileOffset !== null && + (scan.relocatable || + (!loaded.writes.has(slot.va) && !loaded.clobbered.has(slot.va))) + ? [slot] + : [], ), ); const notesBeforeRelocs = scan.notes.recorded; @@ -1059,7 +1035,7 @@ export const readElfInitializers = async ( tables: symbols.tables, notes: scan.notes, fileOffset: (address, width) => - fileOffsetFor(scan, mappings, index, address, width), + fileOffsetFor(scan, mappings, address, width), signal, }); if (scan.notes.recorded !== notesBeforeRelocs || !sectionRels.complete) diff --git a/src/native/initializers/NativeInitializers.fixture.ts b/src/native/initializers/NativeInitializers.fixture.ts index 2893fb4dba..02ba68a2e8 100644 --- a/src/native/initializers/NativeInitializers.fixture.ts +++ b/src/native/initializers/NativeInitializers.fixture.ts @@ -111,6 +111,50 @@ const cStringTable = ( return { bytes: Buffer.concat(parts), offsets }; }; +/** Linked ELF64 with two unmapped targets beside a one-byte mapped constructor. */ +export const elfUnmappedInitializerTargets = ( + options: { readonly type?: 2 | 3; readonly metadataAllocated?: boolean } = {}, +): Buffer => { + const file = Buffer.alloc(0x600); + const names = cStringTable([".text", ".init_array", ".comment", ".shstrtab"]); + const comment = Buffer.from( + "Unmapped initializer targets must not point into metadata.\0", + ); + elfIdent(file, 64, options.type ?? 2, 62); + file.writeBigUInt64LE(64n, 32); + file.writeBigUInt64LE(0x400n, 40); + file.writeUInt16LE(64, 52); + file.writeUInt16LE(56, 54); + file.writeUInt16LE(3, 56); + file.writeUInt16LE(64, 58); + file.writeUInt16LE(5, 60); + file.writeUInt16LE(4, 62); + phdr64(file, 64, 1, 4, 0, 0x400000, 0x100); + phdr64(file, 120, 1, 5, 0x100, 0x401000, 1); + phdr64(file, 176, 1, 6, 0x140, 0x402000, 24); + file[0x100] = 0xc3; + file.writeBigUInt64LE(0n, 0x140); + file.writeBigUInt64LE(0x10n, 0x148); + file.writeBigUInt64LE(0x401000n, 0x150); + comment.copy(file, 0x200); + names.bytes.copy(file, 0x280); + shdr64(file, 1, 0x400, names.offsets[0]!, 1, 6, 0x401000, 0x100, 1); + shdr64(file, 2, 0x400, names.offsets[1]!, 14, 3, 0x402000, 0x140, 24, 0, 8); + shdr64( + file, + 3, + 0x400, + names.offsets[2]!, + 1, + options.metadataAllocated === true ? 2 : 0, + 0, + 0x200, + comment.length, + ); + shdr64(file, 4, 0x400, names.offsets[3]!, 3, 0, 0, 0x280, names.bytes.length); + return file; +}; + /** * ELF64 PIE: DT_INIT, RELATIVE init_array, one import, one RELR fini slot. * `needed` prepends that many DT_NEEDED entries, which repeat by design. @@ -194,6 +238,21 @@ export const elf64PieInitializers = (needed = 0): Buffer => { return file; }; +/** Two initializer addresses share source bytes; only the original has a section RELA write. */ +export const elf64AliasedInitializers = (): Buffer => { + const file = elf64PieInitializers(); + file.writeUInt16LE(3, 56); + phdr64(file, 176, 1, 6, 0x200, 0x1200, 24); + // Observe both mappings through independent dynamic array declarations. + dyn64(file, 2, 25, 0x1200); + dyn64(file, 3, 27, 8); + dyn64(file, 6, 32, 0x200); + // Exercise the allocated section-relocation fallback rather than DT_RELA. + for (let index = 8; index <= 10; index++) + dyn64(file, index, 0x70000000 + index, 0); + return file; +}; + const phdr32 = ( file: Buffer, at: number, diff --git a/src/native/initializers/NativeInitializersParsers.test.ts b/src/native/initializers/NativeInitializersParsers.test.ts index 980ec3f9de..e449ea2cf4 100644 --- a/src/native/initializers/NativeInitializersParsers.test.ts +++ b/src/native/initializers/NativeInitializersParsers.test.ts @@ -29,7 +29,9 @@ vi.mock("../metadata/BinaryContainer.js", async (importOriginal) => { import { elf32RelativeInitializers, elf64PieInitializers, + elf64AliasedInitializers, elfRelocatableCtors, + elfUnmappedInitializerTargets, macho32Initializers, machoArm64FatInitializers, machoArm64Initializers, @@ -71,6 +73,55 @@ const read = async ( }), ); +it.each([ + { type: 2 as const, metadataAllocated: false }, + { type: 3 as const, metadataAllocated: false }, + { type: 2 as const, metadataAllocated: true }, +])( + "keeps section metadata from inventing linked target file offsets ($type / $metadataAllocated)", + async (options) => { + const report = await read(elfUnmappedInitializerTargets(options)); + expect( + report.entries.map((entry) => [entry.value_va, entry.value_file_offset]), + ).toEqual([ + ["0x0", null], + ["0x10", null], + ["0x401000", 0x100], + ]); + expect(report.coverage).toMatchObject({ + status: "complete", + examined_entries: 3, + total_entries: 3, + }); + }, +); + +it("preserves a real mapping to file offset zero instead of treating zero as missing", async () => { + const bytes = elfUnmappedInitializerTargets({ type: 3 }); + bytes.writeBigUInt64LE(0n, 64 + 16); + bytes.writeBigUInt64LE(0n, 64 + 24); + const report = await read(bytes); + expect( + report.entries.slice(0, 2).map((entry) => entry.value_file_offset), + ).toEqual([0, 0x10]); +}); + +it("does not use metadata to back a target in a load segment's zero-fill tail", async () => { + const bytes = elfUnmappedInitializerTargets(); + bytes.writeBigUInt64LE(0x2000n, 176 + 40); + bytes.writeBigUInt64LE(0x403000n, 0x140); + bytes.writeBigUInt64LE(0x403000n, 0x400 + 3 * 64 + 16); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + value_va: "0x403000", + value_file_offset: null, + }); + expect(report.entries[2]).toMatchObject({ + value_va: "0x401000", + value_file_offset: 0x100, + }); +}); + /** One ELF64 section header for {@link withExtraSections}. */ const sectionHeader = ( type: number, @@ -465,6 +516,106 @@ describe("ELF section content admission", () => { ); }); +describe("ELF section content admission", () => { + it("keeps symbol relocations unresolved for a conflicting symbol entry width", async () => { + const bytes = elfRelocatableCtors(); + bytes.writeBigUInt64LE(16n, 0x500 + 4 * 64 + 56); + const report = await read(bytes); + expect( + report.entries.find((entry) => entry.kind === "init_array"), + ).toMatchObject({ + resolution: "unresolved", + value_va: null, + symbol: null, + }); + expect(report.status).toBe("incomplete"); + expect(report.limitations.join("\n")).toContain("entry size"); + }); + + it.each([ + { section: 7, label: "initializer array" }, + { section: 4, label: "symbol table" }, + { section: 5, label: "symbol string table" }, + { section: 8, label: "relocation table" }, + ])("preserves uncertainty for a compressed $label", async ({ section }) => { + const base = elfRelocatableCtors(); + const at = 0x500 + section * 64; + const offset = Number(base.readBigUInt64LE(at + 24)); + const size = Number(base.readBigUInt64LE(at + 32)); + const header = Buffer.alloc(24); + header.writeUInt32LE(1, 0); + header.writeBigUInt64LE(BigInt(size), 8); + header.writeBigUInt64LE(1n, 16); + const compressed = Buffer.concat([ + header, + deflateSync(base.subarray(offset, offset + size)), + ]); + const bytes = Buffer.concat([base, compressed]); + bytes.writeBigUInt64LE(base.readBigUInt64LE(at + 8) | 0x800n, at + 8); + bytes.writeBigUInt64LE(BigInt(base.length), at + 24); + bytes.writeBigUInt64LE(BigInt(compressed.length), at + 32); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect(report.coverage.total_entries).toBeNull(); + expect(report.limitations.join("\n")).toContain("compressed"); + if (section === 7) + expect(report.entries.some((entry) => entry.kind === "init_array")).toBe( + false, + ); + else { + const init = report.entries.find((entry) => entry.kind === "init_array"); + expect(init).toBeDefined(); + if (section === 5) expect(init?.symbol).toBeNull(); + else + expect(init).toMatchObject({ + resolution: "unresolved", + value_va: null, + }); + } + // The uncompressed fixture remains the working comparison. + expect((await read(base)).status).toBe("parsed"); + }); + + it("does not substitute an unrelated dynamic symbol table for DT_SYMTAB", async () => { + const bytes = elf64PieInitializers(); + bytes.writeBigUInt64LE(0x777n, 0x300 + 14 * 16 + 8); + const report = await read(bytes); + expect( + report.entries.find((entry) => entry.slot_va === "0x210"), + ).toMatchObject({ + resolution: "unresolved", + symbol: null, + value_va: null, + }); + expect(report.status).toBe("incomplete"); + }); + + it.each(["compressed", "not a string table"])( + "retains later symbol tables when an earlier name source is %s", + async (source) => { + const bytes = elf64PieInitializers(); + // The first (dynamic) symbol table links section 8; its bytes still contain imp_init. + if (source === "compressed") + bytes.writeBigUInt64LE(0x802n, 0x800 + 8 * 64 + 8); + else bytes.writeUInt32LE(1, 0x800 + 8 * 64 + 4); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect( + report.entries.find((entry) => entry.kind === "init"), + ).toMatchObject({ + symbol: { name: "ctor_a" }, + value_va: "0x100", + }); + expect( + report.entries.find((entry) => entry.slot_va === "0x210"), + ).toMatchObject({ + resolution: "unresolved_import", + symbol: null, + }); + }, + ); +}); + describe("ELF incomplete inputs", () => { it("reports an unsupported relocation machine as incomplete, not as raw zeros", async () => { const bytes = elf64PieInitializers(); @@ -1000,6 +1151,26 @@ describe("ELF relocation coverage", () => { expect(report.entries.every((entry) => entry.value_va === null)).toBe(true); }); + it.each([0, 999])( + "keeps raw object slots unresolved when skipped relocation target %s is unknown", + async (target) => { + const bytes = elfRelocatableCtors(); + // This invalid target makes it unknown whether the skipped table affects .ctors. + bytes.writeUInt32LE(target, 0x500 + 3 * 64 + 44); + bytes.writeBigUInt64LE(16n, 0x500 + 3 * 64 + 56); + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + const ctors = report.entries.filter((entry) => entry.kind === "ctors"); + expect(ctors).toHaveLength(3); + expect( + ctors.every( + (entry) => + entry.resolution === "unresolved" && entry.value_va === null, + ), + ).toBe(true); + }, + ); + it("does not treat a dynamic write that overlaps half a slot as leaving it untouched", async () => { const bytes = elf64PieInitializers(); // The leading null RELA record becomes R_X86_64_32 at 0x204, the upper half of slot 0x200. @@ -1340,6 +1511,112 @@ describe("Initializer target semantics", () => { }); }); +describe("ELF initializer relocation identity", () => { + it("reuses one section relocation for repeated declarations of the same virtual slot", async () => { + const bytes = elf64AliasedInitializers(); + bytes.writeBigUInt64LE(0x200n, 0x300 + 2 * 16 + 8); + const report = await read(bytes); + expect(report.status).toBe("parsed"); + expect( + report.entries + .filter((entry) => entry.slot_va === "0x200") + .map((entry) => [entry.value_va, entry.resolution]), + ).toEqual([ + ["0x100", "relative_addend"], + ["0x100", "relative_addend"], + ]); + }); + + it("does not attribute an i386 REL's in-place addend to an unrelocated alias", async () => { + const bytes = elf32RelativeInitializers(); + bytes.writeUInt16LE(3, 44); + const alias = 116; + bytes.writeUInt32LE(1, alias); + bytes.writeUInt32LE(0x100, alias + 4); + bytes.writeUInt32LE(0x1100, alias + 8); + bytes.writeUInt32LE(4, alias + 16); + bytes.writeUInt32LE(4, alias + 20); + bytes.writeUInt32LE(6, alias + 24); + bytes.writeUInt32LE(1, alias + 28); + bytes.writeUInt32LE(0x1100, 0x124); + // Only the section declaration still names the REL table. + for (let index = 2; index <= 4; index++) + bytes.writeInt32LE(0x70000000 + index, 0x120 + index * 8); + const unrelocated = await read(bytes); + expect(unrelocated.entries[0]).toMatchObject({ + slot_va: "0x1100", + value_va: "0x80", + resolution: "raw", + }); + // Moving the same REL to that address uses its own stored in-place word. + bytes.writeUInt32LE(0x1100, 0x180); + expect((await read(bytes)).entries[0]).toMatchObject({ + slot_va: "0x1100", + value_va: "0x80", + resolution: "relative_addend", + }); + }); + + it.each([false, true])( + "keeps section RELA writes on their virtual-address owner (both aliases relocated: %s)", + async (both) => { + const bytes = elf64AliasedInitializers(); + if (both) bytes.writeBigUInt64LE(0x1200n, 0x4b0); + const report = await read(bytes); + expect(report.status).toBe("parsed"); + expect( + report.entries + .filter((entry) => + ["preinit_array", "init_array"].includes(entry.kind), + ) + .map((entry) => [entry.slot_va, entry.value_va, entry.resolution]), + ).toEqual([ + ["0x200", "0x100", "relative_addend"], + ["0x1200", both ? "0x110" : "0x0", both ? "relative_addend" : "raw"], + ]); + }, + ); + + it("keeps a partial relocation on one alias from clobbering the other", async () => { + const bytes = elf64AliasedInitializers(); + bytes.writeBigUInt64LE(0x1204n, 0x4b0); + bytes.writeBigUInt64LE(10n, 0x4b8); // R_X86_64_32, overlapping the alias's last four bytes. + const report = await read(bytes); + expect(report.status).toBe("incomplete"); + expect( + report.entries.find((entry) => entry.kind === "preinit_array"), + ).toMatchObject({ + value_va: "0x100", + resolution: "relative_addend", + }); + expect( + report.entries.find((entry) => entry.kind === "init_array"), + ).toMatchObject({ + value_va: null, + resolution: "unresolved", + }); + }); + + it("keeps relocatable section identities distinct when their source bytes alias", async () => { + const bytes = elfRelocatableCtors(); + // The .init_array bytes now alias .ctors's relocated middle slot. + bytes.writeBigUInt64LE(0x208n, 0x500 + 7 * 64 + 24); + const report = await read(bytes); + expect(report.status).toBe("parsed"); + expect( + report.entries.map((entry) => [ + entry.kind, + entry.slot_file_offset, + entry.value_va, + entry.symbol?.name, + ]), + ).toEqual([ + ["init_array", 0x208, "0x8", "init_b"], + ["ctors", 0x208, "0x0", "ctor"], + ]); + }); +}); + describe("Initializer address mapping", () => { it("reports no target file offset when load segments map it ambiguously", async () => { const bytes = elf64PieInitializers(); diff --git a/src/native/metadata/ElfPointerRelocations.test.ts b/src/native/metadata/ElfPointerRelocations.test.ts index c5bde4385e..a731a1691e 100644 --- a/src/native/metadata/ElfPointerRelocations.test.ts +++ b/src/native/metadata/ElfPointerRelocations.test.ts @@ -1,7 +1,8 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { elfAbsoluteRelocationType, readElfRelocationWritesSteps, + readElfPointerValuesSteps, type ElfPointerWrite, } from "./ElfPointerRelocations.js"; @@ -113,3 +114,66 @@ it("owns the word-sized absolute relocation type per machine", () => { expect(elfAbsoluteRelocationType(40)).toBe(2); expect(elfAbsoluteRelocationType(8)).toBeUndefined(); }); + +/** Three fully decoded relative values; empty REL declarations add no state. */ +const relativeValues = (duplicate = false): Buffer => { + const bytes = image(); + bytes.writeBigUInt64LE(0n, 4 * 16 + 8); + for (let i = 0; i < 3; i++) { + const at = 0x100 + i * 24; + bytes.writeBigUInt64LE(duplicate ? 0x300n : 0x300n + BigInt(i * 8), at); + bytes.writeBigUInt64LE(8n, at + 8); + bytes.writeBigUInt64LE( + duplicate ? 0x1234n : 0x1234n + BigInt(i * 0x100), + at + 16, + ); + } + return bytes; +}; + +const pointerValues = (bytes: Buffer, maximumIndexBytes: number) => { + const steps = readElfPointerValuesSteps( + bytes, + { + bits: 64, + little: true, + machine: EM_X86_64, + dynamicSegments: [{ offset: 0n, size: 112n }], + mappings: [{ address: 0n, offset: 0, size: bytes.length }], + zeroFills: [], + }, + maximumIndexBytes, + ); + let next = steps.next(); + while (!next.done) next = steps.next(); + return next.value; +}; + +it("admits fitting pointer workspace and preserves its complete values", () => { + const facts = pointerValues(relativeValues(), 6144); + expect( + [0x300n, 0x308n, 0x310n].map((address) => facts.unsignedAt(address, 8)), + ).toEqual([0x1234n, 0x1334n, 0x1434n]); +}); + +it("charges repeated virtual destinations once within the pointer workspace", () => { + const facts = pointerValues(relativeValues(true), 2048); + expect(facts.unsignedAt(0x300n, 8)).toBe(0x1234n); +}); + +it("refuses pointer workspace growth before inserting the next virtual destination", () => { + const inserted = vi.spyOn(Map.prototype, "set"); + try { + expect(() => pointerValues(relativeValues(), 4096)).toThrow( + expect.objectContaining({ + reason: "native-index-capacity", + message: expect.stringContaining("4096 estimated representation bytes"), + }), + ); + expect(inserted.mock.calls.some(([address]) => address === 0x310n)).toBe( + false, + ); + } finally { + inserted.mockRestore(); + } +}); diff --git a/src/native/metadata/ElfPointerRelocations.ts b/src/native/metadata/ElfPointerRelocations.ts index fff7eb7dcc..d1affe57d0 100644 --- a/src/native/metadata/ElfPointerRelocations.ts +++ b/src/native/metadata/ElfPointerRelocations.ts @@ -67,6 +67,10 @@ export interface ElfPointerRelocationInput { // JNI sparse consumers retain per-write provenance; streaming materializers do not. const MAX_RELOCATION_RECORDS = 65_536; +/** Conservative logical reservation for three Map entries, boxed coordinates and + * values, and finish-time tuples/ranges/reach/sort storage including growth slack. + * This is allocation accounting, not a measurement of a JavaScript VM's RSS. */ +const ELF_POINTER_INDEX_ENTRY_BYTES = 2 * 1024; const DT_RELA = 7n, DT_RELASZ = 8n, DT_RELAENT = 9n; @@ -261,8 +265,9 @@ export interface ElfRelocationStreamResult { readonly issues: readonly string[]; readonly dynamic_present: boolean; readonly pointer_coverage_known: boolean; - readonly originalPointerAt: ( + readonly originalValueAt: ( address: bigint, + width: 4 | 8, ) => ElfOverlayPointer | undefined; } type RelocationOptions = { @@ -512,26 +517,199 @@ export function* readElfRelocationWritesSteps( issues: [...issues], dynamic_present: input.dynamicSegments.length !== 0, pointer_coverage_known: known, - originalPointerAt: (address) => { - const at = fileOffset(address, word); + originalValueAt: (address, width) => { + const at = fileOffset(address, width); return at === null ? undefined - : { value: rawWord(at), file_offset: at, relocation: null }; + : { + value: + width === word + ? rawWord(at) + : BigInt( + input.little + ? bytes.readUInt32LE(at) + : bytes.readUInt32BE(at), + ), + file_offset: at, + relocation: null, + }; }, }; } -/** Retain bounded sparse pointer facts for consumers that query the original image. */ +/** Loader values and overlap ownership, independent of retained relocation provenance. */ +export interface ElfVirtualPointerWrites { + readonly hasWrites: (address: bigint, size: number) => boolean; + /** Undefined when a write leaves the selected field unresolved or partly overwritten. */ + readonly unsignedAt: (address: bigint, width: 4 | 8) => bigint | undefined; +} + +/** Both sparse consumers use virtual coordinates; physical aliases never merge writes. */ +class ElfVirtualWriteIndex { + readonly #values = new Map(); + readonly #widths = new Map(); + readonly #unresolved = new Map(); + #reservedBytes = 0; + constructor( + readonly word: 4 | 8, + readonly little: boolean, + readonly maximumBytes: number, + ) {} + add({ + address, + width, + value, + }: Pick): void { + if (!this.#widths.has(address)) { + // Reserve retained containers and finish-time lookup expansion before + // the first insertion. Repeated declarations of one VA share its charge. + if ( + ELF_POINTER_INDEX_ENTRY_BYTES > + this.maximumBytes - this.#reservedBytes + ) + throw new ElfPointerRelocationFailure( + "native-index-capacity", + `ELF virtual pointer index exceeds ${this.maximumBytes} estimated representation bytes (${ELF_POINTER_INDEX_ENTRY_BYTES} reserved per virtual address, including lookup workspace)`, + ); + this.#reservedBytes += ELF_POINTER_INDEX_ENTRY_BYTES; + } + this.#widths.set(address, Math.max(this.#widths.get(address) ?? 0, width)); + if ( + value === null || + (this.#values.has(address) && this.#values.get(address) !== value) + ) + this.#unresolved.set( + address, + Math.max(this.#unresolved.get(address) ?? 0, width), + ); + if (value !== null) this.#values.set(address, value); + } + get unresolvedSize(): number { + return this.#unresolved.size; + } + finish() { + const ranges = [...this.#widths] + .map(([address, width]) => ({ address, end: address + BigInt(width) })) + .sort((a, b) => + a.address < b.address ? -1 : a.address > b.address ? 1 : 0, + ); + const reach: bigint[] = []; + for (const range of ranges) + reach.push( + range.end > (reach.at(-1) ?? 0n) ? range.end : (reach.at(-1) ?? 0n), + ); + let previousEnd: bigint | undefined; + let previousBefore = 0; + const before = (end: bigint): number => { + // Sequential field scans reuse the immutable interval cursor. Large or + // backward jumps keep binary lookup, so random queries cannot rescan it. + if ( + previousEnd !== undefined && + end >= previousEnd && + end - previousEnd <= 16n + ) { + while ( + previousBefore < ranges.length && + ranges[previousBefore]!.address < end + ) + previousBefore++; + previousEnd = end; + return previousBefore; + } + let low = 0, + high = ranges.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (ranges[mid]!.address < end) low = mid + 1; + else high = mid; + } + previousEnd = end; + previousBefore = low; + return low; + }; + const unsignedAt = ( + address: bigint, + width: 4 | 8, + original?: bigint, + ): bigint | undefined => { + if (width > this.word) return undefined; + const end = address + BigInt(width); + const complete = (1n << BigInt(width * 8)) - 1n; + let covered = original === undefined ? 0n : complete; + let assigned = 0n; + let result = original ?? 0n; + let owner: bigint | undefined; + for ( + let at = before(end) - 1; + at >= 0 && (reach[at] ?? 0n) > address; + at-- + ) { + const range = ranges[at]!; + if (range.end <= address) continue; + const unknownWidth = this.#unresolved.get(range.address); + if ( + unknownWidth !== undefined && + range.address + BigInt(unknownWidth) > address + ) + return undefined; + const value = this.#values.get(range.address); + if ( + value === undefined || + range.end - range.address !== BigInt(this.word) || + (width === this.word && + (range.address !== address || range.end !== end)) + ) + return undefined; + owner = range.address; + if (width === this.word) { + if (assigned !== 0n) return undefined; + result = value; + assigned = complete; + covered = complete; + continue; + } + const start = range.address > address ? range.address : address; + const stop = range.end < end ? range.end : end; + for (let byte = start; byte < stop; byte++) { + const sourceShift = this.little + ? byte - range.address + : range.end - byte - 1n; + const targetShift = this.little ? byte - address : end - byte - 1n; + const mask = 0xffn << (targetShift * 8n); + if ((assigned & mask) !== 0n) return undefined; + result = + (result & ~mask) | + (((value >> (sourceShift * 8n)) & 0xffn) << (targetShift * 8n)); + assigned |= mask; + covered |= mask; + } + } + return owner !== undefined && covered === complete ? result : undefined; + }; + return { + unsignedAt, + unresolved: this.#unresolved, + hasWrites: (address: bigint, size: number): boolean => { + const low = before(address + BigInt(size)); + return low > 0 && (reach[low - 1] ?? 0n) > address; + }, + }; + } +} + +/** Retain provenance-bearing facts for bounded metadata queries. */ export function* readElfPointerRelocationsSteps( bytes: Buffer, input: ElfPointerRelocationInput, options: RelocationOptions = {}, ): Generator { - const word = input.bits / 8; const writes: ElfPointerWrite[] = []; - const unresolved = new Map(); const values = new Map(); - const widths = new Map(); + const index = new ElfVirtualWriteIndex( + input.bits === 64 ? 8 : 4, + input.little, + MAX_RELOCATION_RECORDS * ELF_POINTER_INDEX_ENTRY_BYTES, + ); const steps = readElfRelocationWritesSteps(bytes, input, options); let next = steps.next(); while (!next.done) { @@ -544,88 +722,87 @@ export function* readElfPointerRelocationsSteps( `ELF sparse relocation index exceeds ${MAX_RELOCATION_RECORDS} retained writes`, ); writes.push(write); - const { address, width, value } = write; - widths.set(address, Math.max(widths.get(address) ?? 0, width)); - if (value === null) - unresolved.set(address, Math.max(unresolved.get(address) ?? 0, width)); - else { - const previous = values.get(address); - if (previous !== undefined && previous.value !== value) - unresolved.set( - address, - Math.max(unresolved.get(address) ?? 0, width), - ); - values.set(address, write); - } + index.add(write); + if (write.value !== null) values.set(write.address, write); } next = steps.next(); } - const { - issues, - dynamic_present, - pointer_coverage_known: known, - originalPointerAt, - } = next.value; - // Index one span per destination. Reads cannot mistake bytes changed by a - // partial or unresolved relocation for an original valid pointer. - const ranges = [...widths] - .map(([address, width]) => ({ address, end: address + BigInt(width) })) - .sort((a, b) => - a.address < b.address ? -1 : a.address > b.address ? 1 : 0, - ); - const reach: bigint[] = []; - for (const range of ranges) - reach.push( - range.end > (reach.at(-1) ?? 0n) ? range.end : (reach.at(-1) ?? 0n), - ); - const pointerAt = (address: bigint): ElfOverlayPointer | undefined => { - if (!known) return undefined; - const end = address + BigInt(word); - let low = 0, - high = ranges.length; - while (low < high) { - const mid = (low + high) >>> 1; - if ((ranges[mid]?.address ?? end) < end) low = mid + 1; - else high = mid; - } - for (let at = low - 1; at >= 0 && (reach[at] ?? 0n) > address; at--) { - const range = ranges[at]; - if ( - range !== undefined && - range.end > address && - (range.address !== address || - unresolved.has(range.address) || - range.end !== end) - ) - return undefined; - } - const relocated = values.get(address); - if (relocated?.value !== undefined && relocated.value !== null) - return { - value: relocated.value, - file_offset: relocated.file_offset, - relocation: relocated.source, - }; - return originalPointerAt(address); - }; - const hasWrites = (address: bigint, size: number): boolean => { - const end = address + BigInt(size); - let low = 0, - high = ranges.length; - while (low < high) { - const mid = (low + high) >>> 1; - if ((ranges[mid]?.address ?? end) < end) low = mid + 1; - else high = mid; - } - return low > 0 && (reach[low - 1] ?? 0n) > address; + const facts = index.finish(); + const { issues, dynamic_present, pointer_coverage_known, originalValueAt } = + next.value; + const unsignedAt = ( + address: bigint, + width: 4 | 8, + ): ElfOverlayPointer | undefined => { + if (!pointer_coverage_known || width > input.bits / 8) return undefined; + if (!facts.hasWrites(address, width)) + return originalValueAt(address, width); + const resolved = facts.unsignedAt(address, width); + if (resolved === undefined) return undefined; + const source = values.get(address)!; + return { + value: resolved, + file_offset: source.file_offset, + relocation: source.source, + }; }; return { writes, - unresolved, + unresolved: facts.unresolved, issues, dynamic_present, - pointer_coverage_known: known, - pointerAt, - hasWrites, + pointer_coverage_known, + pointerAt: (address) => unsignedAt(address, input.bits === 64 ? 8 : 4), + hasWrites: facts.hasWrites, + }; +} + +/** Value-only consumers keep input-bounded file-backed writes without per-record + * provenance, with a separate allowance for unresolved and zero-fill state. + */ +export function* readElfPointerValuesSteps( + bytes: Buffer, + input: ElfPointerRelocationInput, + maximumIndexBytes: number, +): Generator { + const index = new ElfVirtualWriteIndex( + input.bits === 64 ? 8 : 4, + input.little, + maximumIndexBytes, + ); + const zeroFill = new Set(); + const steps = readElfRelocationWritesSteps(bytes, input, { + requireDynamic: true, + }); + let next = steps.next(); + while (!next.done) { + const write = next.value; + if (write === undefined) yield; + else { + index.add(write); + if (write.value !== null && write.file_offset === null) + zeroFill.add(write.address); + if (index.unresolvedSize + zeroFill.size > MAX_RELOCATION_RECORDS) + throw new ElfPointerRelocationFailure( + "native-index-capacity", + "ELF unresolved and zero-fill overlay exceeds 65536 entries", + ); + } + next = steps.next(); + } + const facts = index.finish(); + const { originalValueAt } = next.value; + return { + hasWrites: facts.hasWrites, + unsignedAt: (address, width) => { + if (!facts.hasWrites(address, width)) return undefined; + return facts.unsignedAt( + address, + width, + width === 4 && input.bits === 64 + ? originalValueAt(address, width)?.value + : undefined, + ); + }, }; } diff --git a/tests/acceptance/nativeInitializersCli.test.ts b/tests/acceptance/nativeInitializersCli.test.ts index bc19e81595..4ed5baef48 100644 --- a/tests/acceptance/nativeInitializersCli.test.ts +++ b/tests/acceptance/nativeInitializersCli.test.ts @@ -1,9 +1,12 @@ -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; import { elfRelocatableCtors, + elf64AliasedInitializers, + elfUnmappedInitializerTargets, machoArm64Initializers, } from "../../src/native/initializers/NativeInitializers.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -11,6 +14,45 @@ import { cliTest } from "../support/cli/cliFixture.js"; const contract = toolContract("inspect_native_initializers"); +cliTest( + "keeps an unrelocated ELF alias's value and source identity through the CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-native-init-alias-cli-"); + const path = join(root, "program"); + const bytes = elf64AliasedInitializers(); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-native-initializers", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = contract.outputSchema.parse(result.json).normalized_result; + expect(report.status).toBe("parsed"); + expect(report.artifact).toMatchObject({ + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }); + expect( + report.entries.find((entry) => entry.kind === "preinit_array"), + ).toMatchObject({ + slot_va: "0x200", + slot_file_offset: 0x200, + value_va: "0x100", + resolution: "relative_addend", + }); + expect( + report.entries.find((entry) => entry.kind === "init_array"), + ).toMatchObject({ + slot_va: "0x1200", + slot_file_offset: 0x200, + value_va: "0x0", + symbol: null, + resolution: "raw", + }); + expect((await readFile(path)).equals(bytes)).toBe(true); + }, +); + cliTest( "lists Mach-O initializers and finalizers through the public CLI", async ({ cli }) => { @@ -84,3 +126,31 @@ cliTest("reports a truncated image as invalid input", async ({ cli }) => { details: { operation: "inspect_native_initializers" }, }); }); + +cliTest( + "keeps unmapped ELF target locations null in public CLI Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-native-init-unmapped-cli-"); + const path = join(root, "program"); + await writeFile(path, elfUnmappedInitializerTargets()); + const result = await cli.run({ + arguments: ["inspect-native-initializers", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + expect( + evidence.normalized_result.entries.map((entry) => [ + entry.value_va, + entry.value_file_offset, + ]), + ).toEqual([ + ["0x0", null], + ["0x10", null], + ["0x401000", 0x100], + ]); + expect(evidence.normalized_result.coverage).toMatchObject({ + examined_entries: 3, + total_entries: 3, + }); + }, +); diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index f877f2d5b5..9c12c85fd0 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -699,6 +699,33 @@ it("says why no MetadataRegistration or inferred type table was accepted", () => ).toContain("no single code-loaded run of Il2CppType pointers"); }); +it("ignores an unrelated unaligned Mach-O bind during module-array fallback", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x704); + bytes.fill(0, 0x600, 0x610); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x500]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + const linked = link(bytes); + 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("ignores an unrelated unaligned Mach-O bind during type-table fallback", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x704); + bytes.fill(0, 0x800, 0x880); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + expect(linkWholeTable(bytes).types_count).toBe(4); +}); + 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 @@ -1116,6 +1143,11 @@ it.each([0n, FIXTURE_BINARY_BASE + 0x210n])( }, ); +it("ignores unrelated unaligned Mach-O binds while finding module records", () => { + const linked = link(createIl2cppDyldBindFixture(0n, 0x704)); + expect(linked.method_addresses).toEqual(expectedAddresses); +}); + it("keeps unbound Mach-O method words classified from their values", () => { const linked = link(createIl2cppBinaryFixture()); expect(linked.method_addresses[1]).toEqual(expectedAddresses[1]); @@ -1924,3 +1956,209 @@ it.each([183, 62])( expect(link(bytes)).toEqual(link(createElfFixture(image, { machine }))); }, ); + +it.each([0x700, 0x704])( + "does not claim a unique module when a bind at %s may change a competing name pointer", + (slot) => { + const bytes = createIl2cppDyldBindFixture(0n, slot); + const duplicate = createIl2cppBinaryFixture({ duplicateModule: true }); + duplicate.copy(bytes, 0x700, 0x700, 0x718); + if (slot === 0x700) bytes.writeBigUInt64LE(0n, slot); + else bytes.writeUInt32LE(0, slot); + expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); + }, +); + +it("does not claim a unique type table when a bound word may complete a competing table", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x700); + bytes.copy(bytes, 0x700, 0x900, 0x920); + bytes.writeBigUInt64LE(0n, 0x700); + bytes.fill(0, 0x800, 0x880); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + expect(() => linkWholeTable(bytes)).toThrow(Il2cppBinaryFormatFailure); +}); + +it("retains a unique module array when an isolated bound word cannot form a competing two-module array", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x700); + bytes.fill(0, 0x600, 0x610); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x500]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + expect(bytes.readBigUInt64LE(0x6f8)).toBe(0n); + expect(bytes.readBigUInt64LE(0x708)).toBe(0n); + const linked = link(bytes); + expect(linked.code_gen_modules_array).toBe(FIXTURE_BINARY_BASE + 0x500n); +}); + +it("does not treat a repeated possible module as a competing array", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x70c); + bytes.fill(0, 0x600, 0x610); + bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x400n, 0x700); + bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x400n, 0x708); + bytes.writeUInt32LE(0, 0x70c); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x500]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + expect(link(bytes).code_gen_modules_array).toBe(FIXTURE_BINARY_BASE + 0x500n); +}); + +it("does not claim a unique module array when an ELF unresolved relocation may form a second code-referenced array", () => { + const image = createIl2cppBinaryFixture({ + codeRegistration: false, + codeReference: true, + }); + image.writeBigUInt64LE(0n, 0x700); + image.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x440n, 0x708); + image.set([0x48, 0x8d, 0x05], 0xc20); + image.writeInt32LE(0x700 - (0xc20 + 7), 0xc23); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset: 0x700, kind: 1 }], + }); + expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); +}); + +it("does not claim a unique module when an ELF unresolved relocation may name a second record", () => { + const image = createIl2cppBinaryFixture({ duplicateModule: true }); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset: 0x700, kind: 1 }], + }); + expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); +}); + +it("does not claim unique registration when competing ELF count has unresolved relocation", () => { + const image = createIl2cppBinaryFixture({ duplicateCodeRegistration: true }); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset: 0x680, kind: 10 }], + }); + expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); +}); + +it("does not claim unique metadata registration when competing ELF count is unresolved", () => { + const image = createIl2cppBinaryFixture({ + duplicateMetadataRegistration: true, + }); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset: 0x8d0, kind: 10 }], + }); + expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); +}); + +it.each([ + [0x708, 10], + [0x710, 1], +])( + "does not claim unique module with unresolved competing field %s", + (offset, kind) => { + const image = createIl2cppBinaryFixture({ duplicateModule: true }); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset, kind }], + }); + expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); + }, +); + +it("rejects a possible ELF module when its known count disproves the record", () => { + const image = createIl2cppBinaryFixture({ duplicateModule: true }); + image.writeUInt32LE(123, 0x708); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset: 0x700, kind: 1 }], + }); + expect(link(bytes).method_addresses).toEqual(expectedAddresses); +}); + +it("rejects a possible ELF code registration when its known array is invalid", () => { + const image = createIl2cppBinaryFixture({ duplicateCodeRegistration: true }); + image.writeBigUInt64LE(0n, 0x688); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset: 0x680, kind: 10 }], + }); + expect(link(bytes).method_addresses).toEqual(expectedAddresses); +}); + +it("rejects a possible ELF metadata registration when its known type pointer is invalid", () => { + const image = createIl2cppBinaryFixture({ + duplicateMetadataRegistration: true, + }); + image.writeBigUInt64LE(0n, 0x8b8); + const bytes = createElfFixture(image, { + machine: 62, + unresolvedRelocations: [{ offset: 0x8d0, kind: 10 }], + }); + expect(link(bytes).method_addresses).toEqual(expectedAddresses); +}); + +it("still uses the ELF type-table fallback when an uncertain metadata candidate is disproven", () => { + const image = createIl2cppBinaryFixture({ + metadataRegistration: false, + adrpAdd: [[0xc20, 0x900]], + }); + image.writeUInt32LE(4, 0x8e0); + image.writeBigUInt64LE(0n, 0x8b8); + const bytes = createElfFixture(image, { + machine: 183, + unresolvedRelocations: [{ offset: 0x8d0, kind: 258 }], + }); + expect(linkWholeTable(bytes).types_count).toBe(4); +}); + +it("does not treat bound data with a known invalid kind as a competing type table", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x700); + bytes.fill(0, 0x800, 0x880); + for (let offset = 0x750; offset < 0x770; offset += 8) + bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x700n, offset); + createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }).copy( + bytes, + 0xc20, + 0xc20, + 0xc28, + ); + expect(linkWholeTable(bytes).types_count).toBe(4); +}); + +it.each([2, 3])( + "binds a partially known module name to its own image count %s", + (count) => { + const bytes = createIl2cppDyldBindFixture(0n, 0x704); + const duplicate = createIl2cppBinaryFixture({ duplicateModule: true }); + duplicate.copy(bytes, 0x700, 0x700, 0x718); + bytes.writeUInt32LE(0, 0x704); + bytes.writeUInt32LE(count, 0x708); + // BIND_TYPE_TEXT_ABSOLUTE32 owns only the upper half of the name pointer. + bytes[0xf00 + 2 + Buffer.byteLength("_FixtureMethod") + 1] = 0x52; + if (count === 2) + expect(link(bytes).method_addresses).toEqual(expectedAddresses); + else expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); + }, +); + +it("does not mix masked image count and pointer extent when count is unresolved", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x704); + bytes.writeUInt32LE(bytes.readUInt32LE(0x400), 0x700); + bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0xff0n, 0x710); + expect(link(bytes).method_addresses).toEqual(expectedAddresses); +}); + +it("retains an unresolved matching image count when its method array could fit", () => { + const bytes = createIl2cppDyldBindFixture(0n, 0x704); + bytes.writeUInt32LE(bytes.readUInt32LE(0x400), 0x700); + bytes.writeBigUInt64LE(bytes.readBigUInt64LE(0x410), 0x710); + expect(() => link(bytes)).toThrow(Il2cppBinaryFormatFailure); +}); diff --git a/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts index 65cac5407e..c6ec232a78 100644 --- a/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts +++ b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts @@ -32,6 +32,7 @@ const withLoadRanges = ( readonly offset: number; readonly size: number; readonly zeroFill?: number; + readonly address?: bigint; }[], ): Buffer => { const headers: Buffer[] = []; @@ -47,7 +48,7 @@ const withLoadRanges = ( header.writeUInt32LE(1); header.writeUInt32LE(7, 4); header.writeBigUInt64LE(BigInt(range.offset), 8); - const address = FIXTURE_BINARY_BASE + BigInt(range.offset); + const address = range.address ?? FIXTURE_BINARY_BASE + BigInt(range.offset); header.writeBigUInt64LE(address, 16); header.writeBigUInt64LE(address, 24); header.writeBigUInt64LE(BigInt(range.size), 32); @@ -61,6 +62,74 @@ const withLoadRanges = ( return result; }; +it.each([183, 62])( + "keeps file-backed relocation writes on their virtual owner despite physical aliases (machine %s)", + (machine) => { + const original = createElfFixture(createIl2cppBinaryFixture(), { machine }); + // RELA restores the zero stored at 0x308, but must not change the same + // file bytes independently mapped at base + 0x2000. A single file copy + // cannot represent those two different loader values. + expect(original.readBigUInt64LE(0x308)).toBe(0n); + for (const [offset, size] of [ + [0x308, 8], + [0x307, 2], + [0x30f, 2], + ] as const) { + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + { + offset, + size, + address: FIXTURE_BINARY_BASE + 0x2000n, + }, + ]); + const snapshot = Buffer.from(bytes); + const image = open(bytes); + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x308n)).toBe( + FIXTURE_BINARY_BASE + 0x200n, + ); + if (offset === 0x308 && size === 8) + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x2000n)).toBe(0n); + expect(Buffer.compare(bytes, snapshot)).toBe(0); + } + }, +); + +it.each([183, 62])( + "keeps unresolved symbol writes from changing another virtual mapping (machine %s)", + (machine) => { + const source = createIl2cppBinaryFixture(); + source.writeBigUInt64LE(0x1234n, 0x700); + const original = createElfFixture(source, { + machine, + unresolvedRelocations: [ + { offset: 0x700, kind: machine === 183 ? 257 : 1 }, + ], + }); + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + { offset: 0x700, size: 8, address: FIXTURE_BINARY_BASE + 0x2000n }, + ]); + const image = open(bytes); + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x700n)).toBe("unknown"); + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x2000n)).toBe(0x1234n); + }, +); + +it("keeps consistent aliases and distinct virtual mappings of untouched file bytes", () => { + const original = createElfFixture(createIl2cppBinaryFixture()); + for (const alias of [ + { offset: 0x300, size: 0x28 }, + { offset: 0x200, size: 0x20, address: FIXTURE_BINARY_BASE + 0x2000n }, + ]) { + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + alias, + ]); + expect(link(bytes)).toEqual(link(original)); + } +}); + it("reuses admitted mappings for repeated file-backed loader reads", () => { const original = createIl2cppBinaryFixture(); const image = open(createElfFixture(original, { zeroFilledTable: true })); @@ -223,3 +292,78 @@ it("keeps mapped-span admission consistent with pointer reads across zero-fill a expect(isMapped(image, FIXTURE_BINARY_BASE + 0xc00n, 4)).toBe(true); } }); + +it("reads both halves of a relocated word without changing a physical alias's UInt32 fields", () => { + const original = createElfFixture(createIl2cppBinaryFixture()); + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + { offset: 0x304, size: 16, address: FIXTURE_BINARY_BASE + 0x2000n }, + ]); + const image = open(bytes); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x308n)).toBe(0x200); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x30cn)).toBe(1); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x2004n)).toBe(0); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x2008n)).toBe(0); + expect(image.bytes.equals(bytes)).toBe(true); +}); + +it.each([183, 62])( + "retains independent relocated values for two virtual aliases (machine %s)", + (machine) => { + const original = createElfFixture(createIl2cppBinaryFixture(), { machine }); + // Redirect a separate existing RELA record to the alias, retaining the write + // to the original 0x308 slot and the same immutable source word. + const dynamic = Number(original.readBigUInt64LE(128)); + const count = Number(original.readBigUInt64LE(dynamic + 24)) / 24; + for (let i = 0; i < count; i++) { + const at = 0x1000 + i * 24; + if (original.readBigUInt64LE(at) === FIXTURE_BINARY_BASE + 0x308n) + continue; + original.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x2000n, at); + original.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x333n, at + 16); + break; + } + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + { offset: 0x308, size: 8, address: FIXTURE_BINARY_BASE + 0x2000n }, + ]); + const image = open(bytes); + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x308n)).toBe( + FIXTURE_BINARY_BASE + 0x200n, + ); + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x2000n)).toBe( + FIXTURE_BINARY_BASE + 0x333n, + ); + expect(image.bytes.equals(bytes)).toBe(true); + }, +); + +it.each([183, 62])( + "keeps an untouched UInt32 half known after a same-start narrow unknown write (machine %s)", + (machine) => { + const bytes = createElfFixture(createIl2cppBinaryFixture(), { + machine, + unresolvedRelocations: [ + { offset: 0x308, kind: machine === 183 ? 258 : 10 }, + ], + }); + const image = open(bytes); + expect(readSlot(image, FIXTURE_BINARY_BASE + 0x308n)).toBe("unknown"); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x308n)).toBe("unknown"); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x30cn)).toBe(1); + }, +); + +it("composes a UInt32 crossing a known relocation edge with unchanged source bytes", () => { + const source = createIl2cppBinaryFixture(); + source.writeUInt32LE(0xaabbccdd, 0x310); + const bytes = createElfFixture(source); + const image = open(bytes); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x306n)).toBe( + bytes.readUInt16LE(0x306) + 0x02000000, + ); + // The relocated word at 0x308 is 0x0000000100000200. Its final two + // bytes are followed by the original dd cc bytes at 0x310. + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x30en)).toBe(0xccdd0000); + expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x30cn)).toBe(1); +}); diff --git a/tests/boundary/mcp/nativeInitializersMcp.test.ts b/tests/boundary/mcp/nativeInitializersMcp.test.ts index 9b2c504e5c..5bbc8eb69f 100644 --- a/tests/boundary/mcp/nativeInitializersMcp.test.ts +++ b/tests/boundary/mcp/nativeInitializersMcp.test.ts @@ -8,6 +8,8 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { machoArm64FatInitializers, elf64PieInitializers, + elf64AliasedInitializers, + elfUnmappedInitializerTargets, } from "../../../src/native/initializers/NativeInitializers.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; @@ -20,6 +22,41 @@ const connect = async (mcp: TestMcp) => { return { client, session }; }; +mcpTest( + "resolves two independently relocated ELF aliases through MCP", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-native-init-alias-mcp-"); + const path = join(root, "program"); + const bytes = elf64AliasedInitializers(); + bytes.writeBigUInt64LE(0x1200n, 0x4b0); + await writeFile(path, bytes); + const response = await client.callTool({ + name: "inspect_native_initializers", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const report = toolContract( + "inspect_native_initializers", + ).outputSchema.parse(response.structuredContent).normalized_result; + expect(report.status).toBe("parsed"); + expect( + report.entries + .filter((entry) => + ["preinit_array", "init_array"].includes(entry.kind), + ) + .map((entry) => [entry.slot_va, entry.value_va, entry.resolution]), + ).toEqual([ + ["0x200", "0x100", "relative_addend"], + ["0x1200", "0x110", "relative_addend"], + ]); + } finally { + await session.close(); + } + }, +); + mcpTest( "advertises inspect_native_initializers and pages ELF initializers", async ({ mcp }) => { @@ -108,3 +145,41 @@ mcpTest( } }, ); + +mcpTest( + "retains unmapped ELF values without fabricating target file locations", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory( + "rea-native-init-unmapped-mcp-", + ); + const path = join(root, "program"); + await writeFile(path, elfUnmappedInitializerTargets()); + const response = await client.callTool({ + name: "inspect_native_initializers", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract( + "inspect_native_initializers", + ).outputSchema.parse(response.structuredContent); + expect( + evidence.normalized_result.entries.map((entry) => [ + entry.value_va, + entry.value_file_offset, + ]), + ).toEqual([ + ["0x0", null], + ["0x10", null], + ["0x401000", 0x100], + ]); + expect(evidence.normalized_result.coverage).toMatchObject({ + examined_entries: 3, + total_entries: 3, + }); + } finally { + await session.close(); + } + }, +);