fix(il2cpp): preserve loader ownership and ABI field boundaries

Resolve pointer and scalar facts through their shared loader-aware owners before registration search or type rendering. Preserve unresolved names and attributes, read count fields at their declared width, and admit declared zero-fill registration tails without accepting unmapped bytes.

Cover relocation overlap and adjacency, loader-bound fields, ABI padding, nullable names, and CLI/MCP behavior; document format and verification limits.

Fixes #6881
Fixes #6873
Fixes #6871
This commit is contained in:
morluto
2026-10-24 04:28:05 +08:00
parent f2db494bfc
commit 52ca222bd0
14 changed files with 420 additions and 59 deletions
+8 -8
View File
@@ -1,11 +1,6 @@
import type { Il2cppMethodAddress } from "../domain/il2cpp/il2cppMetadata.js";
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
openImageSteps,
readPointer,
readSlot,
isExecutable,
} from "./Il2cppBinaryImage.js";
import { openImageSteps, readSlot, isExecutable } from "./Il2cppBinaryImage.js";
import {
findModules,
findCodeRegistration,
@@ -180,8 +175,13 @@ export function* linkIl2cppBinarySteps(
);
// 27.0/27.1 and 27.2 share header version 27 but place byref differently.
if (version === 27) return { type, name: null };
const address =
readPointer(image, registration.types + BigInt(type * 8)) ?? 0n;
const address = readSlot(image, registration.types + BigInt(type * 8));
if (address === "unknown") return { type, name: null };
if (address === null)
throw new Il2cppBinaryFormatFailure(
"invalid-type",
`type ${type} has no mapped type-table slot`,
);
if (address === 0n)
throw new Il2cppBinaryFormatFailure(
"invalid-type",
+21 -13
View File
@@ -76,7 +76,7 @@ export interface NativeImage {
readonly imageBase: bigint;
readonly unresolvedPointerSlots: ReadonlyMap<bigint, number>;
readonly zeroFillPointers: ReadonlyMap<bigint, bigint>;
/** Mach-O loader fixups, used when interpreting method pointer slots. */
/** Mach-O loader fixups shared by every native pointer consumer. */
readonly machoPointerFixups?: PointerFixups;
}
@@ -191,13 +191,8 @@ export function* openImageSteps(
};
}
export const readPointer = (
image: NativeImage,
address: bigint,
): bigint | null => {
if (hasUnresolvedPointer(image, address)) return null;
const virtual = image.zeroFillPointers.get(address);
if (virtual !== undefined) return virtual;
/** Stored bytes only; loader interpretation belongs to readSlot. */
const readStoredWord = (image: NativeImage, address: bigint): bigint | null => {
try {
const offset = mappedFileOffset(
image.container.mappings,
@@ -216,8 +211,12 @@ export const readPointer = (
export const readUInt32 = (
image: NativeImage,
address: bigint,
): number | null => {
if (hasUnresolvedPointer(image, address, 4)) return null;
): number | "unknown" | null => {
if (
hasUnresolvedElfBytes(image, address, 4) ||
image.machoPointerFixups?.hasUnresolvedLegacyBytes(address, 4) === true
)
return "unknown";
const virtual = image.zeroFillPointers.get(address);
if (virtual !== undefined) return Number(virtual & 0xffffffffn);
try {
@@ -329,7 +328,7 @@ export const isExecutable = (image: NativeImage, address: bigint): boolean => {
}
};
const hasUnresolvedPointer = (
const hasUnresolvedElfBytes = (
image: NativeImage,
address: bigint,
size = 8,
@@ -346,13 +345,13 @@ export const readSlot = (
image: NativeImage,
address: bigint,
): bigint | "unknown" | null => {
if (hasUnresolvedPointer(image, address)) return "unknown";
if (hasUnresolvedElfBytes(image, address)) return "unknown";
try {
const virtual = image.zeroFillPointers.get(address);
let raw: bigint | null;
if (virtual !== undefined) raw = virtual;
else if (isZeroFilled(image, address, 8)) raw = 0n;
else raw = readPointer(image, address);
else raw = readStoredWord(image, address);
if (raw === null) return null;
if (image.machoPointerFixups !== undefined) {
const resolution = image.machoPointerFixups.decode(address, raw);
@@ -372,3 +371,12 @@ export const readSlot = (
throw cause;
}
};
/** Require an observed resolved pointer; unknown and unmapped slots stay null. */
export const readPointer = (
image: NativeImage,
address: bigint,
): bigint | null => {
const value = readSlot(image, address);
return typeof value === "bigint" ? value : null;
};
+35 -15
View File
@@ -7,6 +7,7 @@ import {
isMapped,
hasPointerArrayCoverage,
readPointer,
readUInt32,
type NativeImage,
} from "./Il2cppBinaryImage.js";
import type { Il2cppMetadataDecoded } from "./Il2cppMetadataFile.js";
@@ -136,6 +137,8 @@ export function* findModules(
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(
@@ -160,9 +163,10 @@ export function* findModules(
let work = 0;
for (const index of indices) {
if (++work % 256 === 0) yield;
const count = image.bytes.readUInt32LE(offset + 8);
const count = readUInt32(image, address + 8n);
if (count !== counts[index]) continue;
const pointers = image.bytes.readBigUInt64LE(offset + 16);
const pointers = readPointer(image, address + 16n);
if (pointers === null) continue;
if (!hasPointerArrayCoverage(image, pointers, count)) continue;
const list = candidates.get(index) ?? [];
if (list.some((candidate) => candidate.address === address)) continue;
@@ -207,7 +211,6 @@ export function* findCodeRegistration(
modules: readonly Il2cppCodeGenModule[],
): Generator<void, { readonly field: bigint | null; readonly array: bigint }> {
const expected = new Set(modules.map((module) => module.address));
const total = BigInt(modules.length);
const matches: { field: bigint; array: bigint }[] = [];
const matchedFields = new Set<bigint>();
for (const mapping of image.container.mappings) {
@@ -215,9 +218,13 @@ export function* findCodeRegistration(
for (let at = start; at + 16 <= mapping.size; at += 8) {
if ((at & 0xffff) < 8) yield;
const offset = mapping.offset + at;
if (image.bytes.readBigUInt64LE(offset) !== total) continue;
const array = image.bytes.readBigUInt64LE(offset + 8);
if (!isMapped(image, array, modules.length * 8)) continue;
// 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);
if (array === null || !isMapped(image, array, modules.length * 8))
continue;
const seen = new Set<bigint>();
for (let slot = 0; slot < modules.length; slot++) {
if (slot % 256 === 0) yield;
@@ -226,7 +233,6 @@ export function* findCodeRegistration(
seen.add(pointer);
}
if (seen.size === modules.length) {
const field = mapping.address + BigInt(at);
// Exact load aliases can visit one virtual registration field twice.
if (matchedFields.has(field)) continue;
matchedFields.add(field);
@@ -272,8 +278,11 @@ function* findModuleArrayByCode(
const lastPosition = new Map<bigint, number>();
for (let at = start; at + 8 <= mapping.size; at += 8) {
if ((at & 0xffff) < 8) yield;
const value = image.bytes.readBigUInt64LE(mapping.offset + at);
if (!expected.has(value)) {
const raw = image.bytes.readBigUInt64LE(mapping.offset + at);
const value = expected.has(raw)
? readPointer(image, mapping.address + BigInt(at))
: null;
if (value === null || !expected.has(value)) {
runStart = at + 8;
lastPosition.clear();
continue;
@@ -333,21 +342,32 @@ export function* findMetadataRegistration(
const matches: { field: bigint; count: number; types: bigint }[] = [];
const matchedFields = new Set<bigint>();
const low = (offset: number) => image.bytes.readUInt32LE(offset);
const word = (offset: number) => image.bytes.readBigUInt64LE(offset);
for (const mapping of image.container.mappings) {
const start = Number((8n - (mapping.address % 8n)) % 8n);
for (let at = start; at + 128 <= mapping.size; at += 8) {
// 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 count = low(base + 48);
const types = word(base + 56);
const address = mapping.address + BigInt(at);
if (!hasPointerArrayCoverage(image, address, 16)) continue;
if (readUInt32(image, address + 80n) !== typeDefinitions) continue;
if (readUInt32(image, address + 96n) !== typeDefinitions) continue;
const count = readUInt32(image, address + 48n);
const types = readPointer(image, address + 56n);
const fieldOffsets = readPointer(image, address + 88n);
const typeSizes = readPointer(image, address + 104n);
if (
typeof count !== "number" ||
count === 0 ||
count > 0x7fffffff ||
!isMapped(image, word(base + 88), 8) ||
!isMapped(image, word(base + 104), 8) ||
types === null ||
fieldOffsets === null ||
typeSizes === null ||
!isMapped(image, fieldOffsets, 8) ||
!isMapped(image, typeSizes, 8) ||
!isMapped(image, types, count * 8)
)
continue;
+21 -7
View File
@@ -1,6 +1,6 @@
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
readPointer,
readSlot,
readUInt32,
isMapped,
type NativeImage,
@@ -46,12 +46,26 @@ export const createTypeNamer = (
const fail = (message: string): never => {
throw new Il2cppBinaryFormatFailure("invalid-type", message);
};
const pointer = (address: bigint, label: string) =>
readPointer(image, address) ??
fail(`${label} at 0x${address.toString(16)} is not file mapped`);
const u32 = (address: bigint, label: string) =>
readUInt32(image, address) ??
fail(`${label} at 0x${address.toString(16)} is not file mapped`);
const unresolved = (address: bigint, label: string): never => {
throw new Il2cppBinaryFormatFailure(
"unsupported-pointer-encoding",
`${label} at 0x${address.toString(16)} is loader-unresolved; its stored bytes are not a resolved type value`,
);
};
const pointer = (address: bigint, label: string): bigint => {
const value = readSlot(image, address);
if (value === "unknown") return unresolved(address, label);
return (
value ?? fail(`${label} at 0x${address.toString(16)} is not file mapped`)
);
};
const u32 = (address: bigint, label: string): number => {
const value = readUInt32(image, address);
if (value === "unknown") return unresolved(address, label);
return (
value ?? fail(`${label} at 0x${address.toString(16)} is not file mapped`)
);
};
let cachedBytes = 0;
let work = 0;
const guard = (bytes: number) => {
+1 -1
View File
@@ -531,7 +531,7 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
? "Field, parameter and return types are not recovered without binary_path; type indices refer to the native binary."
: decoded.metadata_version === 27
? "Field, parameter and return type names are null for metadata version 27: versions 27.0 to 27.2 share that header number but place the Il2CppType byref flag differently, so a name could gain or lose & without warning; type indices are still reported."
: "Field, parameter and return type names are rendered from the binary's Il2CppType table in a C#-like form (keywords for primitives, Namespace.Outer+Inner, generic parameters by metadata name); nested generic arguments stay with the type component that declares them. Custom modifiers, array bounds and attributes are not reported.",
: "Field, parameter and return type names are rendered from the binary's Il2CppType table in a C#-like form (keywords for primitives, Namespace.Outer+Inner, generic parameters by metadata name); nested generic arguments stay with the type component that declares them. An unresolved type-table slot retains its type index with a null name; a loader-unresolved value inside the type graph is refused rather than rendered from stored placeholder bytes. Custom modifiers, array bounds and attributes are not reported.",
...(linked.binary !== null &&
linked.binary.code_gen_modules_field === null
? [