mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
feat(il2cpp): link native methods and export Ghidra seeds
Link IL2CPP metadata names to native addresses and types through shared CLI/MCP workflows, with bounded queries and atomic Ghidra seed export. Preserve the original implementation by 8rulerstar and follow-up correctness, resource, provenance, and CI dependency repairs. Verified against real Windows/Android artifacts, independent references, actual Ghidra, and focused CLI/MCP tests. Fixes #5112.
This commit is contained in:
1 parent
e50a271c72
commit
52d1a72af0
43 files changed
+5060
-368
No files matched your search
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
GoBinaryFormatFailure,
|
||||
mappedFileOffset,
|
||||
isZeroFilledSpan,
|
||||
type GoFileMapping,
|
||||
type GoZeroFillMapping,
|
||||
} from "../go/GoBinaryContainer.js";
|
||||
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
|
||||
|
||||
const DT_RELA = 7n,
|
||||
DT_RELASZ = 8n,
|
||||
DT_RELAENT = 9n;
|
||||
const DT_PLTRELSZ = 2n,
|
||||
DT_PLTREL = 20n,
|
||||
DT_JMPREL = 23n;
|
||||
const MAX_OVERLAY_ENTRIES = 65536;
|
||||
const malformed = (message: string) =>
|
||||
new Il2cppBinaryFormatFailure("malformed-container", message);
|
||||
const unsupported = (message: string) =>
|
||||
new Il2cppBinaryFormatFailure("unsupported-pointer-encoding", message);
|
||||
|
||||
interface RelocatedImage {
|
||||
readonly bytes: Buffer;
|
||||
readonly unresolved: Set<bigint>;
|
||||
readonly zeroFillPointers: Map<bigint, bigint>;
|
||||
}
|
||||
interface RelocationSource {
|
||||
readonly image: Buffer;
|
||||
readonly mappings: readonly GoFileMapping[];
|
||||
readonly zeroFills: readonly GoZeroFillMapping[];
|
||||
readonly relative: number;
|
||||
readonly unknownPointerKinds: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** Standard dynamic relocations whose destination spans at most eight bytes.
|
||||
* Other encodings (including COPY and TLS descriptors) need their own width
|
||||
* semantics; do not silently apply them or turn their stored zero into absence.
|
||||
*/
|
||||
const RELOCATION_KINDS = new Map([
|
||||
[
|
||||
183,
|
||||
{
|
||||
relative: 1027,
|
||||
unknownPointerKinds: new Set([
|
||||
257, 258, 259, 260, 261, 262, 1025, 1026, 1028, 1029, 1030, 1032,
|
||||
]),
|
||||
},
|
||||
],
|
||||
[
|
||||
62,
|
||||
{
|
||||
relative: 8,
|
||||
unknownPointerKinds: new Set([1, 2, 6, 7, 10, 11, 16, 17, 18, 24, 37]),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function* dynamicTags(image: Buffer): Generator<void, Map<bigint, bigint>> {
|
||||
if (image.length < 64) throw malformed("ELF header is truncated");
|
||||
const offset = Number(image.readBigUInt64LE(32)),
|
||||
width = image.readUInt16LE(54),
|
||||
count = image.readUInt16LE(56);
|
||||
if (width < 56 || offset + count * width > image.length)
|
||||
throw malformed("ELF program headers extend beyond the file");
|
||||
let dynamic: { offset: number; size: number } | undefined;
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const at = offset + index * width;
|
||||
if (image.readUInt32LE(at) !== 2) continue;
|
||||
if (dynamic !== undefined) throw malformed("multiple PT_DYNAMIC segments");
|
||||
dynamic = {
|
||||
offset: Number(image.readBigUInt64LE(at + 8)),
|
||||
size: Number(image.readBigUInt64LE(at + 32)),
|
||||
};
|
||||
}
|
||||
if (dynamic === undefined)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"unsupported-container",
|
||||
"ELF image has no PT_DYNAMIC segment",
|
||||
);
|
||||
if (dynamic.size % 16 !== 0 || dynamic.offset + dynamic.size > image.length)
|
||||
throw malformed("PT_DYNAMIC is truncated or outside the file");
|
||||
const tags = new Map<bigint, bigint>();
|
||||
const unique = new Set([
|
||||
DT_RELA,
|
||||
DT_RELASZ,
|
||||
DT_RELAENT,
|
||||
DT_JMPREL,
|
||||
DT_PLTRELSZ,
|
||||
DT_PLTREL,
|
||||
]);
|
||||
for (let at = dynamic.offset; at < dynamic.offset + dynamic.size; at += 16) {
|
||||
if ((at - dynamic.offset) % 65536 === 0) yield;
|
||||
const tag = image.readBigInt64LE(at);
|
||||
if (tag === 0n) return tags;
|
||||
if (tags.has(tag) && unique.has(tag))
|
||||
throw malformed(`duplicate dynamic tag ${tag}`);
|
||||
tags.set(tag, image.readBigUInt64LE(at + 8));
|
||||
}
|
||||
throw malformed("PT_DYNAMIC has no DT_NULL terminator");
|
||||
}
|
||||
|
||||
const fileOffset = (
|
||||
mappings: readonly GoFileMapping[],
|
||||
address: bigint,
|
||||
size: number,
|
||||
): number | null => {
|
||||
try {
|
||||
return mappedFileOffset(mappings, address, size);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure) return null;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const admitOverlay = (output: RelocatedImage) => {
|
||||
if (
|
||||
output.unresolved.size + output.zeroFillPointers.size >=
|
||||
MAX_OVERLAY_ENTRIES
|
||||
)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"native-index-capacity",
|
||||
`ELF relocation overlay exceeds ${MAX_OVERLAY_ENTRIES} entries`,
|
||||
);
|
||||
};
|
||||
|
||||
/** Each admitted record is interpreted once from the original immutable bytes. */
|
||||
function* applyRelaTable(
|
||||
source: RelocationSource,
|
||||
output: RelocatedImage,
|
||||
address: bigint,
|
||||
size: bigint | undefined,
|
||||
entry: bigint,
|
||||
): Generator<void> {
|
||||
if (
|
||||
size === undefined ||
|
||||
size < 0n ||
|
||||
entry !== 24n ||
|
||||
size % entry !== 0n ||
|
||||
size > BigInt(source.image.length)
|
||||
)
|
||||
throw malformed("invalid DT_RELASZ or DT_RELAENT");
|
||||
const start = fileOffset(source.mappings, address, Number(size));
|
||||
if (start === null) throw malformed("RELA table is not file-backed");
|
||||
for (let at = start; at < start + Number(size); at += 24) {
|
||||
if ((at - start) % (24 * 4096) === 0) yield;
|
||||
const info = source.image.readBigUInt64LE(at + 8),
|
||||
kind = Number(info & 0xffffffffn);
|
||||
if (kind === 0) continue;
|
||||
const destination = source.image.readBigUInt64LE(at);
|
||||
const target = fileOffset(source.mappings, destination, 8);
|
||||
if (kind !== source.relative || info >> 32n !== 0n) {
|
||||
if (!source.unknownPointerKinds.has(kind))
|
||||
throw unsupported(`ELF relocation kind ${kind} is not decoded`);
|
||||
if (!output.unresolved.has(destination)) {
|
||||
admitOverlay(output);
|
||||
output.unresolved.add(destination);
|
||||
}
|
||||
// Registration scans read this private image directly. Clear unknown
|
||||
// fields so their stored bytes cannot masquerade as resolved pointers.
|
||||
if (target !== null) output.bytes.fill(0, target, target + 8);
|
||||
continue;
|
||||
}
|
||||
const value = source.image.readBigInt64LE(at + 16) & 0xffffffffffffffffn;
|
||||
if (target !== null) output.bytes.writeBigUInt64LE(value, target);
|
||||
else if (
|
||||
isZeroFilledSpan(source.mappings, source.zeroFills, destination, 8)
|
||||
) {
|
||||
if (!output.zeroFillPointers.has(destination)) admitOverlay(output);
|
||||
output.zeroFillPointers.set(destination, value);
|
||||
} else
|
||||
throw malformed(
|
||||
"RELATIVE relocation target is outside unambiguous loaded memory",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize relative pointers in a private copy, preserving unknown and .bss facts. */
|
||||
export function* applyElfRelativeRelocationsSteps(
|
||||
image: Buffer,
|
||||
mappings: readonly GoFileMapping[],
|
||||
zeroFills: readonly GoZeroFillMapping[],
|
||||
): Generator<void, RelocatedImage> {
|
||||
if (image.length < 64) throw malformed("ELF header is truncated");
|
||||
const kinds = RELOCATION_KINDS.get(image.readUInt16LE(18));
|
||||
if (kinds === undefined)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"unsupported-container",
|
||||
"ELF relocations require AArch64 or x86-64",
|
||||
);
|
||||
const tags = yield* dynamicTags(image);
|
||||
if (tags.has(0x6000000fn) || tags.has(0x60000011n) || tags.has(17n))
|
||||
throw unsupported(
|
||||
"Android packed (APS2) or REL relocations are not decoded",
|
||||
);
|
||||
yield;
|
||||
const output: RelocatedImage = {
|
||||
bytes: Buffer.from(image),
|
||||
unresolved: new Set(),
|
||||
zeroFillPointers: new Map(),
|
||||
};
|
||||
const source = { image, mappings, zeroFills, ...kinds };
|
||||
const rela = tags.get(DT_RELA);
|
||||
if (rela !== undefined)
|
||||
yield* applyRelaTable(
|
||||
source,
|
||||
output,
|
||||
rela,
|
||||
tags.get(DT_RELASZ),
|
||||
tags.get(DT_RELAENT) ?? 24n,
|
||||
);
|
||||
else if ((tags.get(DT_RELASZ) ?? 0n) !== 0n)
|
||||
throw malformed("DT_RELASZ has no DT_RELA");
|
||||
const plt = tags.get(DT_JMPREL);
|
||||
if (plt !== undefined) {
|
||||
if (tags.get(DT_PLTREL) !== DT_RELA)
|
||||
throw unsupported("PLT relocations require RELA encoding");
|
||||
yield* applyRelaTable(source, output, plt, tags.get(DT_PLTRELSZ), 24n);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Il2cppMethodAddress } from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
|
||||
import {
|
||||
openImageSteps,
|
||||
readPointer,
|
||||
readSlot,
|
||||
isExecutable,
|
||||
} from "./Il2cppBinaryImage.js";
|
||||
import {
|
||||
findModules,
|
||||
findCodeRegistration,
|
||||
findMetadataRegistration,
|
||||
} from "./Il2cppBinaryRegistration.js";
|
||||
import { createTypeNamer } from "./Il2cppBinaryTypes.js";
|
||||
import type {
|
||||
Il2cppMetadataDecoded,
|
||||
Il2cppMetadataLinkFacts,
|
||||
Il2cppMetadataReadOptions,
|
||||
} from "./Il2cppMetadataFile.js";
|
||||
/** One linked code-generation module; its name matches a metadata image. */
|
||||
export interface Il2cppCodeGenModule {
|
||||
readonly image: number;
|
||||
readonly address: bigint;
|
||||
readonly method_pointer_count: number;
|
||||
readonly method_pointers: bigint;
|
||||
}
|
||||
|
||||
type Il2cppMethodCode = bigint | "no-body" | "unknown";
|
||||
|
||||
export interface Il2cppBinaryLink {
|
||||
readonly format: "elf" | "macho" | "pe";
|
||||
readonly architecture: string;
|
||||
readonly image_base: bigint;
|
||||
/**
|
||||
* Address of the CodeRegistration codeGenModulesCount field, or null when the
|
||||
* static registration pair was unresolved and an executable byte-pattern
|
||||
* candidate identified the array; instruction boundaries remain unverified.
|
||||
*/
|
||||
readonly code_gen_modules_field: bigint | null;
|
||||
/** Address of the code-generation module pointer array. */
|
||||
readonly code_gen_modules_array: bigint;
|
||||
readonly code_gen_modules: readonly Il2cppCodeGenModule[];
|
||||
/**
|
||||
* Native code address per metadata method index; "no-body" when its module
|
||||
* slot is zero, "unknown" when its owner or relocated pointer is unresolved.
|
||||
*/
|
||||
readonly method_addresses: Il2cppMethodAddress[];
|
||||
/** Address of MetadataRegistration's typesCount field. */
|
||||
readonly types_field: bigint;
|
||||
readonly types_count: number;
|
||||
/** Il2CppType table index and display name per method return, field and parameter. */
|
||||
readonly return_types: Il2cppTypeReference[];
|
||||
readonly field_types: Il2cppTypeReference[];
|
||||
readonly parameter_types: Il2cppTypeReference[];
|
||||
}
|
||||
|
||||
export type Il2cppTypeReference = {
|
||||
readonly type: number;
|
||||
readonly name: string | null;
|
||||
};
|
||||
|
||||
export type Il2cppBinaryReadOptions = Pick<
|
||||
Il2cppMetadataReadOptions,
|
||||
"beforeText" | "onRow"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Link metadata methods to native code addresses without executing the image.
|
||||
* A method token's row id selects its module's methodPointers slot.
|
||||
*/
|
||||
export function* linkIl2cppBinarySteps(
|
||||
bytes: Buffer,
|
||||
metadata: Il2cppMetadataDecoded,
|
||||
facts: Il2cppMetadataLinkFacts,
|
||||
architecture?: string,
|
||||
options: Il2cppBinaryReadOptions = {},
|
||||
): Generator<void, Il2cppBinaryLink> {
|
||||
const version = metadata.metadata_version;
|
||||
yield;
|
||||
const image = yield* openImageSteps(bytes, architecture);
|
||||
const modules = yield* findModules(image, metadata);
|
||||
const registration_ = yield* findCodeRegistration(image, modules);
|
||||
const methodImage = new Int32Array(metadata.methods.length).fill(-1);
|
||||
for (const [index, entry] of metadata.images.entries())
|
||||
for (let offset = 0; offset < entry.type_count; offset++) {
|
||||
const type = metadata.types[entry.type_start + offset]!;
|
||||
if (offset % LINK_BATCH === 0) yield;
|
||||
for (let method = 0; method < type.method_count; method++) {
|
||||
if (method % LINK_BATCH === 0) yield;
|
||||
methodImage[type.method_start + method] = index;
|
||||
}
|
||||
}
|
||||
yield;
|
||||
const methodAddress = (index: number): Il2cppMethodCode => {
|
||||
const owner = methodImage[index]!;
|
||||
// No image range holds this method, so no module slot describes it.
|
||||
if (owner === -1) return "unknown";
|
||||
const module = modules[owner]!;
|
||||
const token = facts.methodTokens[index]!;
|
||||
const row = token & 0xffffff;
|
||||
if (token >>> 24 !== 0x06 || row === 0 || row > module.method_pointer_count)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"invalid-method-token",
|
||||
`method ${index} token 0x${token.toString(16)} is outside module ${metadata.images[owner]!.name}'s ${module.method_pointer_count} method pointers`,
|
||||
);
|
||||
const address = readSlot(
|
||||
image,
|
||||
module.method_pointers + BigInt((row - 1) * 8),
|
||||
);
|
||||
if (address === null || address === undefined)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"invalid-method-pointer",
|
||||
`module ${metadata.images[owner]!.name} method pointer ${row - 1} is not file mapped`,
|
||||
);
|
||||
if (address === "unknown") return "unknown";
|
||||
if (address === 0n) return "no-body";
|
||||
if (!isExecutable(image, address))
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"invalid-method-pointer",
|
||||
`method ${index} points outside unambiguous executable file data at 0x${address.toString(16)}`,
|
||||
);
|
||||
return address;
|
||||
};
|
||||
const method_addresses: Il2cppMethodAddress[] = [];
|
||||
for (let index = 0; index < metadata.methods.length; index++) {
|
||||
if (index % LINK_BATCH === 0) yield;
|
||||
const code = methodAddress(index);
|
||||
if (typeof code === "bigint" && code < image.imageBase)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"invalid-method-pointer",
|
||||
"method address precedes the link image base",
|
||||
);
|
||||
const row: Il2cppMethodAddress =
|
||||
typeof code === "bigint"
|
||||
? {
|
||||
state: "code",
|
||||
address: `0x${code.toString(16)}`,
|
||||
rva: `0x${(code - image.imageBase).toString(16)}`,
|
||||
}
|
||||
: { state: code, address: null, rva: null };
|
||||
options.onRow?.(row);
|
||||
method_addresses.push(row);
|
||||
}
|
||||
const registration = yield* findMetadataRegistration(
|
||||
image,
|
||||
metadata.types.length,
|
||||
);
|
||||
const typeName = createTypeNamer(
|
||||
image,
|
||||
metadata,
|
||||
facts,
|
||||
version,
|
||||
options.beforeText,
|
||||
);
|
||||
function* reference(type: number): Generator<void, Il2cppTypeReference> {
|
||||
if (type === -1) return { type, name: null };
|
||||
if (type < 0 || type >= registration.count)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"invalid-type",
|
||||
`type index ${type} is outside the ${registration.count} binary types`,
|
||||
);
|
||||
// 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;
|
||||
if (address === 0n)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"invalid-type",
|
||||
`type ${type} has no Il2CppType record`,
|
||||
);
|
||||
return { type, name: yield* typeName(address, 0) };
|
||||
}
|
||||
function* column(types: Int32Array) {
|
||||
const rows: Il2cppTypeReference[] = [];
|
||||
for (let index = 0; index < types.length; index++) {
|
||||
if (index % LINK_BATCH === 0) yield;
|
||||
const row = yield* reference(types[index]!);
|
||||
options.onRow?.(row);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
const return_types = yield* column(facts.returnTypes);
|
||||
const field_types = yield* column(facts.fieldTypes);
|
||||
const parameter_types = yield* column(facts.parameterTypes);
|
||||
return {
|
||||
format: image.container.format,
|
||||
architecture: image.container.architecture,
|
||||
image_base: image.imageBase,
|
||||
code_gen_modules_field: registration_.field,
|
||||
code_gen_modules_array: registration_.array,
|
||||
code_gen_modules: modules,
|
||||
method_addresses,
|
||||
types_field: registration.field,
|
||||
types_count: registration.count,
|
||||
return_types,
|
||||
field_types,
|
||||
parameter_types,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rows between cooperative yields while linking. */
|
||||
const LINK_BATCH = 256;
|
||||
|
||||
/** Synchronous linking for byte-level callers. */
|
||||
export const linkIl2cppBinary = (
|
||||
bytes: Buffer,
|
||||
metadata: Il2cppMetadataDecoded,
|
||||
facts: Il2cppMetadataLinkFacts,
|
||||
architecture?: string,
|
||||
): Il2cppBinaryLink => {
|
||||
const steps = linkIl2cppBinarySteps(bytes, metadata, facts, architecture);
|
||||
for (;;) {
|
||||
const step = steps.next();
|
||||
if (step.done) return step.value;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
export const IL2CPP_BINARY_FAILURE_REASONS = {
|
||||
unsupported: [
|
||||
"unsupported-container",
|
||||
"slice-selection-required",
|
||||
"slice-not-found",
|
||||
"ambiguous-slice-selection",
|
||||
"unsupported-pointer-encoding",
|
||||
"unsupported-type-kind",
|
||||
],
|
||||
capacity: [
|
||||
"native-index-capacity",
|
||||
"type-nesting-capacity",
|
||||
"native-type-text-capacity",
|
||||
],
|
||||
malformed: [
|
||||
"malformed-container",
|
||||
"code-gen-modules-not-found",
|
||||
"ambiguous-code-gen-module",
|
||||
"code-registration-not-found",
|
||||
"invalid-method-pointer",
|
||||
"invalid-method-token",
|
||||
"metadata-registration-not-found",
|
||||
"invalid-type",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type Il2cppBinaryFailureReason =
|
||||
(typeof IL2CPP_BINARY_FAILURE_REASONS)[keyof typeof IL2CPP_BINARY_FAILURE_REASONS][number];
|
||||
|
||||
/** A native image REA cannot link, either outside coverage or inconsistent with the metadata. */
|
||||
export class Il2cppBinaryFormatFailure extends Error {
|
||||
readonly kind: "unsupported" | "malformed" | "capacity";
|
||||
constructor(
|
||||
readonly reason: Il2cppBinaryFailureReason,
|
||||
message: string,
|
||||
) {
|
||||
super(`${reason}: ${message}`);
|
||||
this.name = "Il2cppBinaryFormatFailure";
|
||||
this.kind = (
|
||||
IL2CPP_BINARY_FAILURE_REASONS.unsupported as readonly string[]
|
||||
).includes(reason)
|
||||
? "unsupported"
|
||||
: (IL2CPP_BINARY_FAILURE_REASONS.capacity as readonly string[]).includes(
|
||||
reason,
|
||||
)
|
||||
? "capacity"
|
||||
: "malformed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
readFatContainerHeader,
|
||||
validateFatSlice,
|
||||
} from "../domain/apple/machoContainer.js";
|
||||
import {
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryResourceFailure,
|
||||
mappedFileOffset,
|
||||
isZeroFilledSpan,
|
||||
type GoBinaryContainer,
|
||||
} from "../go/GoBinaryContainer.js";
|
||||
import { readGoBinaryContainer } from "../go/GoBinaryImage.js";
|
||||
import { applyElfRelativeRelocationsSteps } from "./ElfRelocations.js";
|
||||
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
|
||||
const MACHO_CPU = new Map([
|
||||
[0x01000007, "amd64"],
|
||||
[0x0100000c, "arm64"],
|
||||
]);
|
||||
|
||||
/** Select one thin image; a universal Mach-O needs the caller's architecture. */
|
||||
const selectImage = (
|
||||
bytes: Buffer,
|
||||
architecture: string | undefined,
|
||||
): Buffer => {
|
||||
const fat = readFatContainerHeader(bytes, bytes.length);
|
||||
if (fat.state === "not-fat") {
|
||||
if (architecture !== undefined)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"slice-not-found",
|
||||
`architecture ${architecture} was requested but the binary is not a universal Mach-O`,
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
if (fat.state !== "parsed")
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"malformed-container",
|
||||
fat.state === "malformed" ? fat.reason : "universal header is truncated",
|
||||
);
|
||||
const available = fat.slices.map(
|
||||
(slice) => MACHO_CPU.get(slice.cpuType) ?? `macho-cpu-${slice.cpuType}`,
|
||||
);
|
||||
if (architecture === undefined)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"slice-selection-required",
|
||||
`universal Mach-O holds ${available.join(", ")}; select one architecture`,
|
||||
);
|
||||
if (available.filter((value) => value === architecture).length > 1)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"ambiguous-slice-selection",
|
||||
`multiple universal slices declare ${architecture}; no slice was selected`,
|
||||
);
|
||||
const index = available.indexOf(architecture);
|
||||
const slice = fat.slices[index];
|
||||
if (slice === undefined)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"slice-not-found",
|
||||
`universal Mach-O holds ${available.join(", ")}, not ${architecture}`,
|
||||
);
|
||||
const issue = validateFatSlice(slice, bytes.length, fat.tableEnd);
|
||||
if (issue !== null)
|
||||
throw new Il2cppBinaryFormatFailure("malformed-container", issue);
|
||||
return bytes.subarray(slice.offset, slice.offset + slice.size);
|
||||
};
|
||||
|
||||
export interface NativeImage {
|
||||
readonly bytes: Buffer;
|
||||
readonly container: GoBinaryContainer;
|
||||
readonly imageBase: bigint;
|
||||
readonly unresolvedPointerSlots: ReadonlySet<bigint>;
|
||||
readonly zeroFillPointers: ReadonlyMap<bigint, bigint>;
|
||||
}
|
||||
|
||||
export function* openImageSteps(
|
||||
bytes: Buffer,
|
||||
architecture: string | undefined,
|
||||
): Generator<void, NativeImage> {
|
||||
let image = selectImage(bytes, architecture);
|
||||
let container: GoBinaryContainer;
|
||||
try {
|
||||
container = readGoBinaryContainer(image);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryResourceFailure)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"native-index-capacity",
|
||||
cause.message,
|
||||
);
|
||||
if (cause instanceof GoBinaryFormatFailure)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
cause.kind === "unsupported"
|
||||
? "unsupported-container"
|
||||
: "malformed-container",
|
||||
cause.message,
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
if (
|
||||
architecture !== undefined &&
|
||||
(container.format !== "macho" || container.architecture !== architecture)
|
||||
)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"malformed-container",
|
||||
"Selected FAT declaration disagrees with the slice header architecture.",
|
||||
);
|
||||
if (container.bits !== 64 || container.byte_order !== "little")
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"unsupported-container",
|
||||
`only 64-bit little-endian images are supported, not ${container.bits}-bit ${container.byte_order}-endian`,
|
||||
);
|
||||
let relocated:
|
||||
| {
|
||||
bytes: Buffer;
|
||||
unresolved: ReadonlySet<bigint>;
|
||||
zeroFillPointers: ReadonlyMap<bigint, bigint>;
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
if (container.format === "elf")
|
||||
relocated = yield* applyElfRelativeRelocationsSteps(
|
||||
image,
|
||||
container.mappings,
|
||||
container.zero_fills,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure)
|
||||
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",
|
||||
"Mach-O chained fixups are not decoded",
|
||||
);
|
||||
if (container.format === "pe" && container.image_base === undefined)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"malformed-container",
|
||||
"PE image base was not observed",
|
||||
);
|
||||
const imageBase =
|
||||
container.image_base ??
|
||||
container.mappings.find((mapping) => mapping.offset === 0)?.address ??
|
||||
container.mappings.reduce<bigint | undefined>(
|
||||
(lowest, mapping) =>
|
||||
lowest === undefined || mapping.address < lowest
|
||||
? mapping.address
|
||||
: lowest,
|
||||
undefined,
|
||||
) ??
|
||||
0n;
|
||||
return {
|
||||
bytes: image,
|
||||
container,
|
||||
imageBase,
|
||||
unresolvedPointerSlots: relocated?.unresolved ?? new Set<bigint>(),
|
||||
zeroFillPointers: relocated?.zeroFillPointers ?? new Map<bigint, bigint>(),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
const offset = mappedFileOffset(
|
||||
image.container.mappings,
|
||||
address,
|
||||
8,
|
||||
image.container.zero_fills,
|
||||
);
|
||||
return image.bytes.readBigUInt64LE(offset);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure) return null;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** A UInt32 field owns four bytes; reading a pointer would also require unrelated padding. */
|
||||
export const readUInt32 = (
|
||||
image: NativeImage,
|
||||
address: bigint,
|
||||
): number | null => {
|
||||
if (hasUnresolvedPointer(image, address, 4)) return null;
|
||||
const virtual = image.zeroFillPointers.get(address);
|
||||
if (virtual !== undefined) return Number(virtual & 0xffffffffn);
|
||||
try {
|
||||
return image.bytes.readUInt32LE(
|
||||
mappedFileOffset(
|
||||
image.container.mappings,
|
||||
address,
|
||||
4,
|
||||
image.container.zero_fills,
|
||||
),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure) return null;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
export const isMapped = (image: NativeImage, address: bigint, size: number) => {
|
||||
try {
|
||||
mappedFileOffset(image.container.mappings, address, size);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure) return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
// An array of slots that all lack a body can sit wholly in zero-fill (.bss):
|
||||
// an iOS-only assembly in an Android build has its pointers there.
|
||||
export const isZeroFilled = (
|
||||
image: NativeImage,
|
||||
address: bigint,
|
||||
size: number,
|
||||
) =>
|
||||
isZeroFilledSpan(
|
||||
image.container.mappings,
|
||||
image.container.zero_fills,
|
||||
address,
|
||||
size,
|
||||
);
|
||||
|
||||
/** Admit readable slots across loader ranges without walking every pointer.
|
||||
* Each chunk uses the format owner's alias checks; a gap or split slot is refused.
|
||||
*/
|
||||
export const hasPointerArrayCoverage = (
|
||||
image: NativeImage,
|
||||
address: bigint,
|
||||
count: number,
|
||||
): boolean => {
|
||||
const end = address + BigInt(count) * 8n;
|
||||
let cursor = address;
|
||||
while (cursor < end) {
|
||||
let fileEnd = cursor;
|
||||
for (const range of image.container.mappings)
|
||||
if (
|
||||
range.address <= cursor &&
|
||||
range.address + BigInt(range.size) > fileEnd
|
||||
)
|
||||
fileEnd = range.address + BigInt(range.size);
|
||||
let coveredEnd = fileEnd;
|
||||
if (fileEnd === cursor)
|
||||
for (const range of image.container.zero_fills)
|
||||
if (range.address <= cursor && range.address + range.size > coveredEnd)
|
||||
coveredEnd = range.address + range.size;
|
||||
const stop = coveredEnd < end ? coveredEnd : end;
|
||||
const size = Number(((stop - cursor) / 8n) * 8n);
|
||||
if (size <= 0) return false;
|
||||
try {
|
||||
if (fileEnd === cursor) {
|
||||
if (!isZeroFilled(image, cursor, size)) return false;
|
||||
} else {
|
||||
mappedFileOffset(
|
||||
image.container.mappings,
|
||||
cursor,
|
||||
size,
|
||||
image.container.zero_fills,
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure) return false;
|
||||
throw cause;
|
||||
}
|
||||
cursor += BigInt(size);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Both executable coverage and the unique full loader mapping must agree. */
|
||||
export const isExecutable = (image: NativeImage, address: bigint): boolean => {
|
||||
try {
|
||||
const offset = mappedFileOffset(
|
||||
image.container.mappings,
|
||||
address,
|
||||
1,
|
||||
image.container.zero_fills,
|
||||
);
|
||||
return (
|
||||
offset ===
|
||||
mappedFileOffset(image.container.executable_mappings ?? [], address, 1)
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure) return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const hasUnresolvedPointer = (
|
||||
image: NativeImage,
|
||||
address: bigint,
|
||||
size = 8,
|
||||
): boolean => {
|
||||
if (image.unresolvedPointerSlots.size === 0) return false;
|
||||
// Unsupported relocation widths are unknown; conservatively cover any
|
||||
// overlap with the pointer rather than infer a zero or trust stored bytes.
|
||||
for (let delta = -7; delta < size; delta++)
|
||||
if (image.unresolvedPointerSlots.has(address + BigInt(delta))) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const readSlot = (
|
||||
image: NativeImage,
|
||||
address: bigint,
|
||||
): bigint | "unknown" | null => {
|
||||
if (hasUnresolvedPointer(image, address)) return "unknown";
|
||||
const virtual = image.zeroFillPointers.get(address);
|
||||
if (virtual !== undefined) return virtual;
|
||||
try {
|
||||
return isZeroFilled(image, address, 8) ? 0n : readPointer(image, address);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure)
|
||||
throw new Il2cppBinaryFormatFailure("malformed-container", cause.message);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
|
||||
import {
|
||||
isMapped,
|
||||
hasPointerArrayCoverage,
|
||||
readPointer,
|
||||
type NativeImage,
|
||||
} from "./Il2cppBinaryImage.js";
|
||||
import type { Il2cppMetadataDecoded } from "./Il2cppMetadataFile.js";
|
||||
import type { Il2cppCodeGenModule } from "./Il2cppBinaryFile.js";
|
||||
// Bound the retained name-address index independently of binary size. No
|
||||
// repeated name is evidence of ambiguous module ownership by itself.
|
||||
const TOTAL_NAME_OCCURRENCES = 65536;
|
||||
const SCAN_BYTES = 65536;
|
||||
|
||||
interface NameTrie {
|
||||
readonly next: Map<number, NameTrie>;
|
||||
readonly owners: number[];
|
||||
}
|
||||
|
||||
/** Reverse byte matching permits C-string suffix sharing. Only a module's
|
||||
* declared pointer later establishes which exact candidate it actually names.
|
||||
* No decoding/alphabet guess or per-name binary rescan is needed.
|
||||
*/
|
||||
function* nameAddresses(
|
||||
image: NativeImage,
|
||||
names: readonly string[],
|
||||
): Generator<void, Map<bigint, readonly number[]>> {
|
||||
const root: NameTrie = { next: new Map(), owners: [] };
|
||||
let nodes = 1,
|
||||
work = 0;
|
||||
for (const [index, name] of names.entries()) {
|
||||
if (++work % 256 === 0) yield;
|
||||
if (name === "") continue;
|
||||
if (Buffer.byteLength(name) > TOTAL_NAME_OCCURRENCES)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"native-index-capacity",
|
||||
"module-name trie exceeds its65536-node workspace",
|
||||
);
|
||||
let node = root;
|
||||
const bytes = Buffer.from(name, "utf8");
|
||||
for (let at = bytes.length - 1; at >= 0; at--) {
|
||||
if (++work % 4096 === 0) yield;
|
||||
let next = node.next.get(bytes[at]!);
|
||||
if (next === undefined) {
|
||||
if (nodes >= TOTAL_NAME_OCCURRENCES)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"native-index-capacity",
|
||||
"module-name trie exceeds its65536-node workspace",
|
||||
);
|
||||
next = { next: new Map(), owners: [] };
|
||||
node.next.set(bytes[at]!, next);
|
||||
nodes++;
|
||||
}
|
||||
node = next;
|
||||
}
|
||||
node.owners.push(index);
|
||||
}
|
||||
const found = new Map<bigint, readonly number[]>();
|
||||
for (const mapping of image.container.mappings) {
|
||||
const region = image.bytes.subarray(
|
||||
mapping.offset,
|
||||
mapping.offset + mapping.size,
|
||||
);
|
||||
for (let chunk = 0; chunk < region.length; chunk += SCAN_BYTES) {
|
||||
yield;
|
||||
const part = region.subarray(
|
||||
chunk,
|
||||
Math.min(chunk + SCAN_BYTES, region.length),
|
||||
);
|
||||
for (
|
||||
let end = part.indexOf(0);
|
||||
end >= 0;
|
||||
end = part.indexOf(0, end + 1)
|
||||
) {
|
||||
let node: NameTrie | undefined = root;
|
||||
for (let at = chunk + end - 1; at >= 0; at--) {
|
||||
if (++work % 4096 === 0) yield;
|
||||
node = node.next.get(region[at]!);
|
||||
if (node === undefined) break;
|
||||
if (node.owners.length === 0) continue;
|
||||
const address = mapping.address + BigInt(at);
|
||||
if (!found.has(address) && found.size >= TOTAL_NAME_OCCURRENCES)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"native-index-capacity",
|
||||
`native name-address index exceeds ${TOTAL_NAME_OCCURRENCES} entries`,
|
||||
);
|
||||
found.set(address, node.owners);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Method count per image from its contiguous type-definition range. */
|
||||
function* imageMethodCounts(
|
||||
metadata: Il2cppMetadataDecoded,
|
||||
): Generator<void, number[]> {
|
||||
const counts: number[] = [];
|
||||
for (const image of metadata.images) {
|
||||
yield;
|
||||
let total = 0;
|
||||
for (let offset = 0; offset < image.type_count; offset++) {
|
||||
if (offset % 256 === 0) yield;
|
||||
total += metadata.types[image.type_start + offset]!.method_count;
|
||||
}
|
||||
counts.push(total);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Il2CppCodeGenModule records: a moduleName pointer to an image name
|
||||
* followed by methodPointerCount equal to that image's method count.
|
||||
*/
|
||||
export function* findModules(
|
||||
image: NativeImage,
|
||||
metadata: Il2cppMetadataDecoded,
|
||||
): Generator<void, Il2cppCodeGenModule[]> {
|
||||
const names = yield* nameAddresses(
|
||||
image,
|
||||
metadata.images.map((entry) => entry.name),
|
||||
);
|
||||
const counts = yield* imageMethodCounts(metadata);
|
||||
const candidates = new Map<number, Il2cppCodeGenModule[]>();
|
||||
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 indices = names.get(target);
|
||||
if (indices === undefined) continue;
|
||||
let work = 0;
|
||||
for (const index of indices) {
|
||||
if (++work % 256 === 0) yield;
|
||||
const count = image.bytes.readUInt32LE(offset + 8);
|
||||
if (count !== counts[index]) continue;
|
||||
const pointers = image.bytes.readBigUInt64LE(offset + 16);
|
||||
if (!hasPointerArrayCoverage(image, pointers, count)) continue;
|
||||
const list = candidates.get(index) ?? [];
|
||||
if (list.length !== 0)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"ambiguous-code-gen-module",
|
||||
`multiple records match code generation module ${metadata.images[index]!.name}`,
|
||||
);
|
||||
list.push({
|
||||
image: index,
|
||||
address: mapping.address + BigInt(at),
|
||||
method_pointer_count: count,
|
||||
method_pointers: pointers,
|
||||
});
|
||||
candidates.set(index, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
return metadata.images.map((entry, index) => {
|
||||
const list = candidates.get(index) ?? [];
|
||||
if (list.length === 0)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"code-gen-modules-not-found",
|
||||
`no code generation module names ${entry.name} with ${counts[index]} method pointers`,
|
||||
);
|
||||
if (list.length > 1)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"ambiguous-code-gen-module",
|
||||
`${list.length} records match code generation module ${entry.name}`,
|
||||
);
|
||||
return list[0]!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the CodeRegistration codeGenModulesCount/codeGenModules pair: a count
|
||||
* equal to the image count followed by a pointer to an array holding exactly
|
||||
* the located module records.
|
||||
*/
|
||||
export function* findCodeRegistration(
|
||||
image: NativeImage,
|
||||
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 }[] = [];
|
||||
for (const mapping of image.container.mappings) {
|
||||
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;
|
||||
if (image.bytes.readBigUInt64LE(offset) !== total) continue;
|
||||
const array = image.bytes.readBigUInt64LE(offset + 8);
|
||||
if (!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;
|
||||
const pointer = readPointer(image, array + BigInt(slot * 8));
|
||||
if (pointer === null || !expected.has(pointer)) break;
|
||||
seen.add(pointer);
|
||||
}
|
||||
if (seen.size === modules.length) {
|
||||
if (matches.length !== 0)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"code-registration-not-found",
|
||||
"multiple CodeRegistration module tables match",
|
||||
);
|
||||
matches.push({ field: mapping.address + BigInt(at), array });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matches.length === 0 && image.container.architecture === "amd64") {
|
||||
const array = yield* findModuleArrayByCode(image, expected);
|
||||
if (array !== null) return { field: null, array };
|
||||
}
|
||||
if (matches.length !== 1)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"code-registration-not-found",
|
||||
`${matches.length} CodeRegistration module tables list the ${modules.length} located modules`,
|
||||
);
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some x86-64 builds (a Unity 2023.2 Linux GameAssembly.so) keep no static
|
||||
* CodeRegistration: the compiler folded the module array and its count into
|
||||
* the one loop that walks it, so no data word points at the array. Find every
|
||||
* pointer array that lists exactly the located modules, then require exactly
|
||||
* one of them to be the target of a RIP-relative `lea`/`mov` (REX.W 8D/8B with
|
||||
* a disp32 operand). Anything else stays code-registration-not-found.
|
||||
*/
|
||||
const MODULE_ARRAY_CANDIDATES = 64;
|
||||
|
||||
function* findModuleArrayByCode(
|
||||
image: NativeImage,
|
||||
expected: ReadonlySet<bigint>,
|
||||
): Generator<void, bigint | null> {
|
||||
const arrays = new Set<bigint>();
|
||||
for (const mapping of image.container.mappings) {
|
||||
const start = Number((8n - (mapping.address % 8n)) % 8n);
|
||||
let runStart = start;
|
||||
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)) {
|
||||
runStart = at + 8;
|
||||
lastPosition.clear();
|
||||
continue;
|
||||
}
|
||||
const previous = lastPosition.get(value);
|
||||
if (previous !== undefined && previous >= runStart)
|
||||
runStart = previous + 8;
|
||||
lastPosition.set(value, at);
|
||||
if (at - runStart === (expected.size - 1) * 8) {
|
||||
arrays.add(mapping.address + BigInt(runStart));
|
||||
// Retain overlapping windows; at most one position per expected module.
|
||||
if (arrays.size > MODULE_ARRAY_CANDIDATES)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"native-index-capacity",
|
||||
`module-array index exceeds ${MODULE_ARRAY_CANDIDATES} entries`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (arrays.size === 0) return null;
|
||||
const referenced = new Set<bigint>();
|
||||
for (const mapping of image.container.executable_mappings ?? []) {
|
||||
const bytes = image.bytes;
|
||||
const end = mapping.offset + mapping.size - 7;
|
||||
for (let at = mapping.offset; at <= end; at++) {
|
||||
if ((at & 0xffff) < 8) yield;
|
||||
const rex = bytes[at]!;
|
||||
if ((rex & 0xfb) !== 0x48) continue; // REX.W, optionally REX.R
|
||||
const opcode = bytes[at + 1]!;
|
||||
if (opcode !== 0x8d && opcode !== 0x8b) continue;
|
||||
if ((bytes[at + 2]! & 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));
|
||||
if (arrays.has(target)) referenced.add(target);
|
||||
}
|
||||
}
|
||||
return referenced.size === 1 ? [...referenced][0]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find MetadataRegistration by its two TypeDefinitionIndex-sized counts:
|
||||
* fieldOffsetsCount and typeDefinitionsSizesCount (words 10 and 12) both equal
|
||||
* the metadata type-definition count and are followed by mapped pointers.
|
||||
* typesCount and types are words 6 and 7. Counts are int32 in 8-byte slots.
|
||||
*/
|
||||
export function* findMetadataRegistration(
|
||||
image: NativeImage,
|
||||
typeDefinitions: number,
|
||||
): Generator<
|
||||
void,
|
||||
{
|
||||
readonly field: bigint;
|
||||
readonly count: number;
|
||||
readonly types: bigint;
|
||||
}
|
||||
> {
|
||||
const matches: { field: bigint; count: number; types: 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) {
|
||||
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);
|
||||
if (
|
||||
count === 0 ||
|
||||
count > 0x7fffffff ||
|
||||
!isMapped(image, word(base + 88), 8) ||
|
||||
!isMapped(image, word(base + 104), 8) ||
|
||||
!isMapped(image, types, count * 8)
|
||||
)
|
||||
continue;
|
||||
if (matches.length !== 0)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"metadata-registration-not-found",
|
||||
"multiple MetadataRegistration candidates match",
|
||||
);
|
||||
matches.push({
|
||||
field: mapping.address + BigInt(at + 48),
|
||||
count,
|
||||
types,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (matches.length !== 1)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"metadata-registration-not-found",
|
||||
`${matches.length} MetadataRegistration candidates count ${typeDefinitions} type definitions`,
|
||||
);
|
||||
return matches[0]!;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
|
||||
import {
|
||||
readPointer,
|
||||
readUInt32,
|
||||
isMapped,
|
||||
type NativeImage,
|
||||
} from "./Il2cppBinaryImage.js";
|
||||
import type {
|
||||
Il2cppMetadataDecoded,
|
||||
Il2cppMetadataLinkFacts,
|
||||
} from "./Il2cppMetadataFile.js";
|
||||
const PRIMITIVES = new Map([
|
||||
[0x01, "void"],
|
||||
[0x02, "bool"],
|
||||
[0x03, "char"],
|
||||
[0x04, "sbyte"],
|
||||
[0x05, "byte"],
|
||||
[0x06, "short"],
|
||||
[0x07, "ushort"],
|
||||
[0x08, "int"],
|
||||
[0x09, "uint"],
|
||||
[0x0a, "long"],
|
||||
[0x0b, "ulong"],
|
||||
[0x0c, "float"],
|
||||
[0x0d, "double"],
|
||||
[0x0e, "string"],
|
||||
[0x16, "System.TypedReference"],
|
||||
[0x18, "nint"],
|
||||
[0x19, "nuint"],
|
||||
[0x1c, "object"],
|
||||
]);
|
||||
|
||||
// Cached rendered text has its own workspace bound; the report owner separately
|
||||
// charges each returned occurrence and JSON expansion. No live-state cache.
|
||||
const TYPE_TEXT_BYTES = 64 * 1024 * 1024;
|
||||
const TYPE_DEPTH = 64;
|
||||
|
||||
/** Render one snapshot's type graph cooperatively without allocating unchecked expansions. */
|
||||
export const createTypeNamer = (
|
||||
image: NativeImage,
|
||||
metadata: Il2cppMetadataDecoded,
|
||||
facts: Il2cppMetadataLinkFacts,
|
||||
version: number,
|
||||
beforeText?: (bytes: number) => void,
|
||||
) => {
|
||||
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`);
|
||||
let cachedBytes = 0;
|
||||
let work = 0;
|
||||
const guard = (bytes: number) => {
|
||||
if (bytes > TYPE_TEXT_BYTES - cachedBytes)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"native-type-text-capacity",
|
||||
`rendered type text workspace exceeds ${TYPE_TEXT_BYTES} UTF-8 bytes`,
|
||||
);
|
||||
beforeText?.(bytes);
|
||||
};
|
||||
const join = (parts: readonly string[], separator = "") => {
|
||||
let bytes = Math.max(parts.length - 1, 0) * Buffer.byteLength(separator);
|
||||
for (const part of parts) {
|
||||
bytes += Buffer.byteLength(part);
|
||||
guard(bytes);
|
||||
}
|
||||
return parts.join(separator);
|
||||
};
|
||||
function* definitionName(index: number): Generator<void, string> {
|
||||
const seen = new Set<number>();
|
||||
const parts: string[] = [];
|
||||
let namespace = "";
|
||||
for (let at = index; at !== -1;) {
|
||||
if (seen.has(at)) fail(`cyclic declaring type at ${at}`);
|
||||
if (++work % 256 === 0) yield;
|
||||
seen.add(at);
|
||||
const type =
|
||||
metadata.types[at] ?? fail(`type definition ${at} is out of range`);
|
||||
parts.push(type.name);
|
||||
namespace = type.namespace;
|
||||
at = type.declaring_type;
|
||||
}
|
||||
const name = join(parts.reverse(), "+");
|
||||
return namespace === "" ? name : join([namespace, name], ".");
|
||||
}
|
||||
const byAddress = new Map<bigint, string>();
|
||||
const active = new Set<bigint>();
|
||||
const byrefBit = version >= 29 ? 29 : 30;
|
||||
function* name(address: bigint, depth: number): Generator<void, string> {
|
||||
const cached = byAddress.get(address);
|
||||
if (cached !== undefined) return cached;
|
||||
if (active.has(address))
|
||||
fail(`cyclic Il2CppType at 0x${address.toString(16)}`);
|
||||
if (depth > TYPE_DEPTH)
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"type-nesting-capacity",
|
||||
`type rendering exceeds ${TYPE_DEPTH} nested frames`,
|
||||
);
|
||||
active.add(address);
|
||||
try {
|
||||
if (++work % 256 === 0) yield;
|
||||
const data = pointer(address, "Il2CppType data");
|
||||
const bits = u32(address + 8n, "Il2CppType attributes");
|
||||
const kind = (bits >>> 16) & 0xff;
|
||||
const index = Number(BigInt.asIntN(32, data & 0xffffffffn));
|
||||
let result: string;
|
||||
const primitive = PRIMITIVES.get(kind);
|
||||
if (primitive !== undefined) result = primitive;
|
||||
else if (kind === 0x11 || kind === 0x12)
|
||||
result = yield* definitionName(index);
|
||||
else if (kind === 0x0f || kind === 0x1d)
|
||||
result = join([
|
||||
yield* name(data, depth + 1),
|
||||
kind === 0x0f ? "*" : "[]",
|
||||
]);
|
||||
else if (kind === 0x14) {
|
||||
const rank = u32(data + 8n, "Il2CppArrayType rank") & 0xff;
|
||||
if (rank === 0) fail("array rank is zero");
|
||||
result = join([
|
||||
yield* name(pointer(data, "array element"), depth + 1),
|
||||
"[",
|
||||
",".repeat(rank - 1),
|
||||
"]",
|
||||
]);
|
||||
} else if (kind === 0x13 || kind === 0x1e)
|
||||
result =
|
||||
facts.genericParameterName(index) ??
|
||||
fail(`generic parameter ${index} is out of range`);
|
||||
else if (kind === 0x15) result = yield* generic(data, depth);
|
||||
else
|
||||
throw new Il2cppBinaryFormatFailure(
|
||||
"unsupported-type-kind",
|
||||
`type kind 0x${kind.toString(16)} is not rendered`,
|
||||
);
|
||||
if (((bits >>> byrefBit) & 1) === 1) result = join([result, "&"]);
|
||||
const bytes = Buffer.byteLength(result);
|
||||
guard(bytes);
|
||||
cachedBytes += bytes;
|
||||
byAddress.set(address, result);
|
||||
return result;
|
||||
} finally {
|
||||
active.delete(address);
|
||||
}
|
||||
}
|
||||
function* generic(data: bigint, depth: number): Generator<void, string> {
|
||||
const definition =
|
||||
version >= 27
|
||||
? yield* name(pointer(data, "generic class type"), depth + 1)
|
||||
: yield* definitionName(u32(data, "generic class definition"));
|
||||
const instance = pointer(data + 8n, "generic class instance");
|
||||
const count = u32(instance, "generic argument count");
|
||||
const argv = pointer(instance + 8n, "generic arguments");
|
||||
guard(Math.max(count - 1, 0) * 2);
|
||||
if (!isMapped(image, argv, count * 8))
|
||||
fail("generic argument array is not file mapped");
|
||||
const args: string[] = [];
|
||||
let bytes = Math.max(count - 1, 0) * 2;
|
||||
for (let slot = 0; slot < count; slot++) {
|
||||
if (++work % 256 === 0) yield;
|
||||
const argument = yield* name(
|
||||
pointer(argv + BigInt(slot * 8), "generic argument"),
|
||||
depth + 1,
|
||||
);
|
||||
bytes += Buffer.byteLength(argument);
|
||||
guard(bytes);
|
||||
args.push(argument);
|
||||
}
|
||||
return join([definition.replace(/`\d+$/u, ""), "<", join(args, ", "), ">"]);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
import type {
|
||||
Il2cppSeedWriterPort,
|
||||
Il2cppSeedOutput,
|
||||
Il2cppSeedReceipt,
|
||||
} from "../application/il2cpp/Il2cppSeedWriterPort.js";
|
||||
import { writeTextParts } from "../application/JsonFiles.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { EvidenceFileError } from "../domain/evidenceErrors.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ghidraDefaultBase, il2cppGhidraSeeds } from "./Il2cppGhidraSeeds.js";
|
||||
|
||||
const OPERATION = "inspect_il2cpp_metadata";
|
||||
const invalid = (field: string, message: string) =>
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{ path: [field], reason: "invalid_value", message },
|
||||
]);
|
||||
|
||||
/** Atomic create-only publication, with failed cleanup retained for close retry. */
|
||||
export class Il2cppGhidraSeedWriter implements Il2cppSeedWriterPort {
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
readonly #cleanup = AnalysisCleanupLease.forCloseOwner(
|
||||
this.#resources,
|
||||
"IL2CPP seed output resources",
|
||||
);
|
||||
#writing = false;
|
||||
async check(
|
||||
output: Il2cppSeedOutput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<void, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
if (!isAbsolute(output.path))
|
||||
return err(
|
||||
invalid(
|
||||
"ghidra_seeds",
|
||||
"Seed output needs an absolute filesystem path.",
|
||||
),
|
||||
);
|
||||
if (
|
||||
output.base !== undefined &&
|
||||
!/^0x[0-9a-fA-F]{1,16}$/u.test(output.base)
|
||||
)
|
||||
return err(
|
||||
invalid("seed_base", "Seed base must be a 64-bit hexadecimal address."),
|
||||
);
|
||||
try {
|
||||
await lstat(output.path);
|
||||
return err(
|
||||
invalid(
|
||||
"ghidra_seeds",
|
||||
`--ghidra-seeds ${output.path} already exists; seed files are created, never replaced.`,
|
||||
),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
return ok(undefined);
|
||||
return err(
|
||||
new EvidenceFileError("write", "io", { path: output.path, cause }),
|
||||
);
|
||||
}
|
||||
}
|
||||
async write(
|
||||
report: Il2cppMetadata,
|
||||
output: Il2cppSeedOutput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Il2cppSeedReceipt, AnalysisError>> {
|
||||
if (this.#writing || this.#resources.hasPending || this.#cleanup.released)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"capacity",
|
||||
"Seed output is busy, closed, or awaiting owned cleanup.",
|
||||
{ maximum_concurrent_seed_exports: 1 },
|
||||
),
|
||||
);
|
||||
this.#writing = true;
|
||||
try {
|
||||
return await this.#resources.run(() =>
|
||||
this.#write(report, output, options),
|
||||
);
|
||||
} finally {
|
||||
this.#writing = false;
|
||||
}
|
||||
}
|
||||
async #write(
|
||||
report: Il2cppMetadata,
|
||||
output: Il2cppSeedOutput,
|
||||
options: ExecutionOptions | undefined,
|
||||
): Promise<Result<Il2cppSeedReceipt, AnalysisError>> {
|
||||
const ready = await this.check(output, options);
|
||||
if (!ready.ok) return ready;
|
||||
if (report.binary === null)
|
||||
return err(invalid("binary_path", "Ghidra seeds need binary_path."));
|
||||
let seeds;
|
||||
try {
|
||||
seeds = il2cppGhidraSeeds(
|
||||
report,
|
||||
output.base === undefined
|
||||
? ghidraDefaultBase(report.binary)
|
||||
: BigInt(output.base),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
throw cause;
|
||||
}
|
||||
const written = await writeTextParts(
|
||||
[seeds.text],
|
||||
output.path,
|
||||
false,
|
||||
this.#resources,
|
||||
options?.signal === undefined
|
||||
? undefined
|
||||
: { signal: options.signal, operation: OPERATION },
|
||||
);
|
||||
if (!written.ok) {
|
||||
if (written.error.cleanupIncomplete)
|
||||
this.#cleanup.bindCleanupError(written.error);
|
||||
return written;
|
||||
}
|
||||
return ok({
|
||||
...written.value,
|
||||
functions: seeds.functions,
|
||||
folded: seeds.folded,
|
||||
no_body: seeds.no_body,
|
||||
unknown: seeds.unknown,
|
||||
});
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#cleanup.release();
|
||||
} catch (cause) {
|
||||
if (
|
||||
!(cause instanceof ArtifactReaderFailure) ||
|
||||
cause.cleanup === undefined
|
||||
)
|
||||
throw cause;
|
||||
const error = new ProviderCleanupError(
|
||||
"il2cpp-metadata",
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ cause, operation: OPERATION },
|
||||
);
|
||||
this.#cleanup.bindCleanupError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
MAX_GHIDRA_SEED_BYTES,
|
||||
MAX_GHIDRA_SEED_ENTRIES,
|
||||
MAX_GHIDRA_SEED_NAME_LENGTH,
|
||||
} from "../ghidra/GhidraAnalysisSeeds.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
const OPERATION = "inspect_il2cpp_metadata";
|
||||
const capacity = (message: string) =>
|
||||
new AnalysisResourceConstraintError(OPERATION, "capacity", message, {
|
||||
maximum_seed_bytes: MAX_GHIDRA_SEED_BYTES,
|
||||
maximum_seed_name_characters: MAX_GHIDRA_SEED_NAME_LENGTH,
|
||||
});
|
||||
import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js";
|
||||
|
||||
/** A linked metadata report reduced to Ghidra function seeds. */
|
||||
export interface Il2cppGhidraSeeds {
|
||||
/** `address<TAB>function<TAB>name` lines in the REA_GHIDRA_SEED_FILE format. */
|
||||
readonly text: string;
|
||||
readonly functions: number;
|
||||
/** Methods whose code address another method already seeded (identical code folding). */
|
||||
readonly folded: number;
|
||||
/** Each folded address with the name its function seed kept and the names it shares. */
|
||||
readonly folded_groups: readonly {
|
||||
readonly address: string;
|
||||
readonly kept: string;
|
||||
readonly also: readonly string[];
|
||||
}[];
|
||||
/** Methods without native code: abstract, interface, or generic definitions. */
|
||||
readonly without_address: number;
|
||||
readonly no_body: number;
|
||||
readonly unknown: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Ghidra's default loaders place the binary: an ELF linked at 0 goes to
|
||||
* 0x100000, while Mach-O and PE images keep their link base.
|
||||
*/
|
||||
export const ghidraDefaultBase = (
|
||||
binary: NonNullable<Il2cppMetadata["binary"]>,
|
||||
): bigint => {
|
||||
const linked = BigInt(binary.image_base);
|
||||
return binary.format === "elf" && linked === 0n ? 0x100000n : linked;
|
||||
};
|
||||
|
||||
/** `Namespace.Outer.Inner` for a type, following declaring_type to the outermost type. */
|
||||
const typeName = (report: Il2cppMetadata, index: number): string => {
|
||||
const parts: string[] = [];
|
||||
let namespace = "";
|
||||
const seen = new Set<number>();
|
||||
let length = 0;
|
||||
for (let at = index; at !== -1;) {
|
||||
if (seen.has(at))
|
||||
throw new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["types", at],
|
||||
reason: "invalid_format",
|
||||
message: "Cyclic type ancestry cannot name a seed.",
|
||||
},
|
||||
]);
|
||||
seen.add(at);
|
||||
const type = report.types[at];
|
||||
if (type === undefined)
|
||||
throw new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["types", at],
|
||||
reason: "invalid_format",
|
||||
message: "Seed type owner is unavailable.",
|
||||
},
|
||||
]);
|
||||
length += type.name.length + 1;
|
||||
if (length > MAX_GHIDRA_SEED_NAME_LENGTH)
|
||||
throw capacity("Qualified seed type name exceeds the Ghidra name limit.");
|
||||
parts.unshift(type.name);
|
||||
namespace = type.namespace;
|
||||
if (length + namespace.length > MAX_GHIDRA_SEED_NAME_LENGTH)
|
||||
throw capacity("Qualified seed type name exceeds the Ghidra name limit.");
|
||||
at = type.declaring_type;
|
||||
}
|
||||
return namespace === "" ? parts.join(".") : `${namespace}.${parts.join(".")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Name every linked method `Type$$Method`, the Il2CppDumper convention, at
|
||||
* `base + rva`. Whitespace becomes `_` because seed names cannot contain it.
|
||||
* The first method at an address names its function, since a seed file holds
|
||||
* one function seed per address. The other methods at that address are listed
|
||||
* in `#` comment lines after the seeds, so a reader of the file or the
|
||||
* decompilation can see that the kept name is one of several.
|
||||
*/
|
||||
export const il2cppGhidraSeeds = (
|
||||
report: Il2cppMetadata,
|
||||
base: bigint,
|
||||
): Il2cppGhidraSeeds => {
|
||||
if (report.binary === null)
|
||||
throw new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["binary_path"],
|
||||
reason: "invalid_value",
|
||||
message: "Ghidra seeds need a report linked with binary_path.",
|
||||
},
|
||||
]);
|
||||
const seeded = new Map<bigint, { kept: string; also: string[] }>();
|
||||
const lines: string[] = [];
|
||||
let folded = 0;
|
||||
let withoutAddress = 0,
|
||||
noBody = 0,
|
||||
unknown = 0,
|
||||
bytes = 0;
|
||||
const typeNames = new Map<number, string>();
|
||||
const reserve = (length: number) => {
|
||||
if (length > MAX_GHIDRA_SEED_BYTES - bytes)
|
||||
throw capacity("Seed file exceeds Ghidra byte admission.");
|
||||
bytes += length;
|
||||
};
|
||||
const emit = (text: string) => {
|
||||
reserve(Buffer.byteLength(text));
|
||||
lines.push(text);
|
||||
};
|
||||
report.methods.forEach((method, index) => {
|
||||
const rva = report.method_addresses[index]?.rva ?? null;
|
||||
if (rva === null) {
|
||||
withoutAddress++;
|
||||
if (report.method_addresses[index]?.state === "no-body") noBody++;
|
||||
else unknown++;
|
||||
return;
|
||||
}
|
||||
const address = base + BigInt(rva);
|
||||
if (address < 0n || address > 0xffffffffffffffffn)
|
||||
throw new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["seed_base"],
|
||||
reason: "invalid_value",
|
||||
message: "Rebased seed address exceeds 64 bits.",
|
||||
},
|
||||
]);
|
||||
let ownerName = typeNames.get(method.declaring_type);
|
||||
if (ownerName === undefined) {
|
||||
ownerName = typeName(report, method.declaring_type);
|
||||
typeNames.set(method.declaring_type, ownerName);
|
||||
}
|
||||
if (ownerName.length + 2 + method.name.length > MAX_GHIDRA_SEED_NAME_LENGTH)
|
||||
throw capacity(
|
||||
"Qualified method seed name exceeds Ghidra character admission.",
|
||||
);
|
||||
const name = `${ownerName}$$${method.name}`.replace(/\s/gu, "_");
|
||||
if (name.includes("\0"))
|
||||
throw new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["methods", index, "name"],
|
||||
reason: "invalid_format",
|
||||
message: "Seed names cannot contain NUL.",
|
||||
},
|
||||
]);
|
||||
const group = seeded.get(address);
|
||||
if (group !== undefined) {
|
||||
folded++;
|
||||
if (group.also.length === 0)
|
||||
reserve(
|
||||
Buffer.byteLength(
|
||||
`# folded 0x${address.toString(16)} ${group.kept} also\n`,
|
||||
),
|
||||
);
|
||||
reserve(Buffer.byteLength(` ${name}`));
|
||||
group.also.push(name);
|
||||
return;
|
||||
}
|
||||
seeded.set(address, { kept: name, also: [] });
|
||||
if (seeded.size > MAX_GHIDRA_SEED_ENTRIES)
|
||||
throw capacity("Seed function count exceeds Ghidra entry admission.");
|
||||
emit(`0x${address.toString(16)}\tfunction\t${name}\n`);
|
||||
});
|
||||
const foldedGroups = [...seeded]
|
||||
.filter(([, group]) => group.also.length > 0)
|
||||
.map(([address, group]) => ({
|
||||
address: `0x${address.toString(16)}`,
|
||||
kept: group.kept,
|
||||
also: group.also,
|
||||
}));
|
||||
for (const group of foldedGroups) {
|
||||
lines.push(`# folded ${group.address} ${group.kept} also`);
|
||||
for (const name of group.also) lines.push(` ${name}`);
|
||||
lines.push("\n");
|
||||
}
|
||||
emit(
|
||||
`# metadata_sha256 ${report.artifact.sha256}\n# binary_sha256 ${report.binary.artifact.sha256}\n# link_base ${report.binary.image_base}\n# seed_base 0x${base.toString(16)}\n`,
|
||||
);
|
||||
return {
|
||||
text: lines.join(""),
|
||||
functions: seeded.size,
|
||||
folded,
|
||||
folded_groups: foldedGroups,
|
||||
without_address: withoutAddress,
|
||||
no_body: noBody,
|
||||
unknown,
|
||||
};
|
||||
};
|
||||
@@ -29,6 +29,7 @@ const emptyRows = {
|
||||
export const createIl2cppMetadataAdmission = (
|
||||
limit: number,
|
||||
exceed: () => never,
|
||||
linked = false,
|
||||
): Required<Il2cppMetadataReadOptions> => {
|
||||
let used = 0;
|
||||
// Keys/decoded names/literal spans are immutable within this one decoder run.
|
||||
@@ -45,6 +46,14 @@ export const createIl2cppMetadataAdmission = (
|
||||
let minimum = 0;
|
||||
for (const key of Object.keys(emptyRows) as (keyof typeof emptyRows)[])
|
||||
minimum += counts[key] * JSON.stringify(emptyRows[key]).length;
|
||||
if (linked)
|
||||
minimum +=
|
||||
counts.methods *
|
||||
(JSON.stringify({ state: "code", address: null, rva: null })
|
||||
.length +
|
||||
JSON.stringify({ type: 0, name: "" }).length) +
|
||||
(counts.fields + counts.parameters) *
|
||||
JSON.stringify({ type: 0, name: "" }).length;
|
||||
if (minimum > limit) exceed();
|
||||
},
|
||||
beforeText(bytes) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
checkRange,
|
||||
readIndex,
|
||||
Il2cppMetadataFormatFailure,
|
||||
} from "./Il2cppMetadataFormat.js";
|
||||
import {
|
||||
@@ -24,7 +25,13 @@ export type Il2cppMetadataVersion = (typeof IL2CPP_METADATA_VERSIONS)[number];
|
||||
type RecordSection = "typeDefinitions" | "images" | "methods";
|
||||
export type Il2cppMetadataDecoded = Omit<
|
||||
Il2cppMetadata,
|
||||
"artifact" | "limitations"
|
||||
| "artifact"
|
||||
| "limitations"
|
||||
| "binary"
|
||||
| "method_addresses"
|
||||
| "return_types"
|
||||
| "field_types"
|
||||
| "parameter_types"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -121,19 +128,6 @@ export const il2cppMetadataRecordBytes = (
|
||||
const isIndexWidth = (value: number): value is IndexWidth =>
|
||||
value === 1 || value === 2 || value === 4;
|
||||
|
||||
/** Read a serialized index; the all-ones value of narrow widths means -1. */
|
||||
export const readIndex = (bytes: Buffer, offset: number, width: IndexWidth) => {
|
||||
if (width === 1) {
|
||||
const value = bytes.readUInt8(offset);
|
||||
return value === 0xff ? -1 : value;
|
||||
}
|
||||
if (width === 2) {
|
||||
const value = bytes.readUInt16LE(offset);
|
||||
return value === 0xffff ? -1 : value;
|
||||
}
|
||||
return bytes.readInt32LE(offset);
|
||||
};
|
||||
|
||||
const readHeader = (bytes: Buffer) => {
|
||||
const magic = bytes.length < 4 ? null : bytes.readUInt32LE(0);
|
||||
if (bytes.length < 8 || magic !== IL2CPP_METADATA_MAGIC)
|
||||
@@ -390,6 +384,7 @@ export interface MetadataTables {
|
||||
readonly members: ReturnType<typeof readIl2cppMemberTables>;
|
||||
readonly beforeText: Il2cppMetadataReadOptions["beforeText"];
|
||||
readonly onRow: Il2cppMetadataReadOptions["onRow"];
|
||||
readonly linkFacts?: Il2cppMetadataLinkFacts;
|
||||
}
|
||||
|
||||
function* readImages({
|
||||
@@ -572,6 +567,7 @@ function* readMethods({
|
||||
counts,
|
||||
readString,
|
||||
methodReturnParameterToken,
|
||||
linkFacts,
|
||||
onRow,
|
||||
}: MetadataTables): Generator<
|
||||
void,
|
||||
@@ -596,6 +592,17 @@ function* readMethods({
|
||||
"index-out-of-range",
|
||||
`method ${index} declares type ${method.declaring_type} outside the ${typeTotal} type definitions`,
|
||||
);
|
||||
if (linkFacts !== undefined) {
|
||||
const returnAt = at + 4 + widths.type_definition;
|
||||
const tokenAt =
|
||||
returnAt +
|
||||
widths.type +
|
||||
(methodReturnParameterToken ? 4 : 0) +
|
||||
widths.parameter +
|
||||
widths.generic_container;
|
||||
linkFacts.returnTypes[index] = readIndex(bytes, returnAt, widths.type);
|
||||
linkFacts.methodTokens[index] = bytes.readUInt32LE(tokenAt);
|
||||
}
|
||||
parameterRanges.push({
|
||||
start: readIndex(
|
||||
bytes,
|
||||
@@ -700,11 +707,75 @@ export interface Il2cppMetadataReadOptions {
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** Per-row typed fields the public inventory omits but native linking needs. */
|
||||
export interface Il2cppMetadataLinkFacts {
|
||||
/** Method definition token per method index. */
|
||||
readonly methodTokens: Uint32Array;
|
||||
/** Il2CppType table index of each method's return type. */
|
||||
readonly returnTypes: Int32Array;
|
||||
readonly fieldTypes: Int32Array;
|
||||
readonly parameterTypes: Int32Array;
|
||||
/** Demand-read names from this stable metadata snapshot, by GenericParameterIndex. */
|
||||
readonly genericParameterName: (index: number) => string | undefined;
|
||||
}
|
||||
|
||||
/** Own the row layout once; unused generic names allocate no arrays or strings. */
|
||||
const createGenericParameterReader = (tables: MetadataTables) => {
|
||||
const parameters = tables.section("genericParameters"),
|
||||
width = tables.widths.generic_container + 12;
|
||||
const count = entriesIn(parameters, width);
|
||||
if (parameters.count !== null && count !== parameters.count)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"record-size-mismatch",
|
||||
"generic parameter count disagrees with its extent",
|
||||
);
|
||||
return (index: number): string | undefined => {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= count)
|
||||
return undefined;
|
||||
return tables.readString(
|
||||
tables.bytes.readInt32LE(
|
||||
parameters.offset + index * width + tables.widths.generic_container,
|
||||
),
|
||||
`generic parameter ${index} name`,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/** Decoded metadata plus the typed row facts that link it to a native binary. */
|
||||
export interface Il2cppMetadataForLinking {
|
||||
readonly metadata: Il2cppMetadataDecoded;
|
||||
readonly link: Il2cppMetadataLinkFacts;
|
||||
}
|
||||
|
||||
/** Decode image, type, method and member names and string literals without native binaries or decryption. */
|
||||
export function* readIl2cppMetadataSteps(
|
||||
bytes: Buffer,
|
||||
options: Il2cppMetadataReadOptions = {},
|
||||
): Generator<void, Il2cppMetadataDecoded> {
|
||||
return (yield* readMetadataCoreSteps(bytes, options, false)).metadata;
|
||||
}
|
||||
|
||||
/** Decode metadata and retain the typed row facts used for native linking. */
|
||||
export function* readIl2cppMetadataForLinkingSteps(
|
||||
bytes: Buffer,
|
||||
options: Il2cppMetadataReadOptions = {},
|
||||
): Generator<void, Il2cppMetadataForLinking> {
|
||||
const parsed = yield* readMetadataCoreSteps(bytes, options, true);
|
||||
if (parsed.link === undefined)
|
||||
throw new Error(
|
||||
"Native metadata reader did not retain requested linking facts",
|
||||
);
|
||||
return { metadata: parsed.metadata, link: parsed.link };
|
||||
}
|
||||
|
||||
function* readMetadataCoreSteps(
|
||||
bytes: Buffer,
|
||||
options: Il2cppMetadataReadOptions,
|
||||
linking: boolean,
|
||||
): Generator<
|
||||
void,
|
||||
{ metadata: Il2cppMetadataDecoded; link: Il2cppMetadataLinkFacts | undefined }
|
||||
> {
|
||||
const { magic, version, headerBytes, sections } = readHeader(bytes);
|
||||
checkSections(bytes, headerBytes, sections);
|
||||
const byName = new Map(sections.map((entry) => [entry.name, entry]));
|
||||
@@ -713,7 +784,7 @@ export function* readIl2cppMetadataSteps(
|
||||
const widths =
|
||||
layout.indexing === "counted" ? resolveWidths(section) : FIXED_WIDTHS;
|
||||
const expected = il2cppMetadataRecordBytes(widths, version);
|
||||
const tables: MetadataTables = {
|
||||
let tables: MetadataTables = {
|
||||
bytes,
|
||||
section,
|
||||
widths,
|
||||
@@ -741,6 +812,17 @@ export function* readIl2cppMetadataSteps(
|
||||
methods: tables.counts.methods,
|
||||
...tables.members.counts,
|
||||
});
|
||||
if (linking)
|
||||
tables = {
|
||||
...tables,
|
||||
linkFacts: {
|
||||
methodTokens: new Uint32Array(tables.counts.methods),
|
||||
returnTypes: new Int32Array(tables.counts.methods),
|
||||
fieldTypes: new Int32Array(tables.members.counts.fields),
|
||||
parameterTypes: new Int32Array(tables.members.counts.parameters),
|
||||
genericParameterName: createGenericParameterReader(tables),
|
||||
},
|
||||
};
|
||||
yield;
|
||||
const images = yield* readImages(tables);
|
||||
yield* checkImageRanges(images);
|
||||
@@ -752,25 +834,36 @@ export function* readIl2cppMetadataSteps(
|
||||
parameters: parameterRanges,
|
||||
});
|
||||
return {
|
||||
magic,
|
||||
metadata_version: version,
|
||||
index_widths: widths,
|
||||
sections,
|
||||
images,
|
||||
types,
|
||||
methods,
|
||||
...members,
|
||||
metadata: {
|
||||
magic,
|
||||
metadata_version: version,
|
||||
index_widths: widths,
|
||||
sections,
|
||||
images,
|
||||
types,
|
||||
methods,
|
||||
...members,
|
||||
},
|
||||
link: tables.linkFacts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Synchronous consumption of the same decoder for byte-level callers. */
|
||||
export const readIl2cppMetadata = (
|
||||
bytes: Buffer,
|
||||
options: Il2cppMetadataReadOptions = {},
|
||||
): Il2cppMetadataDecoded => {
|
||||
const steps = readIl2cppMetadataSteps(bytes, options);
|
||||
const runSteps = <T>(steps: Generator<void, T>): T => {
|
||||
for (;;) {
|
||||
const step = steps.next();
|
||||
if (step.done) return step.value;
|
||||
}
|
||||
};
|
||||
|
||||
/** Synchronous consumption of the same decoder for byte-level callers. */
|
||||
export const readIl2cppMetadata = (
|
||||
bytes: Buffer,
|
||||
options: Il2cppMetadataReadOptions = {},
|
||||
): Il2cppMetadataDecoded => runSteps(readIl2cppMetadataSteps(bytes, options));
|
||||
|
||||
/** Synchronous decoding that also retains the typed row facts used for native linking. */
|
||||
export const readIl2cppMetadataForLinking = (
|
||||
bytes: Buffer,
|
||||
options: Il2cppMetadataReadOptions = {},
|
||||
): Il2cppMetadataForLinking =>
|
||||
runSteps(readIl2cppMetadataForLinkingSteps(bytes, options));
|
||||
@@ -51,3 +51,16 @@ export const checkRange = (
|
||||
`${label} range [${start}, ${start + length}) is outside the ${total} available entries`,
|
||||
);
|
||||
};
|
||||
|
||||
/** Read a serialized index; the all-ones value of narrow widths means -1. */
|
||||
export const readIndex = (bytes: Buffer, offset: number, width: 1 | 2 | 4) => {
|
||||
if (width === 1) {
|
||||
const value = bytes.readUInt8(offset);
|
||||
return value === 0xff ? -1 : value;
|
||||
}
|
||||
if (width === 2) {
|
||||
const value = bytes.readUInt16LE(offset);
|
||||
return value === 0xffff ? -1 : value;
|
||||
}
|
||||
return bytes.readInt32LE(offset);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import {
|
||||
checkRange,
|
||||
readIndex,
|
||||
Il2cppMetadataFormatFailure,
|
||||
} from "./Il2cppMetadataFormat.js";
|
||||
import type { MetadataTables } from "./Il2cppMetadataFile.js";
|
||||
@@ -132,6 +133,22 @@ function* readNamed<
|
||||
),
|
||||
owners[index]!,
|
||||
);
|
||||
if (
|
||||
tables.linkFacts !== undefined &&
|
||||
(name === "fields" || name === "parameters")
|
||||
) {
|
||||
const column =
|
||||
name === "fields"
|
||||
? tables.linkFacts.fieldTypes
|
||||
: tables.linkFacts.parameterTypes;
|
||||
column[index] = readIndex(
|
||||
tables.bytes,
|
||||
section.offset +
|
||||
index * tables.members.recordBytes[name] +
|
||||
(name === "fields" ? 4 : 8),
|
||||
tables.widths.type,
|
||||
);
|
||||
}
|
||||
tables.onRow?.(row);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
|
||||
import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js";
|
||||
import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import {
|
||||
readStableArtifact,
|
||||
@@ -38,9 +41,14 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
readIl2cppMetadataForLinkingSteps,
|
||||
readIl2cppMetadataSteps,
|
||||
type Il2cppMetadataRowCounts,
|
||||
} from "./Il2cppMetadataFile.js";
|
||||
import {
|
||||
linkIl2cppBinarySteps,
|
||||
type Il2cppBinaryLink,
|
||||
} from "./Il2cppBinaryFile.js";
|
||||
|
||||
/** Bundled format reader identity; the metadata version is a separate observed fact. */
|
||||
export const IL2CPP_METADATA_PROVIDER_IDENTITY = {
|
||||
@@ -50,10 +58,45 @@ export const IL2CPP_METADATA_PROVIDER_IDENTITY = {
|
||||
} as const;
|
||||
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;
|
||||
const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
const failureForPrimary = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
field: "path" | "binary_path" = "path",
|
||||
maximum = INPUT_BYTES,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof Il2cppBinaryFormatFailure && cause.kind === "capacity")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
cause.reason === "native-type-text-capacity" ? "memory" : "capacity",
|
||||
cause.message,
|
||||
{},
|
||||
{ cause },
|
||||
);
|
||||
if (
|
||||
cause instanceof Il2cppBinaryFormatFailure ||
|
||||
cause instanceof Il2cppMetadataFormatFailure
|
||||
) {
|
||||
const binary = cause instanceof Il2cppBinaryFormatFailure;
|
||||
return cause.kind === "unsupported"
|
||||
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
})
|
||||
: new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: [binary ? field : "path"],
|
||||
reason: "invalid_format",
|
||||
message: binary
|
||||
? `Selected binary ${path} does not link to the metadata: ${cause.message}`
|
||||
: `Selected artifact ${path} is corrupt: ${cause.message}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
@@ -62,30 +105,18 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
return new AnalysisCancelledError(OPERATION, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
if (cause instanceof Il2cppMetadataFormatFailure)
|
||||
return cause.kind === "unsupported"
|
||||
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
})
|
||||
: new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: `Selected artifact ${path} is corrupt: ${cause.message}`,
|
||||
},
|
||||
]);
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
if (cause.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"file-size",
|
||||
cause.message,
|
||||
{ maximum_input_bytes: INPUT_BYTES },
|
||||
{ maximum_input_bytes: maximum },
|
||||
{ cause },
|
||||
);
|
||||
if (cause.reason === "path")
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{ path: ["path"], reason: "invalid_format", message: cause.message },
|
||||
{ path: [field], reason: "invalid_format", message: cause.message },
|
||||
]);
|
||||
}
|
||||
if (cause instanceof Error && "code" in cause) {
|
||||
@@ -100,7 +131,7 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
)
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`,
|
||||
},
|
||||
@@ -119,12 +150,26 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
);
|
||||
};
|
||||
|
||||
const failureFor = (cause: unknown, path: string): AnalysisError => {
|
||||
const failureFor = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
field: "path" | "binary_path" = "path",
|
||||
maximum = INPUT_BYTES,
|
||||
): AnalysisError => {
|
||||
const primary =
|
||||
cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "failed"
|
||||
? failureForPrimary(cause.fileCleanupFailure.outcome.cause, path)
|
||||
: failureForPrimary(cause, path);
|
||||
? failureForPrimary(
|
||||
cause.fileCleanupFailure.outcome.cause,
|
||||
path,
|
||||
signal,
|
||||
field,
|
||||
maximum,
|
||||
)
|
||||
: cause instanceof ArtifactFileCleanupFailure && signal?.aborted
|
||||
? new AnalysisCancelledError(OPERATION, { cause: signal.reason })
|
||||
: failureForPrimary(cause, path, signal, field, maximum);
|
||||
if (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
cause.partialObservation !== undefined &&
|
||||
@@ -144,6 +189,37 @@ const failureFor = (cause: unknown, path: string): AnalysisError => {
|
||||
return error;
|
||||
};
|
||||
|
||||
const hex = (value: bigint) => `0x${value.toString(16)}`;
|
||||
|
||||
/** Report fields for a linked binary, with addresses as exact hexadecimal strings. */
|
||||
const binaryReport = (
|
||||
link: Il2cppBinaryLink,
|
||||
artifact: { path: string; sha256: string; bytes: number },
|
||||
) => ({
|
||||
binary: {
|
||||
artifact,
|
||||
format: link.format,
|
||||
architecture: link.architecture,
|
||||
image_base: hex(link.image_base),
|
||||
code_gen_modules_field:
|
||||
link.code_gen_modules_field === null
|
||||
? null
|
||||
: hex(link.code_gen_modules_field),
|
||||
code_gen_modules_array: hex(link.code_gen_modules_array),
|
||||
code_gen_modules: link.code_gen_modules.map((module) => ({
|
||||
image: module.image,
|
||||
address: hex(module.address),
|
||||
method_pointer_count: module.method_pointer_count,
|
||||
})),
|
||||
types_field: hex(link.types_field),
|
||||
types_count: link.types_count,
|
||||
},
|
||||
return_types: link.return_types,
|
||||
field_types: link.field_types,
|
||||
parameter_types: link.parameter_types,
|
||||
method_addresses: link.method_addresses,
|
||||
});
|
||||
|
||||
const reportLimit = (path: string, maximumBytes: number) =>
|
||||
new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
@@ -162,6 +238,14 @@ const sourceArtifact = (path: string, snapshot: StableArtifactSnapshot) => ({
|
||||
const metadataSourceEvidence = (
|
||||
artifact: Il2cppMetadata["artifact"],
|
||||
counts: Il2cppMetadataRowCounts | undefined,
|
||||
binary:
|
||||
| {
|
||||
path: string;
|
||||
architecture?: string;
|
||||
artifact?: Il2cppMetadata["artifact"];
|
||||
}
|
||||
| undefined = undefined,
|
||||
previous?: AnalysisPartialObservation,
|
||||
) =>
|
||||
createEvidence(
|
||||
{
|
||||
@@ -173,10 +257,41 @@ const metadataSourceEvidence = (
|
||||
{
|
||||
operation: "observe_il2cpp_metadata_source",
|
||||
predicateType: "rea.il2cpp-metadata-source",
|
||||
parameters: { path: artifact.path },
|
||||
parameters: {
|
||||
path: artifact.path,
|
||||
...(binary === undefined
|
||||
? {}
|
||||
: {
|
||||
binary_path: binary.path,
|
||||
...(binary.architecture === undefined
|
||||
? {}
|
||||
: { binary_architecture: binary.architecture }),
|
||||
}),
|
||||
},
|
||||
result: {
|
||||
artifact,
|
||||
inventory_status: "not-returned-as-complete",
|
||||
...(binary === undefined
|
||||
? {}
|
||||
: {
|
||||
selected_binary: {
|
||||
path: binary.path,
|
||||
identity_status:
|
||||
binary.artifact === undefined ? "unavailable" : "observed",
|
||||
...(binary.artifact === undefined
|
||||
? {}
|
||||
: {
|
||||
sha256: binary.artifact.sha256,
|
||||
bytes: binary.artifact.bytes,
|
||||
}),
|
||||
...(binary.architecture === undefined
|
||||
? {}
|
||||
: { architecture: binary.architecture }),
|
||||
},
|
||||
}),
|
||||
...(previous === undefined
|
||||
? {}
|
||||
: { upstream_partial_observation: jsonValueSchema.parse(previous) }),
|
||||
...(counts === undefined
|
||||
? {}
|
||||
: { validated_section_row_counts: { ...counts } }),
|
||||
@@ -250,37 +365,46 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
|
||||
const observed: {
|
||||
artifact?: Il2cppMetadata["artifact"];
|
||||
counts?: Il2cppMetadataRowCounts;
|
||||
binary?: Il2cppMetadata["artifact"];
|
||||
} = {};
|
||||
const selectedBinarySource = () =>
|
||||
input.binary_path === undefined
|
||||
? undefined
|
||||
: {
|
||||
path: input.binary_path,
|
||||
...(input.binary_architecture === undefined
|
||||
? {}
|
||||
: { architecture: input.binary_architecture }),
|
||||
...(observed.binary === undefined
|
||||
? {}
|
||||
: { artifact: observed.binary }),
|
||||
};
|
||||
try {
|
||||
return await this.#resources.run(async () => {
|
||||
const snapshot = await readStableArtifact(
|
||||
const snapshot = await this.#readArtifact(
|
||||
input.path,
|
||||
INPUT_BYTES,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
).catch((cause: unknown) => {
|
||||
// Only this awaited stable read owns these typed physical outcomes.
|
||||
const outcome =
|
||||
cause instanceof ArtifactFileCleanupFailure
|
||||
? (cause as ArtifactFileCleanupFailure<StableArtifactSnapshot>)
|
||||
.fileCleanupFailure.outcome
|
||||
: cause instanceof ArtifactFileCancelled
|
||||
? (cause as ArtifactFileCancelled<StableArtifactSnapshot>)
|
||||
.fileCancellation.outcome
|
||||
: undefined;
|
||||
if (outcome?.kind === "completed")
|
||||
observed.artifact = sourceArtifact(input.path, outcome.value);
|
||||
throw cause;
|
||||
});
|
||||
(artifact) => {
|
||||
observed.artifact = artifact;
|
||||
},
|
||||
);
|
||||
const artifact = sourceArtifact(input.path, snapshot);
|
||||
observed.artifact = artifact;
|
||||
assertArtifactReadActive(options?.signal);
|
||||
const limit = this.outputBytes;
|
||||
const admission = createIl2cppMetadataAdmission(limit, () => {
|
||||
throw reportLimit(input.path, limit);
|
||||
});
|
||||
const steps = readIl2cppMetadataSteps(snapshot.bytes, {
|
||||
const admission = createIl2cppMetadataAdmission(
|
||||
limit,
|
||||
() => {
|
||||
throw reportLimit(input.path, limit);
|
||||
},
|
||||
input.binary_path !== undefined,
|
||||
);
|
||||
const readMetadata =
|
||||
input.binary_path === undefined
|
||||
? readIl2cppMetadataSteps
|
||||
: readIl2cppMetadataForLinkingSteps;
|
||||
const steps = readMetadata(snapshot.bytes, {
|
||||
...admission,
|
||||
beforeRows: (counts) => {
|
||||
observed.counts = counts;
|
||||
@@ -293,13 +417,100 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
|
||||
assertArtifactReadActive(options?.signal);
|
||||
step = steps.next();
|
||||
}
|
||||
const decoded = step.value;
|
||||
const value = step.value;
|
||||
const decoded = "metadata" in value ? value.metadata : value;
|
||||
const link = "metadata" in value ? value.link : undefined;
|
||||
const binaryPath = input.binary_path;
|
||||
let linked: Pick<
|
||||
Il2cppMetadata,
|
||||
| "binary"
|
||||
| "method_addresses"
|
||||
| "return_types"
|
||||
| "field_types"
|
||||
| "parameter_types"
|
||||
> = {
|
||||
binary: null,
|
||||
method_addresses: [],
|
||||
return_types: [],
|
||||
field_types: [],
|
||||
parameter_types: [],
|
||||
};
|
||||
if (binaryPath !== undefined) {
|
||||
if (link === undefined)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Native linking facts were not retained for the selected binary.",
|
||||
);
|
||||
try {
|
||||
const binary = await this.#readArtifact(
|
||||
binaryPath,
|
||||
BINARY_BYTES,
|
||||
options?.signal,
|
||||
(artifact) => {
|
||||
observed.binary = artifact;
|
||||
},
|
||||
);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
const linking = linkIl2cppBinarySteps(
|
||||
binary.bytes,
|
||||
decoded,
|
||||
link,
|
||||
input.binary_architecture,
|
||||
admission,
|
||||
);
|
||||
let linkStep = linking.next();
|
||||
while (!linkStep.done) {
|
||||
await this.yieldControl();
|
||||
assertArtifactReadActive(options?.signal);
|
||||
linkStep = linking.next();
|
||||
}
|
||||
linked = binaryReport(linkStep.value, {
|
||||
path: binaryPath,
|
||||
sha256: binary.sha256,
|
||||
bytes: binary.bytes.length,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
const failure = failureFor(
|
||||
cause,
|
||||
binaryPath,
|
||||
options?.signal,
|
||||
"binary_path",
|
||||
BINARY_BYTES,
|
||||
);
|
||||
failure.partialObservation = metadataSourceEvidence(
|
||||
artifact,
|
||||
observed.counts,
|
||||
selectedBinarySource(),
|
||||
failure.partialObservation,
|
||||
);
|
||||
return err(failure);
|
||||
}
|
||||
}
|
||||
const report = {
|
||||
...decoded,
|
||||
...linked,
|
||||
artifact,
|
||||
limitations: [
|
||||
"Only unencrypted global-metadata.dat versions 24, 27, 29, 31 and 39 are decoded; names, counts and string literals are observed file content, not runtime behavior.",
|
||||
"Native code addresses, IL2CPP binary registration, member types, signatures and generic instances are not recovered.",
|
||||
binaryPath === undefined
|
||||
? "Native code addresses and IL2CPP binary registration are not recovered without binary_path."
|
||||
: "Method addresses come from the binary's code-generation module methodPointers, matched by module name, method count and metadata token; generic method instances, adjustor thunks and invokers are not linked, no-body means an observed zero slot, and unknown means no module claimed the method or its slot has an unresolved relocation.",
|
||||
binaryPath === undefined
|
||||
? "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); custom modifiers, array bounds and attributes are not reported.",
|
||||
...(linked.binary !== null &&
|
||||
linked.binary.code_gen_modules_field === null
|
||||
? [
|
||||
"The static CodeRegistration pair was not resolved. The module array is inferred from executable bytes consistent with RIP-relative LEA/MOV referencing one array candidate; instruction boundaries and runtime use were not verified.",
|
||||
]
|
||||
: []),
|
||||
...(binaryPath === undefined
|
||||
? []
|
||||
: [
|
||||
"The binary/metadata pair is caller-selected. Module name/count/token agreement is structural evidence, not a cryptographic proof that both files come from the same build.",
|
||||
]),
|
||||
"declaring_type for types is derived from the nestedTypes section, and member and parameter owners from the owners' index ranges; row positions are the metadata indices.",
|
||||
],
|
||||
};
|
||||
@@ -317,16 +528,46 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
const failure = failureFor(cause, input.path);
|
||||
if (
|
||||
observed.artifact !== undefined &&
|
||||
failure.partialObservation === undefined
|
||||
)
|
||||
failure.retainPartialObservation(
|
||||
metadataSourceEvidence(observed.artifact, observed.counts),
|
||||
if (observed.artifact !== undefined)
|
||||
failure.partialObservation = metadataSourceEvidence(
|
||||
observed.artifact,
|
||||
observed.counts,
|
||||
selectedBinarySource(),
|
||||
failure.partialObservation,
|
||||
);
|
||||
return err(failure);
|
||||
} finally {
|
||||
this.#inspecting = false;
|
||||
}
|
||||
}
|
||||
async #readArtifact(
|
||||
path: string,
|
||||
maximum: number,
|
||||
signal: AbortSignal | undefined,
|
||||
observe: (artifact: Il2cppMetadata["artifact"]) => void,
|
||||
): Promise<StableArtifactSnapshot> {
|
||||
try {
|
||||
const snapshot = await readStableArtifact(
|
||||
path,
|
||||
maximum,
|
||||
signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
);
|
||||
observe(sourceArtifact(path, snapshot));
|
||||
return snapshot;
|
||||
} catch (cause) {
|
||||
const outcome =
|
||||
cause instanceof ArtifactFileCleanupFailure
|
||||
? (cause as ArtifactFileCleanupFailure<StableArtifactSnapshot>)
|
||||
.fileCleanupFailure.outcome
|
||||
: cause instanceof ArtifactFileCancelled
|
||||
? (cause as ArtifactFileCancelled<StableArtifactSnapshot>)
|
||||
.fileCancellation.outcome
|
||||
: undefined;
|
||||
if (outcome?.kind === "completed")
|
||||
observe(sourceArtifact(path, outcome.value));
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user