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:
JANG BYUNGKUN authored and GitHub committed 2026-10-20 21:56:54 +08:00
1 parent e50a271c72
commit 52d1a72af0
43 files changed
+5060 -368

No files matched your search

@@ -178,8 +178,7 @@ explicit path or endpoint and do not need it.
parameter pages. Missing collections in historical Evidence remain unknown.
Versions 24, 27, 29, 31 and 39; no Unity, IL2CPP
binary or disassembler is run. Unrecognized magic (possibly encrypted) and
other versions are unsupported. Names do not establish native code addresses
or runtime behavior. See the
other versions are unsupported. Optional matching `binary_path` enables static native addresses and type references; query `method_addresses` with indices or addresses/states, preserving every shared-body method and both artifact identities. Without a linked binary native counts remain unknown. Byte-pattern fallback is inferred; no runtime behavior or same-build pairing is proved. See the
[IL2CPP metadata guide](https://github.com/morluto/rea/blob/main/docs/il2cpp-metadata.md).
- Embedded cargo-auditable Rust dependency metadata: `inspect_rust_binary`
with the explicit local ELF, PE, or thin Mach-O file. This built-in
+4 -2
View File
@@ -111,8 +111,10 @@ rea inspect-il2cpp-metadata /absolute/path/global-metadata.dat --json
The command decodes a version 24, 27, 29, 31 or 39 `global-metadata.dat` and reports assembly
images, type, method and member names, string literals, index ranges, header
sections and SHA-256. It does not decrypt metadata or link names to native code
addresses. MCP defaults to compact counts
sections and SHA-256. With `--binary PATH` it also reads the matching
`GameAssembly.dylib`, `GameAssembly.dll` or `libil2cpp.so` (never runs it) and
links each method to its code address; `--ghidra-seeds PATH` then writes Ghidra
function seeds. It does not decrypt metadata. MCP defaults to compact counts
with retained Evidence; query indexed rows with filters, batch indices and
selected fields. The CLI defaults to complete Evidence;
`--detail summary --evidence_output FILE` exports the full result to a new file
+175 -4
View File
@@ -17,6 +17,9 @@ used by the cited Unity 2019.4 build; earlier 24.x layouts are not inferred from
the version number and are refused when their header or records differ. The path
must name a regular file; directories and symbolic links are rejected.
With `binary_path`, the tool also reads the matching native binary and links
each method to its code address. See [Native method addresses](#native-method-addresses).
## MCP
MCP defaults to a compact summary and retains the complete decoded Evidence in
@@ -54,14 +57,16 @@ the original rows without reopening or decoding the file:
```
Collections are `sections`, `images`, `types`, `methods`, `fields`,
`properties`, `events`, `parameters` and `string_literals`. Every returned row includes
`properties`, `events`, `parameters`, `string_literals`, `method_addresses`,
`return_types`, `field_types`, `parameter_types` and `code_gen_modules`. Every returned row includes
its source `index`, scoped by artifact SHA-256 and collection. All collections
accept batch `indices` and `fields`; collections whose rows have a `name` also
accept exact `names`. Types also accept `image_indices`, `namespaces` and
`declaring_types`; methods, fields, properties and events accept
`declaring_types`. Parameters accept exact `method_indices`, so a batch of
method indices retrieves their parameters without reading the entire parameter
table. Filters intersect, duplicate indices are returned once, and
table. `parameter_types` accepts the same `method_indices`, resolved through the
retained `parameters` owners; Evidence without `parameters` refuses that filter. Filters intersect, duplicate indices are returned once, and
rows retain metadata order. An empty filter selects no rows; `fields: []`
returns indices only. Omitted fields return the complete selected rows.
@@ -103,7 +108,9 @@ After finding method indices, parameters can be requested directly:
Historical Evidence that predates these member collections remains queryable.
A missing collection has an unknown (`null`) summary count, and requesting it
reports that it was not collected; it is never presented as an empty inventory.
reports that it was not collected; it is never presented as an empty inventory. The same holds for Evidence that predates binary linking: its summary
omits `binary`, and `method_addresses`, `method_address_states`,
`return_types`, `field_types` and `parameter_types` counts are `null`.
Existing empty collections retain a zero count. Full Evidence is preserved.
`offset` counts matching rows, not source indices. `limit` defaults to 64;
@@ -136,6 +143,9 @@ in [CLI and Evidence](cli.md#run-a-command). Compact delivery requires
`--evidence_output`; existing files are never overwritten. The full export
preserves every decoded row for subsequent queries without the original file.
`--binary PATH` links method addresses and `--architecture arm64|amd64` selects
the slice of a universal Mach-O.
## Observations
The result contains:
@@ -174,6 +184,154 @@ The complete inventory of a large game is several megabytes and can exceed
the MCP response budget. Summary delivery and filtered, projected pages keep
unneeded rows out of agent context without discarding the source Evidence.
## Native method addresses
`binary_path` names the native binary built with the metadata: macOS
`GameAssembly.dylib` (Mach-O), Windows `GameAssembly.dll` (64-bit PE) or
Android `libil2cpp.so` (64-bit ELF, AArch64 or x86-64). A
universal Mach-O needs `binary_architecture` (`arm64` or `amd64`); a thin binary
rejects it. The file is read, never loaded or executed.
A position-independent `libil2cpp.so` stores zero in most pointer slots and
keeps the value in `R_AARCH64_RELATIVE` or `R_X86_64_RELATIVE` relocations
(`DT_RELA`). The reader writes those values into a private copy before
searching it, so addresses are the link-time addresses and RVAs are relative to
the first loaded segment. Android packed
relocations (`DT_ANDROID_RELA`) are refused. No target code is executed.
The binary's `Il2CppCodeRegistration` points to one `Il2CppCodeGenModule` per
assembly image. Each module record starts with its module name, its method
pointer count and its method pointer array. The reader finds, for every metadata
image, the single record whose name pointer reaches that image's name string and
whose count equals the image's method count. It then requires exactly one
`codeGenModulesCount`/`codeGenModules` pair that lists exactly those records. No
symbols are needed, so stripped builds work. A method's token row id selects its
slot in the module's method pointer array.
Module records establish names through their declared NUL-terminated string pointers. NUL/0xFF padding, spaces, Unicode and suffix-shared storage are supported. A pointer to a longer string does not match just its suffix; a pointer directly into a shared suffix may name it. Exact reversed UTF-8 matching avoids rescanning the file for every name and retains duplicate-name owners until module records resolve them. A contributor investigated a Linux
`GameAssembly.so` from Unity 2023.2.13f1 whose static registration was not located. When no static pair is resolved on x86-64, the reader searches executable ranges for bytes consistent with RIP-relative LEA/MOV references to module-array candidates. Exactly one referenced array candidate must remain. `code_gen_modules_field` is then `null`, and Evidence is **inferred**: instruction boundaries, the absence of a static registration elsewhere, and runtime use were not verified. The module records and slots themselves remain file observations. Static-table associations are **derived**. Otherwise the result is `code-registration-not-found`.
The binary/metadata pair is caller-selected. Name/count/token agreement is structural evidence, not a cryptographic same-build proof. File-offset locations refer to the primary metadata artifact; linked virtual addresses refer to `binary.artifact`. Both SHA-256 identities remain available in compact native pages as `artifact` and `binary_source`.
Contributor-reported verification (not all rerun by the follow-up): macOS arm64 and x86_64 slices from Unity 2021.3.45f2, 2022.3.30f1, 2022.3.62f3 and
6000.0.84f1 against dSYM symbols; Android arm64 and x86-64 from 2022.3.62f3
against `libil2cpp.sym.so`; Windows x64 from 2023.2.13f1 (metadata version 29,
the MIT-licensed game Between Two Worlds 1.0.5, `GameAssembly.dll` SHA-256
`a3ae7f6ecd1f671297b7d98d12713ef024062d93cd7a4205298102656c93b67a`) against
Il2CppDumper, where all 43,198 linked methods share an address with a
same-named Il2CppDumper method and identical code folding groups 10,448 methods
into 1,735 addresses. A macOS x86_64 build from Unity 2020.3.49f1 (metadata version 27, which Il2CppDumper reads as 27.1) links 10,555 methods, all at addresses where Il2CppDumper names the same method; its other addresses are generic instances. A build from Unity 2021.1.28f1 (also read as 27.1) gives the same result: 10,345 methods linked, every address named the same by Il2CppDumper, and its other 3,486 addresses generic instances.
These checks compare addresses with symbols or with another static tool. They
do not establish that a linked address is the code the game runs (a runtime may
patch, hook or replace it), that builds from other Unity versions, compilers or
obfuscators link the same way, or that a method without an address has no code:
generic instances, adjustor thunks and invokers are not linked, and an
`unknown` row was not looked up at all.
The result adds:
- `binary`: artifact identity, `format`, `architecture`, `image_base`,
`code_gen_modules_field` (the address of `codeGenModulesCount`, or `null` as
described above), `code_gen_modules_array` (the module pointer array) and
`code_gen_modules` (`image`, record `address`, `method_pointer_count`);
- `method_addresses`: one row per method index with `state`, `address` and
`rva` (address minus `image_base`), both hexadecimal strings. `state` is
`code` when the module slot holds an unambiguously mapped executable address, `no-body` when an observed slot is zero,
as for abstract, extern and most generic methods, and `unknown` when no type
range in any image holds the method or a symbol relocation leaves its slot unresolved.
`address` and `rva` are `null` unless `state` is `code`. The summary counts
each state in `counts.method_address_states`. Ghidra seeds are written only
for `code` rows.
- `return_types`, `field_types` and `parameter_types`: one row per method,
field and parameter index with `type`, the index into the binary's
`Il2CppType` table, and `name`, rendered from that table: C# keywords for
primitives (`int`, `string`, `object`), `Namespace.Outer+Inner` for type
definitions, `T[]`, `T[,]` and `T*`, generic instances as
`System.Collections.Generic.List<string>` and generic parameters by their
metadata name (`TKey`). A trailing `&` marks a by-reference type. `name` is
`null` only for type index `-1`.
`binary` also records `types_field` and `types_count`: the address of
`Il2CppMetadataRegistration.typesCount` and its value. That record is found as
the single place whose `fieldOffsetsCount` and `typeDefinitionsSizesCount` both
equal the metadata type-definition count and whose type table is file mapped.
Without `binary_path`, `binary` is `null` and the four binary collections are
empty in the complete producer report to indicate unrequested observations. Their summary counts, including state counts, are `null`, and native page requests report unavailable observations. This does not establish an empty native inventory. Missing native fields in historical Evidence remain unknown too.
For metadata version 27, type names are `null` and only type indices are
reported: versions 27.0 to 27.2 share that header number but place the
Il2CppType byref flag in different bits, so a rendered name could gain or lose
`&` without warning.
The builds checked are listed after the module search rules above.
| Reason | Category | Meaning |
| --------------------------------- | ----------- | ------------------------------------------------------------------------------------------------- |
| `unsupported-container` | unsupported | Not a 64-bit little-endian Mach-O, PE or ELF image (ELF: AArch64 or x86-64 with dynamic section). |
| `slice-selection-required` | unsupported | A universal Mach-O was given without `binary_architecture`. |
| `slice-not-found` | unsupported | The requested slice is absent, or a thin binary was given an architecture. |
| `unsupported-pointer-encoding` | unsupported | Mach-O chained fixups or Android packed (APS2) ELF relocations; pointers not decoded. |
| `malformed-container` | input error | The container headers are damaged. |
| `code-gen-modules-not-found` | input error | An image has no module record; often a binary from a different build. |
| `ambiguous-code-gen-module` | input error | More than one module record matches an image. |
| `code-registration-not-found` | input error | No unique module table or executable-byte array candidate was resolved. |
| `invalid-method-token` | input error | A method token is not a method row inside its module's pointer count. |
| `invalid-method-pointer` | input error | A slot is unavailable or its nonzero address lacks unambiguous executable file coverage. |
| `metadata-registration-not-found` | input error | No single MetadataRegistration matches the type-definition count. |
| `invalid-type` | input error | A type index or `Il2CppType` record is out of range, unmapped or malformed. |
| `unsupported-type-kind` | unsupported | A valid type kind has no implemented rendering. |
Format and selection failures name `binary_path`; capacity refusals remain typed resource errors. Failed linking preserves the metadata source identity, validated counts, selected binary path/architecture and its SHA-256 when observed, plus prior producer observations. It never publishes an incomplete native table as complete. The binary input is bounded to1GiB.
Scans yield cooperatively in bounded batches. Name-address retention and reverse-trie construction are each limited to65,536 entries/nodes and fallback module-array candidates to64 (`native-index-capacity`); exceeding capacity does not prove absence. Rendered type text has a64MiB UTF-8 cache-workspace guard, separate from the64MiB encoded-report admission, and64 nested rendering frames (`native-type-text-capacity` / `type-nesting-capacity`). Genuine cycles are malformed; unsupported type kinds are explicit coverage failures. Native row/text admission runs before allocation or retention. Unused generic parameter names are read on demand from the same stable metadata snapshot. There is no cross-inspection file cache; validated immutable Evidence is reused for session views.
ELF RELATIVE writes into zero-fill are kept as a private virtual-pointer overlay. Unresolved supported-width symbol relocations, including RELA PLT entries, remain unknown; stored zeros never establish no-body. COPY, TLS descriptor, REL, packed APS2 and other unsupported relocation encodings fail closed. Input bytes are unchanged.
Native address queries batch exact64-bit hexadecimal `addresses` and `states` (`code`, `no-body`, `unknown`), intersecting indices and pagination. Numerically equal address spellings match; original row indices and every shared-body alternative survive:
```json
{
"source": {
"kind": "retained-evidence",
"evidence_id": "<parent_evidence_id>"
},
"view": {
"kind": "il2cpp-metadata",
"collection": "method_addresses",
"addresses": ["0x180c56ed0"],
"states": ["code"],
"fields": ["address", "rva"],
"offset": 0,
"limit": 64
}
}
```
## Ghidra function names
The CLI can turn a linked report into a
[`REA_GHIDRA_SEED_FILE`](installation.md) so Ghidra names every IL2CPP method
before auto-analysis:
```bash
rea inspect-il2cpp-metadata global-metadata.dat --binary libil2cpp.so \
--ghidra-seeds il2cpp-seeds.tsv --json > /dev/null
export REA_GHIDRA_SEED_FILE="$PWD/il2cpp-seeds.tsv"
```
Each linked method becomes one `function` seed named `Namespace.Type$$Method`
(nested types joined with `.`, whitespace replaced by `_`), the Il2CppDumper
convention. Seeds are at `seed-base + rva`. The default base is where Ghidra's
loaders place the binary: `0x100000` for an ELF linked at 0, and the link base
for Mach-O and PE; `--seed-base 0x...` overrides it for a different import. When
identical code folding gives several methods one address, the first method names
the function and a `# folded <address> <kept> also <names>` comment line after
the seeds lists the others, so the kept name is visibly one of several. Ghidra still names a seeded function that is a thunk after its
target. The file is atomically published with exclusive creation; an existing destination is never replaced. Failed staging retains its cleanup owner for retry. Source digest and link/seed-base comments make the mapping inspectable. Unknown slots are skipped separately from observed no-body slots. `--seed-base` requires `--ghidra-seeds`; overflowing rebased addresses and names/outputs beyond the existing Ghidra protocol limits (2,000 name characters,32MiB file,1,000,000 entries) are refused before publication. The CLI and reusable service use one formatter and owned output workflow; ordinary MCP inspection remains read-only.
## Failures
A file whose magic is not `0xFAB11BAF` fails as an unsupported target with
@@ -224,7 +382,13 @@ report limit can still require the summary and paging path.
## Outside this tool
- Decrypting or deobfuscating protected metadata.
- Native code addresses, member types and method signatures; use the
- Linking Mach-O binaries that use chained fixups, ELF images with Android
packed (APS2) relocations, and 32-bit images; generic method instances,
adjustor thunks and invokers.
- Full method signatures beyond the rendered field, parameter and return type
names, generic method instances, and type names for metadata version 27.
- Running Ghidra or another disassembler. `--ghidra-seeds` only writes a seed
file for [`REA_GHIDRA_SEED_FILE`](installation.md); use the
[native workflow](native-investigation.md) for native code.
- Metadata versions other than 24, 27, 29, 31 and 39.
@@ -246,3 +410,10 @@ An independent byte-layout oracle matched all 36 images, 2,468 types and 12,409
methods. Corresponding managed DLLs independently matched names and ownership
for 35 images, 2,463 types and 12,406 methods; the generated image has no matching
DLL. This does not establish real-producer coverage for versions 29 or 39.
Compact native summaries omit the `binary.code_gen_modules` table and keep its
count in `counts.code_gen_modules`. Query `collection: "code_gen_modules"`
with batch `image_indices` or source `indices`, selected `fields` and pagination.
Module row `index` is its retained collection position; `image` joins the
metadata image collection. Scalar registration facts and both source identities
remain inline. Complete Evidence retains the original full table.
+7
View File
@@ -125,6 +125,13 @@ try {
events: report.events.length,
parameters: report.parameters.length,
string_literals: report.string_literals.length,
// The verifier selects no binary, so native observations are unrequested.
code_gen_modules: null,
method_addresses: null,
method_address_states: null,
return_types: null,
field_types: null,
parameter_types: null,
});
const page = analysisViewResultSchema.parse(
(
+5 -2
View File
@@ -238,7 +238,8 @@ const writeTextPartsOwned = async (
if (cause.outcome.error instanceof AnalysisCancelledError)
return err(
new AnalysisCancelledError(cause.outcome.error.operation, {
cause,
cause: cause.outcome.error.cause,
cleanupPrimary: cause.outcome.error,
cleanup,
}),
);
@@ -377,7 +378,9 @@ const writeStagedParts = async (
const assertWriteActive = (cancellation?: TextWriteCancellation): void => {
if (cancellation?.signal.aborted === true)
throw new AnalysisCancelledError(cancellation.operation);
throw new AnalysisCancelledError(cancellation.operation, {
cause: cancellation.signal.reason,
});
};
const fileErrorCode = (cause: unknown): unknown =>
@@ -1,3 +1,5 @@
import { createEmptyIl2cppMetadataFixture } from "../../domain/il2cpp/il2cppMetadata.fixture.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { expect, it } from "vitest";
import { Il2cppMetadataService } from "./Il2cppMetadataService.js";
import {
@@ -6,48 +8,18 @@ import {
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import {
IL2CPP_METADATA_SECTION_NAMES,
type Il2cppMetadata,
} from "../../domain/il2cpp/il2cppMetadata.js";
import { ok } from "../../domain/result.js";
import type { Il2cppMetadata } from "../../domain/il2cpp/il2cppMetadata.js";
import { err, ok } from "../../domain/result.js";
import { EvidenceFileError } from "../../domain/evidenceErrors.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { Il2cppSeedWriterPort } from "./Il2cppSeedWriterPort.js";
const identity = {
id: "fixture-il2cpp",
name: "Source-owned IL2CPP reader seam",
version: "1",
};
const report: Il2cppMetadata = {
artifact: {
path: "/artifacts/global-metadata.dat",
sha256: "a".repeat(64),
bytes: 1024,
},
magic: 0xfab11baf,
metadata_version: 39,
index_widths: {
type_definition: 1,
generic_container: 1,
parameter: 1,
type: 1,
type_width_source: "interface-offsets-record",
},
sections: IL2CPP_METADATA_SECTION_NAMES.map((name) => ({
name,
offset: 380,
size: 0,
count: 0,
})),
images: [],
types: [],
methods: [],
fields: [],
properties: [],
events: [],
parameters: [],
string_literals: [],
limitations: [],
};
const report = createEmptyIl2cppMetadataFixture();
const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) =>
new Il2cppMetadataService({
@@ -68,8 +40,10 @@ it("rejects invalid paths and extra arguments before invoking the reader", async
{ path: report.artifact.path, detail: "rows" },
]) {
const result = await service.inspect(input);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
expect(result).toMatchObject({
ok: false,
error: expect.any(AnalysisInputError),
});
}
expect(calls.count).toBe(0);
});
@@ -150,3 +124,148 @@ it("reports cancellation before and after reading", async () => {
});
}
});
const linkedReport = (): Il2cppMetadata => ({
...report,
binary: {
artifact: {
path: "/artifacts/GameAssembly.dll",
sha256: "b".repeat(64),
bytes: 4096,
},
format: "pe",
architecture: "amd64",
image_base: "0x1000",
code_gen_modules_field: "0x1100",
code_gen_modules_array: "0x1200",
code_gen_modules: [],
types_field: "0x1300",
types_count: 0,
},
});
const seedWriter = (
write: Il2cppSeedWriterPort["write"],
close: () => Promise<void> = async () => {},
): Il2cppSeedWriterPort => ({ check: async () => ok(undefined), write, close });
const selectedPair = {
path: report.artifact.path,
binary_path: "/artifacts/GameAssembly.dll",
detail: "complete",
} as const;
it("preserves source Evidence and requested output choices when publication fails", async () => {
const failure = new EvidenceFileError("write", "io", {
path: "/output/seeds.tsv",
});
const service = new Il2cppMetadataService(
{ identity, inspect: async () => ok(linkedReport()) },
seedWriter(async () => err(failure)),
);
const result = await service.inspectWithGhidraSeeds(selectedPair, {
path: "/output/seeds.tsv",
base: "0x2000",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed publication accepted");
expect(result.error).toBe(failure);
expect(result.error.partialObservation).toMatchObject({
operation: "inspect_il2cpp_metadata",
parameters: {
path: selectedPair.path,
binary_path: selectedPair.binary_path,
ghidra_seeds: "/output/seeds.tsv",
seed_base: "0x2000",
},
});
await service.close();
});
it("retains a published seed receipt if cancellation arrives after the atomic write", async () => {
const controller = new AbortController(),
reason = new Error("cancel after publication");
const receipt = {
path: "/output/seeds.tsv",
bytes: 200,
functions: 0,
folded: 0,
no_body: 0,
unknown: 0,
};
const service = new Il2cppMetadataService(
{ identity, inspect: async () => ok(linkedReport()) },
seedWriter(async () => {
controller.abort(reason);
return ok(receipt);
}),
);
const result = await service.inspectWithGhidraSeeds(
selectedPair,
{ path: receipt.path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation ignored");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(result.error.diagnostics).toMatchObject({
published_seed_output: receipt,
});
await service.close();
});
it("closes both ports and keeps combined cleanup incomplete until both owners resolve", async () => {
const metadata = new ProviderCleanupError(
identity.id,
["metadata-reader"],
{},
),
output = new ProviderCleanupError(identity.id, ["seed-output"], {});
let metadataCloses = 0,
seedCloses = 0;
const service = new Il2cppMetadataService(
{
identity,
inspect: async () => ok(report),
close: async () => {
metadataCloses++;
throw metadata;
},
},
seedWriter(
async () => {
throw new Error("not used");
},
async () => {
seedCloses++;
throw output;
},
),
);
let combined;
try {
await service.close();
} catch (cause) {
combined = cause;
}
expect(metadataCloses).toBe(1);
expect(seedCloses).toBe(1);
expect(combined).toBeInstanceOf(AnalysisError);
if (!(combined instanceof AnalysisError))
throw new Error("Missing cleanup failure");
const failure = combined;
expect([...failure.cleanupResources].sort()).toEqual([
"metadata-reader",
"seed-output",
]);
output.resolveCleanup();
expect(failure.cleanupIncomplete).toBe(true);
metadata.resolveCleanup();
expect(failure.cleanupIncomplete).toBe(false);
});
it.each([false, true])(
"classifies native association confidence (inferred: %s)",
async (inferred) => {
const value = linkedReport();
if (inferred) value.binary!.code_gen_modules_field = null;
const result = await serviceReturning(value).inspect(selectedPair);
if (!result.ok) throw result.error;
expect(result.value.confidence).toBe(inferred ? "inferred" : "derived");
},
);
+177 -24
View File
@@ -1,3 +1,10 @@
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type {
Il2cppSeedOutput,
Il2cppSeedWriterPort,
Il2cppSeedReceipt,
} from "./Il2cppSeedWriterPort.js";
import { isAbsolute } from "node:path";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
@@ -6,7 +13,7 @@ import {
type AnalysisViewRetention,
} from "../analysisView/AnalysisViewService.js";
import type { Il2cppMetadataPort } from "./Il2cppMetadataPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
@@ -16,6 +23,8 @@ import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
il2cppMetadataSchema,
inspectIl2cppMetadataInputSchema,
type Il2cppMetadata,
type Il2cppMetadataProviderInput,
} from "../../domain/il2cpp/il2cppMetadata.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -28,17 +37,68 @@ const NO_RETENTION: AnalysisViewRetention = {
/** Shared exact input validation, artifact-bound Evidence and summary delivery for CLI and MCP. */
export class Il2cppMetadataService {
constructor(readonly provider: Il2cppMetadataPort) {}
constructor(
readonly provider: Il2cppMetadataPort,
private readonly seeds?: Il2cppSeedWriterPort,
) {}
close(): Promise<void> {
return this.provider.close?.() ?? Promise.resolve();
async close(): Promise<void> {
let failure: AnalysisError | undefined;
for (const close of [
() => this.provider.close?.(),
() => this.seeds?.close(),
]) {
try {
await close();
} catch (cause) {
const next =
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
this.provider.identity.id,
[],
{
reason:
cause instanceof Error ? cause.message : String(cause),
},
{ cause, operation: OPERATION },
);
failure =
failure === undefined
? next
: analysisErrorWithCleanupFailure(
failure,
next,
OPERATION,
).bindCleanupDependencies([failure, next]);
}
}
if (failure !== undefined) throw failure;
}
/** Inspect one explicitly selected file; detail changes delivery, not decoded coverage. */
async inspect(
inspect(
rawInput: unknown,
options?: ExecutionOptions,
retention: AnalysisViewRetention = NO_RETENTION,
): Promise<Result<Evidence, AnalysisError>> {
return this.#inspect(rawInput, options, retention);
}
/** Explicit output workflow for CLI/SDK callers; ordinary inspection stays read-only. */
inspectWithGhidraSeeds(
rawInput: unknown,
output: Il2cppSeedOutput,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
return this.#inspect(rawInput, options, NO_RETENTION, output);
}
/** Inspect one explicitly selected file; detail changes delivery, not decoded coverage. */
async #inspect(
rawInput: unknown,
options: ExecutionOptions | undefined,
retention: AnalysisViewRetention,
output?: Il2cppSeedOutput,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(
@@ -51,16 +111,54 @@ export class Il2cppMetadataService {
cause: input.error,
}),
);
if (!isAbsolute(input.data.path))
for (const field of ["path", "binary_path"] as const) {
const value = input.data[field];
if (value !== undefined && !isAbsolute(value))
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: [field],
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
},
]),
);
}
if (
input.data.binary_architecture !== undefined &&
input.data.binary_path === undefined
)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["path"],
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
path: ["binary_architecture"],
reason: "invalid_value",
message: "binary_architecture selects a slice of binary_path.",
},
]),
);
if (output !== undefined) {
if (input.data.binary_path === undefined)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["ghidra_seeds"],
reason: "invalid_value",
message:
"--ghidra-seeds needs --binary to link method addresses.",
},
]),
);
if (this.seeds === undefined)
return err(
new AnalysisOutputError(
OPERATION,
"No seed output adapter is configured.",
),
);
const ready = await this.seeds.check(output, options);
if (!ready.ok) return ready;
}
const { detail, ...providerInput } = input.data;
if (detail === "summary") {
const ready = requireAnalysisViewRetention(OPERATION, retention);
@@ -69,7 +167,14 @@ export class Il2cppMetadataService {
const inspected = await this.provider.inspect(providerInput, options);
if (!inspected.ok) return inspected;
const report = il2cppMetadataSchema.safeParse(inspected.value);
if (!report.success || report.data.artifact.path !== providerInput.path)
if (
!report.success ||
report.data.artifact.path !== providerInput.path ||
(report.data.binary?.artifact.path ?? undefined) !==
providerInput.binary_path ||
(providerInput.binary_architecture !== undefined &&
report.data.binary?.architecture !== providerInput.binary_architecture)
)
return err(
new AnalysisOutputError(
OPERATION,
@@ -77,7 +182,38 @@ export class Il2cppMetadataService {
),
);
const value = report.data;
const evidence = createEvidence(
const evidence = this.#evidence(value, providerInput, output);
let seedReceipt: Il2cppSeedReceipt | undefined;
const cancelled = () =>
err(
new AnalysisCancelledError(OPERATION, {
cause: options?.signal?.reason,
partialObservation: evidence,
...(seedReceipt === undefined
? {}
: { diagnostics: { published_seed_output: { ...seedReceipt } } }),
}),
);
if (options?.signal?.aborted) return cancelled();
if (output !== undefined && this.seeds !== undefined) {
const written = await this.seeds.write(value, output, options);
if (!written.ok) {
written.error.retainPartialObservation(evidence);
return written;
}
seedReceipt = written.value;
}
if (options?.signal?.aborted) return cancelled();
return detail === "summary"
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence);
}
#evidence(
value: Il2cppMetadata,
providerInput: Il2cppMetadataProviderInput,
output: Il2cppSeedOutput | undefined,
): Evidence {
return createEvidence(
{
path: value.artifact.path,
sha256: value.artifact.sha256,
@@ -86,12 +222,39 @@ export class Il2cppMetadataService {
this.provider.identity,
{
operation: OPERATION,
parameters: providerInput,
parameters: {
...(Object.fromEntries(
Object.entries(providerInput).filter(
([, value]) => value !== undefined,
),
) as Record<string, string>),
...(output === undefined
? {}
: {
ghidra_seeds: output.path,
...(output.base === undefined
? {}
: { seed_base: output.base }),
}),
},
result: value,
confidence: "observed",
confidence:
value.binary === null
? "observed"
: value.binary.code_gen_modules_field === null
? "inferred"
: "derived",
limitations: value.limitations,
locations: [
{ kind: "artifact-path", path: value.artifact.path },
...(value.binary === null
? []
: [
{
kind: "artifact-path" as const,
path: value.binary.artifact.path,
},
]),
...value.sections
.filter(
({ name, size }) =>
@@ -116,15 +279,5 @@ export class Il2cppMetadataService {
],
},
);
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
partialObservation: evidence,
}),
);
return detail === "summary"
? summarizeRetainedAnalysis(evidence, retention)
: ok(evidence);
}
}
@@ -0,0 +1,32 @@
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Il2cppMetadata } from "../../domain/il2cpp/il2cppMetadata.js";
import type { Result } from "../../domain/result.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
/** Explicit file output, separate from read-only metadata inspection. */
export interface Il2cppSeedOutput {
readonly path: string;
readonly base?: string;
}
export interface Il2cppSeedReceipt {
readonly path: string;
readonly bytes: number;
readonly functions: number;
readonly folded: number;
readonly no_body: number;
readonly unknown: number;
}
/** Shared output workflow; adapters own file format and publication resources. */
export interface Il2cppSeedWriterPort {
check(
output: Il2cppSeedOutput,
options?: ExecutionOptions,
): Promise<Result<void, AnalysisError>>;
write(
report: Il2cppMetadata,
output: Il2cppSeedOutput,
options?: ExecutionOptions,
): Promise<Result<Il2cppSeedReceipt, AnalysisError>>;
close(): Promise<void>;
}
+69 -7
View File
@@ -1,5 +1,7 @@
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { z } from "incur";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err } from "../domain/result.js";
import { createIl2cppMetadataService } from "../composition/il2cpp.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@@ -18,23 +20,83 @@ export const registerIl2cppCommands = (
): void => {
cli.command(CLI_COMMANDS.inspectIl2cppMetadata, {
description:
"Inventory IL2CPP global-metadata.dat names, owners and string literals",
"Inventory IL2CPP global-metadata.dat names, owners and string literals, optionally linking method addresses from the native binary",
args: z.object({
path: z
.string()
.describe("Local global-metadata.dat (version 24, 27, 29, 31 or 39)"),
}),
options: z.object(analysisEvidenceDeliveryOptions),
options: z.object({
...analysisEvidenceDeliveryOptions,
binary: z
.string()
.optional()
.describe(
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only",
),
architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe("Slice of a universal Mach-O binary"),
ghidraSeeds: z
.string()
.optional()
.describe(
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
),
seedBase: z
.string()
.regex(/^0x[0-9a-fA-F]{1,16}$/u)
.optional()
.describe(
"Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => {
const service = createIl2cppMetadataService();
return deliverCliAnalysisEvidence({
run: () =>
service.inspect(
{ path: absoluteCallerPath(args.path), detail: "complete" },
{ signal },
),
run: () => {
const input = {
path: absoluteCallerPath(args.path),
detail: "complete" as const,
...(options.binary === undefined
? {}
: { binary_path: absoluteCallerPath(options.binary) }),
...(options.architecture === undefined
? {}
: { binary_architecture: options.architecture }),
};
if (options.ghidraSeeds !== undefined)
return service.inspectWithGhidraSeeds(
input,
{
path: absoluteCallerPath(options.ghidraSeeds),
...(options.seedBase === undefined
? {}
: { base: options.seedBase }),
},
{ signal },
);
if (options.seedBase !== undefined)
return Promise.resolve(
err(
new AnalysisInputError(
"inspect_il2cpp_metadata",
undefined,
[
{
path: ["seed_base"],
reason: "invalid_value",
message: "--seed-base requires --ghidra-seeds.",
},
],
),
),
);
return service.inspect(input, { signal });
},
service,
providerId: "il2cpp-metadata",
operation: "inspect_il2cpp_metadata",
-3
View File
@@ -74,9 +74,6 @@ describe("CLI JSON path normalization", () => {
expect(parsed.error.issues).toEqual([
expect.objectContaining({
path: ["executable_path"],
message: expect.stringContaining(
"must be absolute local filesystem paths",
),
}),
]);
});
+5 -1
View File
@@ -1,6 +1,10 @@
import { Il2cppGhidraSeedWriter } from "../il2cpp/Il2cppGhidraSeedWriter.js";
import { Il2cppMetadataService } from "../application/il2cpp/Il2cppMetadataService.js";
import { Il2cppMetadataProvider } from "../il2cpp/Il2cppMetadataProvider.js";
/** Compose bundled offline inspection without acquiring a toolchain or starting any process. */
export const createIl2cppMetadataService = (): Il2cppMetadataService =>
new Il2cppMetadataService(new Il2cppMetadataProvider());
new Il2cppMetadataService(
new Il2cppMetadataProvider(),
new Il2cppGhidraSeedWriter(),
);
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module refeLine truncated
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module refeLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+10 -1
View File
@@ -15,7 +15,7 @@ export const IL2CPP_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_il2cpp_metadata"),
kind: "artifact-provider",
description:
"Inspect a local Unity IL2CPP global-metadata.dat (versions 24, 27, 29, 31 and 39). Default summary retains complete Evidence and returns artifact identity, version and counts and parent_evidence_id. Query inspect_analysis_view kind il2cpp-metadata, collection sections/images/types/methods/fields/properties/events/parameters/string_literals, with exact indices or names, fields and pagination. Types also accept image_indices, namespaces and declaring_types; methods, fields, properties and events accept declaring_types. Parameters accept method_indices. Filters intersect and empty arrays select none. Each row always keeps index, scoped to its artifact SHA-256 and collection; page offsets count matching rows in metadata order. Explicit detail complete returns all rows. Reads existing files, with 256 MiB input and 64 MiB encoded-report guards and one active inspection per reader. Unsupported versions and malformed metadata return reasons. Type declaring_type is derived from nestedTypes, and member and parameter owners from index ranges; native code addresses and runtime behavior remain unknown.",
"Inspect local Unity IL2CPP metadata (24/27/29/31/39), optionally linking a caller-selected 64-bit little-endian Mach-O, PE or ELF binary. Static reading only. Default summary retains complete Evidence; use parent_evidence_id with inspect_analysis_view kind il2cpp-metadata for named collections, indices, fields and paging. Parameters accept method_indices; method_addresses accept addresses and states. Stable row indices are scoped to the metadata digest; native pages also identify the binary. Native states distinguish executable code, observed zero slots (no-body), and unknown owners/relocations. Shared addresses preserve all methods. code_gen_modules pages accept image_indices; summaries omit that table. Type names are C#-like; version27 retains indices with unknown names. Unrequested native counts are null. Static-table links are derived; the x86-64 executable-byte fallback is inferred and does not verify instruction boundaries. Input guards:256MiB metadata/1GiB binary;64MiB encoded report; one active inspection. Unsupported layouts, pointer encodings and resource limits have typed reasons. Full Evidence remains available with detail complete. Neither static linking nor structural agreement proves runtime behavior or a same-build pair.",
inputSchema: inspectIl2cppMetadataInputSchema,
outputSchema: evidenceResultOf(
z.union([il2cppMetadataSchema, il2cppMetadataSummaryViewResultSchema]),
@@ -28,6 +28,15 @@ export const IL2CPP_TOOL_CONTRACTS = [
detail: "summary",
},
},
{
title: "Link method addresses from a universal macOS binary",
input: {
path: "/artifacts/Game.app/Contents/Resources/Data/il2cpp_data/Metadata/global-metadata.dat",
binary_path:
"/artifacts/Game.app/Contents/Frameworks/GameAssembly.dylib",
binary_architecture: "arm64",
},
},
],
},
] as const satisfies readonly ToolContract[];
@@ -1,6 +1,6 @@
import { createEmptyIl2cppMetadataFixture } from "../il2cpp/il2cppMetadata.fixture.js";
import { afterEach, expect, it, vi } from "vitest";
import {
IL2CPP_METADATA_SECTION_NAMES,
il2cppMetadataParentSchema,
type Il2cppMetadata,
} from "../il2cpp/il2cppMetadata.js";
@@ -14,26 +14,7 @@ afterEach(() => {
});
const report: Il2cppMetadata = {
artifact: {
path: "/artifacts/global-metadata.dat",
sha256: "a".repeat(64),
bytes: 1024,
},
magic: 0xfab11baf,
metadata_version: 39,
index_widths: {
type_definition: 1,
generic_container: 1,
parameter: 1,
type: 1,
type_width_source: "interface-offsets-record",
},
sections: IL2CPP_METADATA_SECTION_NAMES.map((name) => ({
name,
offset: 380,
size: 0,
count: 0,
})),
...createEmptyIl2cppMetadataFixture(),
images: [{ name: "A.dll", type_start: 0, type_count: 2 }],
types: [
{
@@ -61,6 +42,11 @@ const report: Il2cppMetadata = {
events: [],
parameters: [{ name: "times", method: 0 }],
string_literals: [{ value: "Go", value_base64: null }],
binary: null,
method_addresses: [],
return_types: [],
field_types: [],
parameter_types: [],
limitations: [],
};
@@ -72,6 +58,12 @@ const parent = (normalizedResult: unknown, sealedResult: boolean) => ({
limitations: [],
});
const summarize = (source: ReturnType<typeof parent>) => {
const result = projectIl2cppMetadataView(source, { kind: "summary" });
if (!result.ok) throw result.error;
return result.value;
};
const page = {
kind: "il2cpp-metadata",
collection: "types",
@@ -98,6 +90,10 @@ it("validates a sealed parent once across pages and keeps counts exact", () => {
events: 0,
parameters: 1,
string_literals: 1,
method_addresses: null,
return_types: null,
field_types: null,
parameter_types: null,
},
});
}
@@ -123,9 +119,8 @@ it("preserves historical omission as unknown while keeping existing metadata que
])
delete historical[key];
const source = parent(freezeJsonSnapshot(historical), true);
const summary = projectIl2cppMetadataView(source, { kind: "summary" });
if (!summary.ok) throw summary.error;
expect(summary.value).toMatchObject({
const summary = summarize(source);
expect(summary).toMatchObject({
summary: {
counts: { types: 2, methods: 1, fields: null, parameters: null },
},
@@ -147,6 +142,141 @@ it("preserves historical omission as unknown while keeping existing metadata que
expect(unavailable.ok).toBe(false);
});
it("treats Evidence retained before binary linking as unknown, not unlinked", () => {
const historical = jsonObjectSchema.parse(report);
for (const key of [
"binary",
"method_addresses",
"return_types",
"field_types",
"parameter_types",
])
delete historical[key];
const source = parent(freezeJsonSnapshot(historical), true);
const summary = summarize(source);
expect(summary).not.toHaveProperty("summary.binary");
expect(summary).toMatchObject({
summary: {
counts: {
fields: 1,
method_addresses: null,
method_address_states: null,
return_types: null,
},
},
unknowns: expect.arrayContaining([
expect.stringContaining("did not collect method_addresses"),
]),
});
expect(projectIl2cppMetadataView(source, page).ok).toBe(true);
const unavailable = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "method_addresses",
offset: 0,
limit: 1,
});
expect(unavailable.ok).toBe(false);
const current = projectIl2cppMetadataView(parent(report, false), {
kind: "summary",
});
if (!current.ok) throw current.error;
expect(current.value).toMatchObject({
summary: {
binary: null,
counts: {
method_addresses: null,
method_address_states: null,
return_types: null,
},
},
});
expect(
projectIl2cppMetadataView(parent(report, false), {
kind: "il2cpp-metadata",
collection: "method_addresses",
offset: 0,
limit: 64,
}).ok,
).toBe(false);
});
const linkedBinary = {
artifact: {
path: "/fixture/libil2cpp.so",
sha256: "c".repeat(64),
bytes: 64,
},
format: "elf" as const,
architecture: "arm64",
image_base: "0x0",
code_gen_modules_field: "0x10",
code_gen_modules_array: "0x20",
code_gen_modules: [],
types_field: "0x30",
types_count: 4,
};
it("selects parameter types by owning method through the parameters collection", () => {
const linked = {
...report,
parameters: [
{ name: "a", method: 0 },
{ name: "b", method: 0 },
],
binary: { ...linkedBinary },
method_addresses: report.methods.map(() => ({
state: "no-body" as const,
address: null,
rva: null,
})),
return_types: report.methods.map(() => ({ type: -1, name: null })),
field_types: report.fields.map(() => ({ type: 1, name: "int" })),
parameter_types: [
{ type: 2, name: "string" },
{ type: 3, name: "bool" },
],
};
const select = (
source: ReturnType<typeof parent>,
method_indices: number[],
) =>
projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "parameter_types",
method_indices,
offset: 0,
limit: 8,
});
const owned = select(parent(linked, false), [0]);
if (!owned.ok) throw owned.error;
expect(owned.value).toMatchObject({
items: [
{ index: 0, type: 2, name: "string" },
{ index: 1, type: 3, name: "bool" },
],
});
const none = select(parent(linked, false), [1]);
if (!none.ok) throw none.error;
expect(none.value).toMatchObject({ items: [] });
// Without retained parameters the owners are unknown, so the filter is refused.
const historical = jsonObjectSchema.parse(linked);
delete historical.parameters;
expect(select(parent(freezeJsonSnapshot(historical), true), [0]).ok).toBe(
false,
);
});
it.each([{ method_addresses: undefined }, { binary: undefined }])(
"rejects a binary link field retained without its collections: %j",
(changed) => {
const result = projectIl2cppMetadataView(
parent({ ...report, ...changed }, false),
{ kind: "summary" },
);
expect(result.ok).toBe(false);
},
);
it.each([
{ fields: [{ name: "bad", declaring_type: 2 }] },
{ parameters: [{ name: "bad", method: 1 }] },
@@ -288,3 +418,137 @@ it("refuses a parent whose subject contradicts the recorded artifact identity",
);
expect(result.ok).toBe(false);
});
const linkedReport = (): Il2cppMetadata => ({
...structuredClone(report),
types: report.types.map((type, index) => ({
...type,
method_start: index * 2,
method_count: 2,
})),
methods: [
{ name: "A", declaring_type: 0 },
{ name: "B", declaring_type: 0 },
{ name: "Abstract", declaring_type: 1 },
{ name: "Unresolved", declaring_type: 1 },
],
binary: {
artifact: {
path: "/artifacts/GameAssembly.dll",
sha256: "c".repeat(64),
bytes: 4096,
},
format: "pe",
architecture: "amd64",
image_base: "0x1000",
code_gen_modules_field: "0x1200",
code_gen_modules_array: "0x1300",
code_gen_modules: [
{ image: 0, address: "0x1400", method_pointer_count: 4 },
],
types_field: "0x1500",
types_count: 1,
},
method_addresses: [
{ state: "code", address: "0x1100", rva: "0x100" },
{ state: "code", address: "0x1100", rva: "0x100" },
{ state: "no-body", address: null, rva: null },
{ state: "unknown", address: null, rva: null },
],
return_types: Array.from({ length: 4 }, () => ({ type: 0, name: "void" })),
field_types: [{ type: 0, name: "int" }],
parameter_types: [{ type: 0, name: "int" }],
});
it("selects every folded method by numeric address with stable indices and both source identities", () => {
const source = parent(freezeJsonSnapshot(linkedReport()), true);
const view = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "method_addresses",
addresses: ["0x00001100", "0x1100"],
states: ["code"],
fields: [],
offset: 1,
limit: 1,
});
if (!view.ok) throw view.error;
expect(view.value).toMatchObject({
items: [{ index: 1 }],
artifact: { sha256: report.artifact.sha256 },
binary_source: {
artifact: { path: "/artifacts/GameAssembly.dll", sha256: "c".repeat(64) },
image_base: "0x1000",
},
coverage: { total: 2, exhausted: true },
});
});
it.each([
{ addresses: [] },
{ states: [] },
{ indices: [] },
{ addresses: ["0x1100"], indices: [2] },
{ states: ["no-body" as const], addresses: ["0x1100"] },
])(
"intersects native selectors and preserves empty selection: %j",
(selection) => {
const view = projectIl2cppMetadataView(parent(linkedReport(), false), {
kind: "il2cpp-metadata",
collection: "method_addresses",
...selection,
offset: 0,
limit: 64,
});
if (!view.ok) throw view.error;
expect(view.value).toMatchObject({
items: [],
coverage: { total: 0, exhausted: true },
});
},
);
it.each([
{ state: "no-body", address: "0x1100", rva: null },
{ state: "unknown", address: null, rva: "0x100" },
{ state: "code", address: "0x1100", rva: "0x101" },
{ state: "code", address: "0x10000000000000000", rva: "0x100" },
{ state: "code", address: "malformed", rva: "0x100" },
])(
"rejects malformed native facts without throwing during boundary validation: %j",
(row) => {
const value = jsonObjectSchema.parse(linkedReport());
value.method_addresses = [row, ...linkedReport().method_addresses.slice(1)];
const view = projectIl2cppMetadataView(parent(value, false), {
kind: "summary",
});
expect(view.ok).toBe(false);
},
);
it("keeps native summaries bounded while paging selected module facts", () => {
const value = linkedReport(),
source = parent(value, false);
const summary = summarize(source);
expect(summary).toMatchObject({
summary: {
counts: { code_gen_modules: 1 },
binary: { artifact: value.binary!.artifact },
},
});
expect("summary" in summary && summary.summary).not.toHaveProperty(
"binary.code_gen_modules",
);
const page = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "code_gen_modules",
image_indices: [0],
fields: ["image", "address"],
offset: 0,
limit: 1,
});
if (!page.ok) throw page.error;
expect(page.value).toMatchObject({
items: [{ index: 0, image: 0, address: "0x1400" }],
coverage: { total: 1, exhausted: true },
});
});
+232 -19
View File
@@ -10,9 +10,14 @@ import {
il2cppMetadataSectionSchema,
il2cppMetadataParameterSchema,
il2cppMetadataStringLiteralSchema,
il2cppMethodAddressSchema,
il2cppBinaryLinkSchema,
il2cppCodeGenModuleSchema,
il2cppTypeReferenceSchema,
il2cppMetadataObjectSchema,
il2cppMetadataParentSchema,
il2cppMetadataMemberCollections,
il2cppMetadataBinaryCollections,
il2cppMetadataTypeSchema,
} from "../il2cpp/il2cppMetadata.js";
import { err, ok, type Result } from "../result.js";
@@ -38,6 +43,19 @@ const countsSchema = z.strictObject({
events: z.number().int().nonnegative().nullable(),
parameters: z.number().int().nonnegative().nullable(),
string_literals: z.number().int().nonnegative().nullable(),
code_gen_modules: z.number().int().nonnegative().nullable(),
method_addresses: z.number().int().nonnegative().nullable(),
/** method_addresses rows per state; null when native observations were not collected. */
method_address_states: z
.strictObject({
code: z.number().int().nonnegative(),
no_body: z.number().int().nonnegative(),
unknown: z.number().int().nonnegative(),
})
.nullable(),
return_types: z.number().int().nonnegative().nullable(),
field_types: z.number().int().nonnegative().nullable(),
parameter_types: z.number().int().nonnegative().nullable(),
});
export const il2cppMetadataSummarySchema = il2cppMetadataObjectSchema
@@ -51,8 +69,19 @@ export const il2cppMetadataSummarySchema = il2cppMetadataObjectSchema
events: true,
parameters: true,
string_literals: true,
method_addresses: true,
return_types: true,
field_types: true,
parameter_types: true,
binary: true,
})
.extend({ counts: countsSchema });
.extend({
binary: il2cppBinaryLinkSchema
.omit({ code_gen_modules: true })
.nullable()
.optional(),
counts: countsSchema,
});
const indexSchema = z.number().int().nonnegative();
const sectionRowSchema = il2cppMetadataSectionSchema.extend({
@@ -72,6 +101,15 @@ const parameterRowSchema = il2cppMetadataParameterSchema.extend({
const stringLiteralRowSchema = il2cppMetadataStringLiteralSchema.extend({
index: indexSchema,
});
const methodAddressRowSchema = il2cppMethodAddressSchema.extend({
index: indexSchema,
});
const typeReferenceRowSchema = il2cppTypeReferenceSchema.extend({
index: indexSchema,
});
const codeGenModuleRowSchema = il2cppCodeGenModuleSchema.extend({
index: indexSchema,
});
const indexPage = {
kind: z.literal("il2cpp-metadata"),
indices: z.array(indexSchema).optional(),
@@ -128,6 +166,33 @@ export const il2cppMetadataViewRequestSchema = z.discriminatedUnion(
collection: z.literal("string_literals"),
fields: z.array(stringLiteralRowSchema.keyof()).optional(),
}),
z.strictObject({
...indexPage,
collection: z.literal("method_addresses"),
addresses: z.array(z.string().regex(/^0x[0-9a-fA-F]{1,16}$/u)).optional(),
states: z.array(il2cppMethodAddressSchema.shape.state).optional(),
fields: z.array(methodAddressRowSchema.keyof()).optional(),
}),
z.strictObject({
...indexPage,
collection: z.literal("code_gen_modules"),
image_indices: z.array(indexSchema).optional(),
fields: z.array(codeGenModuleRowSchema.keyof()).optional(),
}),
...(["return_types", "field_types"] as const).map((collection) =>
z.strictObject({
...indexPage,
collection: z.literal(collection),
fields: z.array(typeReferenceRowSchema.keyof()).optional(),
}),
),
z.strictObject({
...indexPage,
collection: z.literal("parameter_types"),
// Owners come from the retained parameters collection.
method_indices: z.array(indexSchema).optional(),
fields: z.array(typeReferenceRowSchema.keyof()).optional(),
}),
],
);
@@ -139,16 +204,30 @@ export const il2cppMetadataViewItemsSchema = z.union([
z.array(memberRowSchema.partial().required({ index: true })),
z.array(parameterRowSchema.partial().required({ index: true })),
z.array(stringLiteralRowSchema.partial().required({ index: true })),
z.array(methodAddressRowSchema.partial().required({ index: true })),
z.array(typeReferenceRowSchema.partial().required({ index: true })),
z.array(codeGenModuleRowSchema.partial().required({ index: true })),
]);
export const il2cppMetadataViewContextSchema = z.strictObject({
metadata_version: il2cppMetadataObjectSchema.shape.metadata_version,
counts: countsSchema,
binary_source: il2cppBinaryLinkSchema
.pick({
artifact: true,
format: true,
architecture: true,
image_base: true,
})
.optional(),
});
type Report = z.infer<typeof il2cppMetadataParentSchema>;
type Request = z.infer<typeof il2cppMetadataViewRequestSchema>;
type Row = NonNullable<Report[Request["collection"]]>[number];
type CollectionRows = Omit<Report, "binary"> & {
code_gen_modules: NonNullable<Report["binary"]>["code_gen_modules"];
};
type Row = NonNullable<CollectionRows[Request["collection"]]>[number];
interface CheckedReport {
readonly report: Report;
readonly counts: z.infer<typeof countsSchema>;
@@ -190,6 +269,27 @@ const parseMetadataReport = (
events: report.events?.length ?? null,
parameters: report.parameters?.length ?? null,
string_literals: report.string_literals?.length ?? null,
code_gen_modules: report.binary?.code_gen_modules.length ?? null,
method_addresses:
report.binary == null
? null
: (report.method_addresses?.length ?? null),
method_address_states:
(report.binary == null ? undefined : report.method_addresses)?.reduce(
(states, row) => {
if (row.state === "code") states.code++;
else if (row.state === "no-body") states.no_body++;
else states.unknown++;
return states;
},
{ code: 0, no_body: 0, unknown: 0 },
) ?? null,
return_types:
report.binary == null ? null : (report.return_types?.length ?? null),
field_types:
report.binary == null ? null : (report.field_types?.length ?? null),
parameter_types:
report.binary == null ? null : (report.parameter_types?.length ?? null),
},
};
if (reusable) checkedReports.set(key, checked);
@@ -231,10 +331,27 @@ const selection = (view: Request, report: Report) => {
!("declaring_types" in view) || view.declaring_types === undefined
? undefined
: new Set(view.declaring_types);
const addresses =
view.collection === "method_addresses" && view.addresses !== undefined
? new Set(view.addresses.map((value) => BigInt(value).toString(16)))
: undefined;
const states =
view.collection === "method_addresses" && view.states !== undefined
? new Set<string>(view.states)
: undefined;
const methods =
view.collection === "parameters" && view.method_indices !== undefined
(view.collection === "parameters" ||
view.collection === "parameter_types") &&
view.method_indices !== undefined
? new Set(view.method_indices)
: undefined;
// A parameter type row is owned by the method of the parameter at its index.
const parameterOwner = (row: Row, index: number) =>
"method" in row
? row.method
: view.collection === "parameter_types"
? report.parameters?.[index]?.method
: undefined;
const namespaces =
view.collection !== "types" || view.namespaces === undefined
? undefined
@@ -263,7 +380,38 @@ const selection = (view: Request, report: Report) => {
else ranges.push(range);
}
}
const moduleImages =
view.collection === "code_gen_modules" && view.image_indices !== undefined
? new Set(view.image_indices)
: undefined;
if (
[
moduleImages,
addresses,
states,
names,
owners,
namespaces,
methods,
imageIndices,
].every((filter) => filter === undefined)
)
return undefined;
return (row: Row, index: number) => {
if (
moduleImages !== undefined &&
(!("image" in row) || !moduleImages.has(row.image))
)
return false;
if (
addresses !== undefined &&
(!("address" in row) ||
row.address === null ||
!addresses.has(BigInt(row.address).toString(16)))
)
return false;
if (states !== undefined && (!("state" in row) || !states.has(row.state)))
return false;
if (
names !== undefined &&
(!("name" in row) || row.name === null || !names.has(row.name))
@@ -279,10 +427,7 @@ const selection = (view: Request, report: Report) => {
(!("namespace" in row) || !namespaces.has(row.namespace))
)
return false;
if (
methods !== undefined &&
(!("method" in row) || !methods.has(row.method))
)
if (methods !== undefined && !methods.has(parameterOwner(row, index) ?? -1))
return false;
if (imageIndices === undefined) return true;
let lower = 0;
@@ -309,7 +454,7 @@ export const projectIl2cppMetadataView = (
path: ["view"],
reason: "invalid_value",
message:
"IL2CPP metadata Evidence supports summary and selected sections, images, types, methods, member, parameter and string literal pages.",
"IL2CPP metadata Evidence supports summary and selected sections, images, types, methods, member, parameter, string literal, method address and type reference pages.",
},
]),
);
@@ -342,9 +487,14 @@ export const projectIl2cppMetadataView = (
events,
parameters,
string_literals,
method_addresses,
return_types,
field_types,
parameter_types,
...identity
} = report;
const allRows = {
code_gen_modules: report.binary?.code_gen_modules,
sections,
images,
types,
@@ -354,6 +504,10 @@ export const projectIl2cppMetadataView = (
events,
parameters,
string_literals,
method_addresses,
return_types,
field_types,
parameter_types,
};
const shared = {
parent_evidence_id: parent.evidenceId,
@@ -362,13 +516,23 @@ export const projectIl2cppMetadataView = (
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
limitations: [...parent.limitations],
unknowns: [
...il2cppMetadataMemberCollections
...(report.binary != null && report.binary.code_gen_modules_field === null
? [
"Module-array association is inferred from executable byte patterns; instruction boundaries and runtime use remain unverified.",
]
: []),
...[
...il2cppMetadataMemberCollections,
...il2cppMetadataBinaryCollections,
]
.filter((name) => report[name] === undefined)
.map(
(name) =>
`Retained Evidence did not collect ${name}; its count is unknown. Reinspect the metadata to acquire these facts.`,
),
"Names come from unencrypted metadata only; native code addresses and runtime registration remain unknown.",
report.binary == null
? "Names come from unencrypted metadata only; native code addresses and runtime registration remain unknown."
: "Addresses come from static binary tables; runtime behavior and generic instance code remain unknown.",
...(parent.subject == null
? [
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
@@ -376,15 +540,32 @@ export const projectIl2cppMetadataView = (
: []),
],
};
if (view.kind === "summary")
if (view.kind === "summary") {
const binary =
report.binary == null
? report.binary
: (({ code_gen_modules: _modules, ...facts }) => facts)(report.binary);
return ok({
kind: "summary",
view,
...shared,
summary: { ...identity, counts },
summary: {
...identity,
...(binary === undefined ? {} : { binary }),
counts,
},
coverage: completeWithinViewCoverage(1, 1),
});
const rows = allRows[view.collection];
}
const nativeUnavailable =
report.binary == null &&
[
"method_addresses",
"return_types",
"field_types",
"parameter_types",
].includes(view.collection);
const rows = nativeUnavailable ? undefined : allRows[view.collection];
if (rows === undefined)
return err(
analysisViewInputError([
@@ -395,17 +576,39 @@ export const projectIl2cppMetadataView = (
},
]),
);
if (
view.collection === "parameter_types" &&
view.method_indices !== undefined &&
report.parameters === undefined
)
return err(
analysisViewInputError([
{
path: ["view", "method_indices"],
reason: "invalid_value",
message:
"Retained Evidence did not collect parameters, so parameter owners are unknown; inspect the metadata again to select parameter types by method.",
},
]),
);
const matches = selection(view, report);
const selectedFields =
view.fields === undefined ? undefined : new Set<string>(view.fields);
const items = [];
let total = 0;
for (const index of rowIndices(rows.length, view.indices)) {
const row = rows[index]!;
if (!matches(row, index)) continue;
if (total >= view.offset && items.length < view.limit)
items.push(selectFields(row, index, selectedFields));
total++;
if (matches === undefined && view.indices === undefined) {
total = rows.length;
const end = Math.min(rows.length, view.offset + view.limit);
for (let index = Math.min(view.offset, rows.length); index < end; index++)
items.push(selectFields(rows[index]!, index, selectedFields));
} else {
for (const index of rowIndices(rows.length, view.indices)) {
const row = rows[index]!;
if (matches !== undefined && !matches(row, index)) continue;
if (total >= view.offset && items.length < view.limit)
items.push(selectFields(row, index, selectedFields));
total++;
}
}
return ok({
kind: "il2cpp-metadata",
@@ -413,6 +616,16 @@ export const projectIl2cppMetadataView = (
...shared,
items,
metadata_version: report.metadata_version,
...(report.binary == null
? {}
: {
binary_source: {
artifact: report.binary.artifact,
format: report.binary.format,
architecture: report.binary.architecture,
image_base: report.binary.image_base,
},
}),
counts,
coverage: pageViewCoverage(view.offset, items.length, total),
});
@@ -0,0 +1,42 @@
import {
IL2CPP_METADATA_SECTION_NAMES,
type Il2cppMetadata,
} from "./il2cppMetadata.js";
/** Fresh empty facts for domain and application boundary tests. */
export const createEmptyIl2cppMetadataFixture = (): Il2cppMetadata => ({
artifact: {
path: "/artifacts/global-metadata.dat",
sha256: "a".repeat(64),
bytes: 1024,
},
magic: 0xfab11baf,
metadata_version: 39,
index_widths: {
type_definition: 1,
generic_container: 1,
parameter: 1,
type: 1,
type_width_source: "interface-offsets-record",
},
sections: IL2CPP_METADATA_SECTION_NAMES.map((name) => ({
name,
offset: 380,
size: 0,
count: 0,
})),
images: [],
types: [],
methods: [],
fields: [],
properties: [],
events: [],
binary: null,
method_addresses: [],
return_types: [],
field_types: [],
parameter_types: [],
parameters: [],
string_literals: [],
limitations: [],
});
+161 -1
View File
@@ -95,6 +95,17 @@ export const inspectIl2cppMetadataInputSchema = z.strictObject({
.describe(
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
),
binary_path: localPathStringSchema
.optional()
.describe(
"Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed",
),
binary_architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe(
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise",
),
});
export type IL2cppMetadataSectionName = z.infer<
@@ -166,6 +177,68 @@ export const il2cppMetadataStringLiteralSchema = z.strictObject({
value_base64: canonicalBase64Schema.nullable(),
});
/** Hexadecimal virtual address or offset, such as 0x153584. */
const addressPattern = /^0x[0-9a-f]{1,16}$/u;
export const il2cppAddressSchema = z.string().regex(addressPattern);
/**
* How the binary answered for one method: `code` has an address, `no-body` is a
* module slot that records no code (abstract, extern or generic methods), and
* `unknown` means no code-generation module claims the method, so the binary
* was not asked, or a relocation cannot resolve its slot; code remains unknown.
*/
export const IL2CPP_METHOD_ADDRESS_STATES = [
"code",
"no-body",
"unknown",
] as const;
/** Native code of one method: virtual address and image-base-relative RVA. */
export const il2cppMethodAddressSchema = z.strictObject({
state: z.enum(IL2CPP_METHOD_ADDRESS_STATES),
/** Null unless state is code. */
address: il2cppAddressSchema.nullable(),
rva: il2cppAddressSchema.nullable(),
});
/** Index into the binary Il2CppType table and its C#-like display name. */
export const il2cppTypeReferenceSchema = z.strictObject({
type: optionalIndex,
/** Null for type index -1, and for every row of a version 27 report. */
name: z.string().nullable(),
});
/** Code-generation module record located for one metadata image. */
export const il2cppCodeGenModuleSchema = z.strictObject({
image: count,
address: il2cppAddressSchema,
method_pointer_count: count,
});
/** Identity and registration facts of the linked native binary. */
export const il2cppBinaryLinkSchema = z.strictObject({
artifact: z.strictObject({
path: localPathStringSchema,
sha256: digestSchema,
bytes: count,
}),
format: z.enum(["elf", "macho", "pe"]),
architecture: z.string(),
image_base: il2cppAddressSchema,
/**
* CodeRegistration's codeGenModulesCount field, followed by the module array
* pointer; null when no static pair was resolved and executable byte-pattern
* candidates identified the array without verified instruction boundaries.
*/
code_gen_modules_field: il2cppAddressSchema.nullable(),
/** The code-generation module pointer array. */
code_gen_modules_array: il2cppAddressSchema,
code_gen_modules: z.array(il2cppCodeGenModuleSchema),
/** MetadataRegistration's typesCount field, followed by the types pointer. */
types_field: il2cppAddressSchema,
types_count: count,
});
/** Unrefined report fields, shared with projections that omit row collections. */
export const il2cppMetadataObjectSchema = z.strictObject({
artifact: z.strictObject({
@@ -197,6 +270,14 @@ export const il2cppMetadataObjectSchema = z.strictObject({
events: z.array(il2cppMetadataMemberSchema),
parameters: z.array(il2cppMetadataParameterSchema),
string_literals: z.array(il2cppMetadataStringLiteralSchema),
/** Null unless binary_path was selected. */
binary: il2cppBinaryLinkSchema.nullable(),
/** One row per method index when binary is linked, otherwise empty. */
method_addresses: z.array(il2cppMethodAddressSchema),
/** Per method, field and parameter index when binary is linked, otherwise empty. */
return_types: z.array(il2cppTypeReferenceSchema),
field_types: z.array(il2cppTypeReferenceSchema),
parameter_types: z.array(il2cppTypeReferenceSchema),
limitations: z.array(z.string()),
});
@@ -209,13 +290,26 @@ export const il2cppMetadataMemberCollections = [
"string_literals",
] as const;
/** Historical reports retain omission instead of inventing empty member inventories. */
/** Collections a report linked to a native binary adds; absent before linking existed. */
export const il2cppMetadataBinaryCollections = [
"method_addresses",
"return_types",
"field_types",
"parameter_types",
] as const;
/** Historical reports retain omission instead of inventing empty member or binary inventories. */
const il2cppMetadataParentObjectSchema = il2cppMetadataObjectSchema.partial({
fields: true,
properties: true,
events: true,
parameters: true,
string_literals: true,
binary: true,
method_addresses: true,
return_types: true,
field_types: true,
parameter_types: true,
});
const validateMetadataFacts = (
@@ -243,6 +337,44 @@ const validateMetadataFacts = (
message: "Section range is outside the selected artifact.",
});
});
if (report.binary != null)
for (const collection of [
"return_types",
"field_types",
"parameter_types",
] as const)
report[collection]?.forEach((row, index) => {
if (
row.type >= report.binary!.types_count ||
(row.type === -1 && row.name !== null)
)
context.addIssue({
code: "custom",
path: [collection, index],
message:
"Type reference is outside the linked type table or names an unavailable type.",
});
});
report.method_addresses?.forEach((row, index) => {
if (
row.state === "code"
? row.address === null ||
row.rva === null ||
report.binary == null ||
(addressPattern.test(row.address) &&
addressPattern.test(row.rva) &&
addressPattern.test(report.binary.image_base) &&
BigInt(row.address) !==
BigInt(report.binary.image_base) + BigInt(row.rva))
: row.address !== null || row.rva !== null
)
context.addIssue({
code: "custom",
path: ["method_addresses", index],
message:
"Only a code row carries an address and RVA; both are then present.",
});
});
for (const collection of ["fields", "properties", "events"] as const)
report[collection]?.forEach((row, index) => {
if (row.declaring_type >= report.types.length)
@@ -260,6 +392,32 @@ const validateMetadataFacts = (
message: "Parameter owner is outside the retained method definitions.",
});
});
const linkedRows = {
method_addresses: report.methods,
return_types: report.methods,
field_types: report.fields,
parameter_types: report.parameters,
};
for (const collection of il2cppMetadataBinaryCollections) {
const rows = report[collection];
if ((report.binary === undefined) !== (rows === undefined)) {
context.addIssue({
code: "custom",
path: [collection],
message: `${collection} is retained exactly when the binary link field is.`,
});
continue;
}
const owners = linkedRows[collection];
if (rows === undefined) continue;
const expected = report.binary === null ? 0 : owners?.length;
if (expected === undefined || rows.length !== expected)
context.addIssue({
code: "custom",
path: [collection],
message: `A linked binary needs one ${collection} row per metadata row; none without one.`,
});
}
report.string_literals?.forEach((row, index) => {
if ((row.value === null) === (row.value_base64 === null))
context.addIssue({
@@ -286,3 +444,5 @@ export type Il2cppMetadataProviderInput = Omit<
>;
export type Il2cppMetadataSection = z.infer<typeof il2cppMetadataSectionSchema>;
export type Il2cppMetadata = z.infer<typeof il2cppMetadataSchema>;
export type Il2cppMethodAddress = z.infer<typeof il2cppMethodAddressSchema>;
@@ -87,9 +87,16 @@ const statementPosition = (
parents: WeakMap<t.Node, t.Node>,
declarator?: t.VariableDeclarator,
): StatementPosition | null => {
const parent = parents.get(statement);
const directParent = parents.get(statement);
const enclosing =
(t.isExportNamedDeclaration(directParent) ||
t.isExportDefaultDeclaration(directParent)) &&
directParent.declaration === statement
? directParent
: statement;
const parent = parents.get(enclosing);
if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null;
const index = parent.body.indexOf(statement);
const index = parent.body.indexOf(enclosing);
return index < 0
? null
: {
@@ -378,16 +385,8 @@ const computeDirectInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
if (t.isClassDeclaration(node) && node.id !== null) {
const parent = parents.get(node);
const statement =
(t.isExportNamedDeclaration(parent) ||
t.isExportDefaultDeclaration(parent)) &&
parent.declaration === node
? parent
: node;
return statementPosition(statement, parents);
}
if (t.isClassDeclaration(node) && node.id !== null)
return statementPosition(node, parents);
if (
(t.isAssignmentExpression(node) &&
t.isIdentifier(unwrapJavaScriptExpression(node.left).node)) ||
+3 -3
View File
@@ -19,7 +19,7 @@ import { err, ok, type Result } from "../domain/result.js";
export const GHIDRA_SEED_FORMAT = "rea-ghidra-seeds-v1";
export const MAX_GHIDRA_SEED_BYTES = 32 * 1024 * 1024;
export const MAX_GHIDRA_SEED_ENTRIES = 1_000_000;
const MAX_SEED_NAME_LENGTH = 2000;
export const MAX_GHIDRA_SEED_NAME_LENGTH = 2000;
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
const count = z.number().int().nonnegative();
@@ -92,13 +92,13 @@ export const parseGhidraSeeds = (
if (
name !== undefined &&
(name.length === 0 ||
name.length > MAX_SEED_NAME_LENGTH ||
name.length > MAX_GHIDRA_SEED_NAME_LENGTH ||
/[\s\0]/u.test(name))
)
return err(
seedLineError(
number,
`name must be 1..${MAX_SEED_NAME_LENGTH} characters without whitespace`,
`name must be 1..${MAX_GHIDRA_SEED_NAME_LENGTH} characters without whitespace`,
),
);
if (kind === "label" && name === undefined)
+6
View File
@@ -50,7 +50,13 @@ export interface GoBinaryContainer {
readonly architecture: string;
readonly bits: 32 | 64;
readonly byte_order: "little" | "big";
/** Declared preferred base when the container supplies one (PE). */
readonly image_base?: bigint;
/** Mach-O load-command observation; omitted for other formats. */
readonly macho_chained_fixups?: boolean;
readonly mappings: readonly GoFileMapping[];
/** File-backed ranges the native loader marks executable; omitted if unobserved. */
readonly executable_mappings?: readonly GoFileMapping[];
readonly zero_fills: readonly GoZeroFillMapping[];
readonly sections: readonly GoImageSection[];
readonly search: readonly GoFileMapping[];
+6 -1
View File
@@ -101,6 +101,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
"ELF program headers",
);
const mappings: GoFileMapping[] = [];
const executableMappings: GoFileMapping[] = [];
const zeroFills: GoZeroFillMapping[] = [];
const sections: GoImageSection[] = [];
const fallback: GoFileMapping[] = [];
@@ -123,7 +124,10 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
: reader.fileRange(fileOffset, size, "ELF load segment")),
address,
};
if (mapping.size !== 0) mappings.push(mapping);
if (mapping.size !== 0) {
mappings.push(mapping);
if ((flags & 1) !== 0) executableMappings.push(mapping);
}
if (memorySize > size)
zeroFills.push({ address: address + size, size: memorySize - size });
if (mapping.size !== 0 && (flags & 3) === 2) fallback.push(mapping);
@@ -223,6 +227,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
bits,
byte_order: reader.little ? "little" : "big",
mappings,
executable_mappings: executableMappings,
zero_fills: zeroFills,
sections,
search: search === null ? fallback : [search],
+8 -1
View File
@@ -32,11 +32,13 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
"Mach-O command count exceeds its load-command range",
);
const mappings: GoFileMapping[] = [];
const executableMappings: GoFileMapping[] = [];
const zeroFills: GoZeroFillMapping[] = [];
const sections: GoImageSection[] = [];
const named: GoFileMapping[] = [];
let fallback: GoFileMapping | null = null;
let offset = commandStart;
let chainedFixups = false;
const end = commandStart + commandBytes;
for (let index = 0; index < count; index++) {
const command = reader.u32(offset);
@@ -50,6 +52,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
"malformed",
"Mach-O load command size is invalid or truncated",
);
if (command === 0x80000034) chainedFixups = true;
if (command === 1 || command === 0x19) {
const wide = command === 0x19;
if (wide !== (bits === 64))
@@ -96,8 +99,10 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
const name = fixedName(
reader.range(offset + 8, 16, "Mach-O segment name"),
);
if (mapping !== undefined && name !== "__PAGEZERO")
if (mapping !== undefined && name !== "__PAGEZERO") {
mappings.push(mapping);
if ((protection & 4) !== 0) executableMappings.push(mapping);
}
if (memorySize > fileSize && name !== "__PAGEZERO")
zeroFills.push({
address: address + fileSize,
@@ -192,10 +197,12 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
]);
return {
format: "macho",
macho_chained_fixups: chainedFixups,
architecture: names.get(cpu) ?? `macho-cpu-${cpu}`,
bits,
byte_order: little ? "little" : "big",
mappings,
executable_mappings: executableMappings,
zero_fills: zeroFills,
sections,
search: named.length > 0 ? named : fallback === null ? [] : [fallback],
+8 -1
View File
@@ -87,6 +87,7 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
"PE sections",
);
const mappings: GoFileMapping[] = [];
const executableMappings: GoFileMapping[] = [];
const zeroFills: GoZeroFillMapping[] = [];
const sections: GoImageSection[] = [];
let search: GoFileMapping | null = null;
@@ -124,7 +125,11 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
: reader.fileRange(BigInt(offset), BigInt(size), "PE section")),
address,
};
if (size !== 0) mappings.push(mapping);
if (size !== 0) {
mappings.push(mapping);
if ((characteristics & 0x20000000) !== 0)
executableMappings.push({ ...mapping, size: contentSize });
}
if (virtualSize > size)
zeroFills.push({
address: address + BigInt(size),
@@ -151,10 +156,12 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
]);
return {
format: "pe",
image_base: imageBase,
architecture: names.get(machine) ?? `pe-machine-${machine}`,
bits,
byte_order: "little",
mappings,
executable_mappings: executableMappings,
zero_fills: zeroFills,
sections,
search: search === null ? [] : [search],
+221
View File
@@ -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;
}
+217
View File
@@ -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;
}
};
+49
View File
@@ -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";
}
}
+319
View File
@@ -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;
}
};
+339
View File
@@ -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]!;
}
+175
View File
@@ -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;
};
+162
View File
@@ -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;
}
}
}
+199
View File
@@ -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,
};
};
+9
View File
@@ -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) {
+122 -29
View File
@@ -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));
+13
View File
@@ -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);
};
+17
View File
@@ -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);
}
+290 -49
View File
@@ -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;
}
}
}
@@ -217,9 +217,6 @@ cliTest(
issues: [
{
path: ["executable_path"],
message: expect.stringContaining(
"must be absolute local filesystem paths",
),
},
],
},
+317 -27
View File
@@ -26,11 +26,29 @@ import {
FIXTURE_METHODS,
FIXTURE_TYPES,
} from "../fixtures/il2cpp/metadata.js";
import {
createElfFixture,
createIl2cppBinaryFixture,
createUniversalFixture,
FIXTURE_BINARY_BASE,
FIXTURE_METHOD_ADDRESSES,
FIXTURE_RETURN_TYPE_NAME,
} from "../fixtures/il2cpp/binary.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
const parseCliMetadata = (value: unknown) =>
il2cppMetadataSchema.parse(
toolContract("inspect_il2cpp_metadata").outputSchema.parse(value)
.normalized_result,
);
const parseMetadataSummary = (value: unknown) =>
il2cppMetadataSummaryViewResultSchema.parse(
parseEvidence(value).normalized_result,
);
cliTest(
"returns identical complete inventories through CLI and MCP and pages a retained summary after removal",
async ({ cli }) => {
@@ -74,18 +92,14 @@ cliTest(
const omitted = await call("inspect_il2cpp_metadata", { path });
expect(omitted.isError, JSON.stringify(omitted)).not.toBe(true);
expect(
il2cppMetadataSummaryViewResultSchema.parse(
parseEvidence(omitted.structuredContent).normalized_result,
).summary.counts.methods,
parseMetadataSummary(omitted.structuredContent).summary.counts.methods,
).toBe(FIXTURE_METHODS.length);
const summarized = await call("inspect_il2cpp_metadata", {
path,
detail: "summary",
});
expect(summarized.isError, JSON.stringify(summarized)).not.toBe(true);
const view = il2cppMetadataSummaryViewResultSchema.parse(
parseEvidence(summarized.structuredContent).normalized_result,
);
const view = parseMetadataSummary(summarized.structuredContent);
expect(view.summary.magic).toBe(report.magic);
expect(view.summary.counts).toEqual({
sections: 31,
@@ -98,8 +112,15 @@ cliTest(
events: 2,
parameters: 1,
string_literals: 2,
code_gen_modules: null,
method_addresses: null,
method_address_states: null,
return_types: null,
field_types: null,
parameter_types: null,
});
expect(view.summary).not.toHaveProperty("sections");
expect(view.summary.binary).toBeNull();
expect(view.summary.index_widths).toEqual(report.index_widths);
// Pages come from retained Evidence, not the file.
@@ -200,11 +221,70 @@ cliTest(
30000,
);
const metadataPageReader =
(
call: Awaited<ReturnType<typeof connectLocalToolsMcp>>["call"],
context: { parentId: string; sha256: string },
) =>
async (view: Record<string, unknown>) => {
const result = await call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: context.parentId },
view: { kind: "il2cpp-metadata", ...view },
});
expect(result.isError).not.toBe(true);
const evidence = parseEvidence(result.structuredContent);
expect(evidence.evidence_links).toContain(context.parentId);
const payload = analysisViewResultSchema.parse(evidence.normalized_result);
if (payload.kind !== "il2cpp-metadata")
throw new Error("Wrong collection response");
expect(payload.artifact.sha256).toBe(context.sha256);
return payload;
};
const assertRetainedNativeSelections = async (
selected: ReturnType<typeof metadataPageReader>,
binaryPath: string,
binaryBytes: Buffer,
) => {
expect(
await selected({
collection: "code_gen_modules",
image_indices: [0],
fields: ["image"],
limit: 1,
}),
).toMatchObject({
items: [{ index: 0, image: 0 }],
coverage: { total: 1, exhausted: true },
});
const native = await selected({
collection: "method_addresses",
addresses: ["0x0000000100000200", "0x100000220"],
states: ["code"],
fields: ["rva"],
offset: 1,
limit: 1,
});
expect(native).toMatchObject({
items: [{ index: 2, rva: "0x220" }],
binary_source: {
artifact: {
path: binaryPath,
sha256: createHash("sha256").update(binaryBytes).digest("hex"),
},
},
coverage: { total: 2, exhausted: true },
});
};
it("queries compact retained metadata through the real SDK with exact row identities and fields", async () => {
const root = await createTestTempDirectory("rea-il2cpp-sdk-selection-");
const path = join(root, "global-metadata.dat");
const fixture = createIl2cppMetadataFixture({ version: 31 });
await writeFile(path, fixture.bytes);
const binaryPath = join(root, "GameAssembly.dylib");
const binaryBytes = createIl2cppBinaryFixture();
await writeFile(binaryPath, binaryBytes);
const { client, call } = await connectLocalToolsMcp();
const directory = await call("inspect_desktop_application", { path: root });
const directoryView = analysisViewResultSchema.parse(
@@ -220,11 +300,12 @@ it("queries compact retained metadata through the real SDK with exact row identi
if (probe === undefined)
throw new Error("Missing IL2CPP metadata next operation");
expect(probe.path).toBe(await realpath(path));
const response = await call(probe.operation, { path: probe.path });
const response = await call(probe.operation, {
path: probe.path,
binary_path: binaryPath,
});
expect(response.isError).not.toBe(true);
const overview = il2cppMetadataSummaryViewResultSchema.parse(
parseEvidence(response.structuredContent).normalized_result,
);
const overview = parseMetadataSummary(response.structuredContent);
expect(overview.summary.counts).toMatchObject({
sections: 31,
images: 2,
@@ -234,27 +315,15 @@ it("queries compact retained metadata through the real SDK with exact row identi
});
expect(overview.summary).not.toHaveProperty("sections");
expect(overview.summary).not.toHaveProperty("methods");
await unlink(path);
await Promise.all([unlink(path), unlink(binaryPath)]);
const source = {
kind: "retained-evidence",
evidence_id: overview.parent_evidence_id,
};
const selected = async (view: Record<string, unknown>) => {
const result = await call("inspect_analysis_view", {
source,
view: { kind: "il2cpp-metadata", ...view },
});
expect(result.isError).not.toBe(true);
const evidence = parseEvidence(result.structuredContent);
expect(evidence.evidence_links).toContain(overview.parent_evidence_id);
const payload = analysisViewResultSchema.parse(evidence.normalized_result);
if (payload.kind !== "il2cpp-metadata")
throw new Error("Wrong collection response");
expect(payload.artifact.sha256).toBe(
createHash("sha256").update(fixture.bytes).digest("hex"),
);
return payload;
};
const selected = metadataPageReader(call, {
parentId: overview.parent_evidence_id,
sha256: createHash("sha256").update(fixture.bytes).digest("hex"),
});
expect(
(
await selected({
@@ -326,6 +395,7 @@ it("queries compact retained metadata through the real SDK with exact row identi
items: [],
coverage: { total: 0, exhausted: true },
});
await assertRetainedNativeSelections(selected, binaryPath, binaryBytes);
const invalid = await call("inspect_analysis_view", {
source,
view: {
@@ -536,3 +606,223 @@ cliTest.skipIf(process.platform === "win32")(
},
30000,
);
cliTest(
"links method addresses from a selected binary slice through CLI and MCP pages",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-il2cpp-binary-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "GameAssembly.dylib");
await writeFile(path, createIl2cppMetadataFixture().bytes);
await writeFile(
binaryPath,
createUniversalFixture([
{ cpu: 0x0100000c, bytes: createIl2cppBinaryFixture() },
]),
);
const expected = FIXTURE_METHOD_ADDRESSES.map((address) =>
typeof address !== "bigint"
? { state: address, address: null, rva: null }
: {
state: "code",
address: `0x${address.toString(16)}`,
rva: `0x${(address - FIXTURE_BINARY_BASE).toString(16)}`,
},
);
const result = await cli.run({
arguments: [
"inspect-il2cpp-metadata",
path,
"--binary",
binaryPath,
"--architecture",
"arm64",
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
const report = parseCliMetadata(result.json);
expect(report.method_addresses).toEqual(expected);
expect(report.return_types.map((row) => row.name)).toEqual(
Array.from({ length: 5 }, () => FIXTURE_RETURN_TYPE_NAME),
);
expect(report.binary).toMatchObject({
format: "macho",
architecture: "arm64",
image_base: "0x100000000",
});
const { client, call } = await connectLocalToolsMcp();
const missing = await call("inspect_il2cpp_metadata", {
path,
binary_path: binaryPath,
});
expect(parseMcpToolError(missing).error).toMatchObject({
code: "unsupported_target",
message: expect.stringContaining("slice-selection-required"),
});
const summarized = await call("inspect_il2cpp_metadata", {
path,
binary_path: binaryPath,
binary_architecture: "arm64",
detail: "summary",
});
expect(summarized.isError, JSON.stringify(summarized)).not.toBe(true);
const view = parseMetadataSummary(summarized.structuredContent);
expect(view.summary.counts.method_addresses).toBe(5);
expect(view.summary.counts.method_address_states).toEqual({
code: 4,
no_body: 1,
unknown: 0,
});
const page = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: view.parent_evidence_id,
},
view: {
kind: "il2cpp-metadata",
collection: "method_addresses",
offset: 2,
},
});
const projected = analysisViewResultSchema.parse(
parseEvidence(page.structuredContent).normalized_result,
);
if (projected.kind !== "il2cpp-metadata")
throw new Error("Wrong IL2CPP page kind");
expect(projected.items).toEqual(
expected.slice(2).map((row, offset) => ({ index: offset + 2, ...row })),
);
await client.ping();
},
30000,
);
cliTest(
"marks a module array found through an x86-64 instruction as derived",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-il2cpp-inlined-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "GameAssembly.so");
await writeFile(path, createIl2cppMetadataFixture().bytes);
await writeFile(
binaryPath,
createElfFixture(
createIl2cppBinaryFixture({
codeRegistration: false,
codeReference: true,
}),
{ machine: 62 },
),
);
const result = await cli.run({
arguments: [
"inspect-il2cpp-metadata",
path,
"--binary",
binaryPath,
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
const report = parseCliMetadata(result.json);
expect(report.binary?.code_gen_modules_field).toBeNull();
expect(
report.limitations.some((line) =>
line.includes("static CodeRegistration pair was not resolved"),
),
).toBe(true);
},
30000,
);
cliTest(
"writes Ghidra function seeds for a linked ELF and refuses to overwrite them",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-il2cpp-seeds-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "libil2cpp.so");
const seeds = join(root, "seeds.tsv");
const shifted = join(root, "shifted.tsv");
await writeFile(path, createIl2cppMetadataFixture().bytes);
await writeFile(binaryPath, createElfFixture(createIl2cppBinaryFixture()));
const run = (output: string, extra: readonly string[] = []) =>
cli.run({
arguments: [
"inspect-il2cpp-metadata",
path,
"--binary",
binaryPath,
"--ghidra-seeds",
output,
...extra,
"--json",
],
});
const linked = await run(seeds);
expect(linked.exitCode, linked.stderr).toBe(0);
const linkedFacts = il2cppMetadataSchema.parse(
parseEvidence(linked.json).normalized_result,
);
// This ELF is linked at the fixture base, so Ghidra keeps that base.
expect(await readFile(seeds, "utf8")).toBe(
[
"0x100000200\tfunction\tGame.Player$$.ctor",
"0x100000210\tfunction\tGame.Player$$Update",
"0x100000220\tfunction\tGame.Player.Slot$$Add",
"0x100000230\tfunction\tSystem.Größe$$.ctor",
`# metadata_sha256 ${linkedFacts.artifact.sha256}`,
`# binary_sha256 ${linkedFacts.binary!.artifact.sha256}`,
"# link_base 0x100000000",
"# seed_base 0x100000000",
"",
].join("\n"),
);
const moved = await run(shifted, ["--seed-base", "0x100000"]);
expect(moved.exitCode, moved.stderr).toBe(0);
expect((await readFile(shifted, "utf8")).split("\n")[0]).toBe(
"0x100200\tfunction\tGame.Player$$.ctor",
);
await writeFile(seeds, "kept\n");
const again = await run(seeds);
expect(again.exitCode).not.toBe(0);
expect(again.json).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["ghidra_seeds"],
message: expect.stringContaining("already exists"),
},
],
},
});
expect(await readFile(seeds, "utf8")).toBe("kept\n");
const unlinked = await cli.run({
arguments: [
"inspect-il2cpp-metadata",
path,
"--ghidra-seeds",
join(root, "unlinked.tsv"),
"--json",
],
});
expect(unlinked.exitCode).not.toBe(0);
expect(unlinked.json).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["ghidra_seeds"],
message: expect.stringContaining("needs --binary"),
},
],
},
});
},
);
@@ -134,12 +134,13 @@ describe("evidence bundle export cancellation", () => {
const path = join(root, "bundle.json");
if (overwrite) await writeFile(path, "original");
const controller = new AbortController();
const cancellationReason = new Error("cancel staged publication");
let closed = false;
let continued = false;
function* parts() {
try {
yield "staged partial data";
controller.abort();
controller.abort(cancellationReason);
yield "cancelled data";
continued = true;
yield "unreachable";
@@ -157,6 +158,7 @@ describe("evidence bundle export cancellation", () => {
error: {
_tag: "AnalysisCancelledError",
operation: "export_evidence_bundle",
cause: cancellationReason,
},
});
expect(closed).toBe(true);
@@ -0,0 +1,499 @@
import { Il2cppBinaryFormatFailure } from "../../../src/il2cpp/Il2cppBinaryFormat.js";
import { expect, it } from "vitest";
import {
linkIl2cppBinary,
linkIl2cppBinarySteps,
} from "../../../src/il2cpp/Il2cppBinaryFile.js";
import { readIl2cppMetadataForLinking } from "../../../src/il2cpp/Il2cppMetadataFile.js";
import {
createElfFixture,
createIl2cppBinaryFixture,
createUniversalFixture,
createSplitPointerPeFixture,
FIXTURE_BINARY_BASE,
FIXTURE_METHOD_ADDRESSES,
FIXTURE_RETURN_TYPE_NAME,
type Il2cppBinaryFixtureOptions,
} from "../../fixtures/il2cpp/binary.js";
import { createIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
const { metadata, link: facts } = readIl2cppMetadataForLinking(
createIl2cppMetadataFixture().bytes,
);
const expectedAddress = (value: bigint | "no-body" | "unknown") =>
typeof value === "bigint"
? {
state: "code",
address: `0x${value.toString(16)}`,
rva: `0x${(value - FIXTURE_BINARY_BASE).toString(16)}`,
}
: { state: value, address: null, rva: null };
const expectedAddresses = FIXTURE_METHOD_ADDRESSES.map(expectedAddress);
const link = (bytes: Buffer, architecture?: string) =>
linkIl2cppBinary(bytes, metadata, facts, architecture);
const failure = (bytes: Buffer, architecture?: string) => {
try {
link(bytes, architecture);
} catch (cause) {
if (cause instanceof Il2cppBinaryFormatFailure)
return { reason: cause.reason, kind: cause.kind };
throw cause;
}
throw new Error("expected a link failure");
};
it("links methods to code through module tokens, not metadata order", () => {
const linked = link(createIl2cppBinaryFixture());
expect(linked).toMatchObject({
format: "macho",
architecture: "arm64",
image_base: FIXTURE_BINARY_BASE,
code_gen_modules_field: FIXTURE_BINARY_BASE + 0x600n,
code_gen_modules_array: FIXTURE_BINARY_BASE + 0x500n,
code_gen_modules: [
{
image: 0,
address: FIXTURE_BINARY_BASE + 0x400n,
method_pointer_count: 3,
},
{
image: 1,
address: FIXTURE_BINARY_BASE + 0x440n,
method_pointer_count: 2,
},
],
});
expect(linked.method_addresses).toEqual(expectedAddresses);
expect(linked).toMatchObject({
types_field: FIXTURE_BINARY_BASE + 0x830n,
types_count: 4,
});
expect(linked.return_types).toEqual(
Array.from({ length: 5 }, () => ({
type: 0,
name: FIXTURE_RETURN_TYPE_NAME,
})),
);
});
it("requires an explicit slice of a universal Mach-O and reads only that slice", () => {
const thin = createIl2cppBinaryFixture();
const universal = createUniversalFixture([
{ cpu: 0x01000007, bytes: Buffer.alloc(0x100) },
{ cpu: 0x0100000c, bytes: thin },
]);
expect(failure(universal)).toEqual({
reason: "slice-selection-required",
kind: "unsupported",
});
expect(link(universal, "arm64").method_addresses).toEqual(expectedAddresses);
expect(failure(universal, "ppc64").reason).toBe("slice-not-found");
expect(failure(thin, "arm64").reason).toBe("slice-not-found");
});
it.each<
[string, Il2cppBinaryFixtureOptions, string, "unsupported" | "malformed"]
>([
[
"a method count mismatch",
{ firstModuleCount: 4 },
"code-gen-modules-not-found",
"malformed",
],
[
"two matching module records",
{ duplicateModule: true },
"ambiguous-code-gen-module",
"malformed",
],
[
"no CodeRegistration module table",
{ codeRegistration: false },
"code-registration-not-found",
"malformed",
],
[
"no MetadataRegistration type table",
{ metadataRegistration: false },
"metadata-registration-not-found",
"malformed",
],
[
"chained fixups",
{ chainedFixups: true },
"unsupported-pointer-encoding",
"unsupported",
],
])("rejects %s without partial addresses", (_, options, reason, kind) => {
expect(failure(createIl2cppBinaryFixture(options))).toEqual({ reason, kind });
});
it("reports type indices without names for metadata version 27", () => {
const linked = linkIl2cppBinary(
createIl2cppBinaryFixture(),
{ ...metadata, metadata_version: 27 },
facts,
);
const rows = [
...linked.return_types,
...linked.field_types,
...linked.parameter_types,
];
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row) => row.name === null)).toBe(true);
expect(link(createIl2cppBinaryFixture()).return_types[0]?.name).toBe(
FIXTURE_RETURN_TYPE_NAME,
);
});
it("does not search for an empty image name", () => {
const unnamed = {
...metadata,
images: metadata.images.map((image, index) =>
index === 0 ? { ...image, name: "" } : image,
),
};
expect(() =>
linkIl2cppBinary(createIl2cppBinaryFixture(), unnamed, facts),
).toThrow("code-gen-modules-not-found");
});
it("refuses an image name repeated past the search bound", () => {
const fixture = createIl2cppBinaryFixture();
const flooded = Buffer.concat([
fixture,
Buffer.from("\0mscorlib.dll".repeat(65537) + "\0", "utf8"),
]);
// The Mach-O segment covers the original size; widen it to the new tail.
flooded.writeBigUInt64LE(BigInt(flooded.length), 64);
flooded.writeBigUInt64LE(BigInt(flooded.length), 80);
expect(failure(flooded)).toEqual({
reason: "native-index-capacity",
kind: "capacity",
});
});
it.each([
[{ duplicateRela: true }],
[{ relaEntry: 16n }],
[{ relaSize: 25n }],
] as const)("rejects malformed ELF relocation tables (%o)", (options) => {
expect(
failure(createElfFixture(createIl2cppBinaryFixture(), options)),
).toEqual({ reason: "malformed-container", kind: "malformed" });
});
it("does not apply symbol relocations as RELATIVE ones", () => {
// With every slot left zero, no module table can be found.
expect(() =>
link(
createElfFixture(createIl2cppBinaryFixture(), {
symbolRelocations: true,
}),
),
).toThrow(Il2cppBinaryFormatFailure);
});
it("reads a method pointer array in zero-fill as methods without bodies", () => {
// An iOS-only assembly in an Android build keeps its array in .bss.
const linked = link(
createElfFixture(createIl2cppBinaryFixture(), { zeroFilledTable: true }),
);
expect(linked.method_addresses).toEqual([
...expectedAddresses.slice(0, 3),
expectedAddress("no-body"),
expectedAddress("no-body"),
]);
});
it("reads a PE pointer array across file bytes and the declared zero-fill tail", () => {
const bytes = createSplitPointerPeFixture();
const original = Buffer.from(bytes);
expect(link(bytes).method_addresses).toEqual(expectedAddresses);
expect(bytes).toEqual(original);
expect(failure(createSplitPointerPeFixture(false)).reason).toBe(
"code-gen-modules-not-found",
);
});
it("reports a method no image range claims as unknown, not as without a body", () => {
// No type's method range holds this row, so no module slot describes it.
const orphan = {
...metadata,
methods: [...metadata.methods, { name: "Lost", declaring_type: -1 }],
};
const tokens = Uint32Array.from([...facts.methodTokens, 0x06000001]);
const linked = linkIl2cppBinary(createIl2cppBinaryFixture(), orphan, {
...facts,
methodTokens: tokens,
});
expect(linked.method_addresses).toEqual([
...expectedAddresses,
expectedAddress("unknown"),
]);
});
it("finds an x86-64 module array through the instruction that loads it", () => {
const inlined = createElfFixture(
createIl2cppBinaryFixture({ codeRegistration: false, codeReference: true }),
{ machine: 62 },
);
const linked = link(inlined);
expect(linked.code_gen_modules_field).toBeNull();
expect(linked.code_gen_modules_array).toBe(FIXTURE_BINARY_BASE + 0x500n);
expect(linked.method_addresses).toEqual(
link(createElfFixture(createIl2cppBinaryFixture(), { machine: 62 }))
.method_addresses,
);
});
it.each([
["no instruction loads the array", { machine: 62 }, { codeReference: false }],
["the image is not x86-64", { machine: 183 }, { codeReference: true }],
] as const)("keeps code-registration-not-found when %s", (_, elf, binary) => {
expect(
failure(
createElfFixture(
createIl2cppBinaryFixture({ codeRegistration: false, ...binary }),
elf,
),
).reason,
).toBe("code-registration-not-found");
});
it("refuses to choose among too many candidate module arrays", () => {
const base = createIl2cppBinaryFixture({
codeRegistration: false,
codeReference: true,
});
// Copy the two-slot module array (16 bytes at 0x500) 80 times after the image.
const copies = Buffer.concat(
Array.from({ length: 80 }, () => base.subarray(0x500, 0x510)),
);
const crafted = Buffer.concat([base, copies]);
crafted.writeBigUInt64LE(BigInt(crafted.length), 64);
crafted.writeBigUInt64LE(BigInt(crafted.length), 80);
// Mach-O is arm64 in the fixture; relabel it x86-64 so the fallback runs.
crafted.writeUInt32LE(0x01000007, 4);
crafted.writeUInt32LE(3, 8);
expect(failure(crafted).reason).toBe("native-index-capacity");
});
it("retains overlapping fallback arrays when only the later window is referenced", () => {
const bytes = createIl2cppBinaryFixture({
codeRegistration: false,
codeReference: true,
});
bytes.writeUInt32LE(0x01000007, 4);
bytes.writeUInt32LE(3, 8);
// [B, A, B] contains both [B, A] and [A, B]; code references only [A, B].
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x440n, 0x500);
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x400n, 0x508);
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x440n, 0x510);
bytes.writeInt32LE(0x508 - (0xc00 + 7), 0xc03);
const linked = link(bytes);
expect(linked.code_gen_modules_field).toBeNull();
expect(linked.code_gen_modules_array).toBe(FIXTURE_BINARY_BASE + 0x508n);
expect(linked.method_addresses).toEqual(expectedAddresses);
});
it("finds image names padded with 0xFF as MSVC writes them", () => {
expect(link(createIl2cppBinaryFixture({ namePadding: 0xff }))).toEqual(
link(createIl2cppBinaryFixture()),
);
});
it("does not match a longer string merely because its suffix names an image", () => {
const bytes = createIl2cppBinaryFixture({ namePrefix: "Mono." });
const suffix = bytes.readBigUInt64LE(0x440);
bytes.writeBigUInt64LE(suffix - 5n, 0x440); // The record actually names Mono.mscorlib.dll.
expect(failure(bytes).reason).toBe("code-gen-modules-not-found");
});
it("accepts a module's declared pointer into a suffix-shared C string", () => {
expect(
link(createIl2cppBinaryFixture({ namePrefix: "Mono." })).method_addresses,
).toEqual(expectedAddresses);
});
it("links an ELF shared object after applying its RELATIVE relocations", () => {
const elf = createElfFixture(createIl2cppBinaryFixture());
const fromMacho = link(createIl2cppBinaryFixture());
expect(link(elf)).toEqual({ ...fromMacho, format: "elf" });
});
it.each([
[{ dynamic: false }, "unsupported-container"],
[{ machine: 243 }, "unsupported-container"],
[{ androidPacked: true }, "unsupported-pointer-encoding"],
] as const)(
"refuses ELF relocations it cannot apply (%o)",
(options, reason) => {
expect(
failure(createElfFixture(createIl2cppBinaryFixture(), options)),
).toEqual({
reason,
kind: "unsupported",
});
},
);
it.each([
["Assembly CSharp.dll", "mscorlib.dll"],
["程序集.dll", "mscorlib.dll"],
["shared.dll", "shared.dll"],
] as const)(
"preserves module names and all candidate owners: %s / %s",
(first, second) => {
const imageNames = [first, second] as const;
const selected = {
...metadata,
images: metadata.images.map((image, index) => ({
...image,
name: imageNames[index]!,
})),
};
const linked = linkIl2cppBinary(
createIl2cppBinaryFixture({ imageNames }),
selected,
facts,
);
expect(linked.method_addresses).toEqual(expectedAddresses);
},
);
it("requires executable loader coverage before calling a mapped pointer code", () => {
const bytes = createIl2cppBinaryFixture();
bytes.writeUInt32LE(3, 92); // __TEXT is mapped read/write, with no execute permission.
expect(failure(bytes).reason).toBe("invalid-method-pointer");
});
it("does not treat RIP-relative-looking data as executable registration evidence", () => {
const bytes = createIl2cppBinaryFixture({
codeRegistration: false,
codeReference: true,
});
bytes.writeUInt32LE(0x01000007, 4);
bytes.writeUInt32LE(3, 92);
expect(failure(bytes).reason).toBe("code-registration-not-found");
});
it("yields throughout a large scan before reaching a later invalid method token", () => {
const bytes = Buffer.alloc(16 * 1024 * 1024, 0xaa);
createIl2cppBinaryFixture().copy(bytes);
bytes.writeBigUInt64LE(BigInt(bytes.length), 64);
bytes.writeBigUInt64LE(BigInt(bytes.length), 80);
const steps = linkIl2cppBinarySteps(bytes, metadata, {
...facts,
methodTokens: Uint32Array.from([0, ...facts.methodTokens.slice(1)]),
});
for (let index = 0; index < 200; index++)
expect(steps.next().done).toBe(false);
// Abandoning the cooperatively cancellable scan owns no external resources.
steps.return(undefined as never);
});
it("rejects a recursive binary type graph without overflowing the JavaScript stack", () => {
const bytes = createIl2cppBinaryFixture();
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x980n, 0x980);
expect(failure(bytes).reason).toBe("invalid-type");
});
it("guards a shared generic expansion before concatenating unchecked text", () => {
const bytes = Buffer.alloc(0x4000);
createIl2cppBinaryFixture().copy(bytes);
bytes.writeBigUInt64LE(BigInt(bytes.length), 64);
bytes.writeBigUInt64LE(BigInt(bytes.length), 80);
let prior = FIXTURE_BINARY_BASE + 0x9b0n; // int
for (let level = 0; level < 12; level++) {
const at = 0x1000 + level * 96,
address = FIXTURE_BINARY_BASE + BigInt(at);
bytes.writeBigUInt64LE(address + 16n, at);
bytes.writeUInt32LE(0x15 << 16, at + 8);
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x9a0n, at + 16);
bytes.writeBigUInt64LE(address + 32n, at + 24);
bytes.writeUInt32LE(2, at + 32);
bytes.writeBigUInt64LE(address + 48n, at + 40);
bytes.writeBigUInt64LE(prior, at + 48);
bytes.writeBigUInt64LE(prior, at + 56);
prior = address;
}
bytes.writeBigUInt64LE(prior, 0x900);
const stopped = new Error("type text admission");
const steps = linkIl2cppBinarySteps(bytes, metadata, facts, undefined, {
beforeText: (size) => {
if (size > 1024) throw stopped;
},
});
expect(() => {
for (;;) {
if (steps.next().done) break;
}
}).toThrow(stopped);
});
it("reports a symbol-relocated method slot as unknown rather than no-body", () => {
const bytes = createElfFixture(createIl2cppBinaryFixture());
const dynamic = Number(bytes.readBigUInt64LE(128));
const relocationBytes = Number(bytes.readBigUInt64LE(dynamic + 24));
for (let at = 0x1000; at < 0x1000 + relocationBytes; at += 24) {
if (bytes.readBigUInt64LE(at) !== FIXTURE_BINARY_BASE + 0x300n) continue;
bytes.writeBigUInt64LE((1n << 32n) | 1025n, at + 8); // unresolved symbol, not RELATIVE
}
const linked = link(bytes);
expect(linked.method_addresses[1]).toEqual(expectedAddress("unknown"));
expect(linked.method_addresses[0]).toEqual(expectedAddresses[0]);
});
it("reads a relative relocation into a zero-filled pointer array as a known value", () => {
const bytes = createElfFixture(createIl2cppBinaryFixture(), {
zeroFilledTable: true,
});
const dynamic = Number(bytes.readBigUInt64LE(128));
const relocationBytes = Number(bytes.readBigUInt64LE(dynamic + 24));
// Reuse the now-unused second module's file-backed first slot relocation.
for (let at = 0x1000; at < 0x1000 + relocationBytes; at += 24) {
if (bytes.readBigUInt64LE(at) === FIXTURE_BINARY_BASE + 0x318n)
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + BigInt(bytes.length), at);
}
expect(link(bytes).method_addresses).toEqual([
...expectedAddresses.slice(0, 4),
expectedAddress("no-body"),
]);
});
it("refuses an ambiguous architecture instead of choosing the first FAT slice", () => {
const bytes = createUniversalFixture([
{ cpu: 0x0100000c, bytes: createIl2cppBinaryFixture() },
{ cpu: 0x0100000c, bytes: createIl2cppBinaryFixture() },
]);
expect(failure(bytes, "arm64").reason).toBe("ambiguous-slice-selection");
});
it("checks the observed slice header against the caller-selected FAT architecture", () => {
const bytes = createUniversalFixture([
{ cpu: 0x01000007, bytes: createIl2cppBinaryFixture() },
]);
expect(failure(bytes, "amd64").reason).toBe("malformed-container");
});
it("still validates version 27 type indices when rendered names are unavailable", () => {
const decoded = readIl2cppMetadataForLinking(
createIl2cppMetadataFixture({ version: 27 }).bytes,
);
const input = {
...decoded.link,
returnTypes: Int32Array.from([999, ...decoded.link.returnTypes.slice(1)]),
};
expect(() =>
linkIl2cppBinary(createIl2cppBinaryFixture(), decoded.metadata, input),
).toThrow("invalid-type");
});
it("reads type attributes without requiring file-backed alignment padding", () => {
const bytes = createIl2cppBinaryFixture().subarray(0, 0xffc);
bytes.writeBigUInt64LE(BigInt(bytes.length), 80);
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0xff0n, 0x900);
bytes.writeBigUInt64LE(0n, 0xff0);
bytes.writeUInt32LE(0x08 << 16, 0xff8);
expect(link(bytes).return_types.map((row) => row.name)).toEqual(
Array.from({ length: 5 }, () => "int"),
);
});
@@ -0,0 +1,204 @@
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import { expect, it } from "vitest";
import { parseGhidraSeeds } from "../../../src/ghidra/GhidraAnalysisSeeds.js";
import {
ghidraDefaultBase,
il2cppGhidraSeeds,
} from "../../../src/il2cpp/Il2cppGhidraSeeds.js";
import {
IL2CPP_METADATA_MAGIC,
IL2CPP_METADATA_SECTION_NAMES,
type Il2cppMetadata,
} from "../../../src/domain/il2cpp/il2cppMetadata.js";
const type = (
name: string,
namespace: string,
method_start: number,
method_count: number,
declaring_type: number,
) => ({
name,
namespace,
method_start,
method_count,
nested_type_start: -1,
nested_type_count: 0,
declaring_type,
});
const address = (rva: string): Il2cppMetadata["method_addresses"][number] =>
rva === "no-body" || rva === "unknown"
? { state: rva, address: null, rva: null }
: { state: "code", address: rva, rva };
const report: Il2cppMetadata = {
artifact: {
path: "/artifacts/global-metadata.dat",
sha256: "a".repeat(64),
bytes: 1024,
},
magic: IL2CPP_METADATA_MAGIC,
metadata_version: 31,
index_widths: {
type_definition: 4,
generic_container: 4,
parameter: 4,
type: 4,
type_width_source: "fixed-width-version",
},
sections: IL2CPP_METADATA_SECTION_NAMES.map((name) => ({
name,
offset: 256,
size: 0,
count: 0,
})),
images: [{ name: "Assembly-CSharp.dll", type_start: 0, type_count: 3 }],
types: [
type("Player", "Game", 0, 2, -1),
type("Slot", "", 2, 2, 0),
type("<>c", "", 4, 1, 1),
],
methods: [
{ name: "Update", declaring_type: 0 },
{ name: "Abstract", declaring_type: 0 },
{ name: "Add", declaring_type: 1 },
{ name: "Folded", declaring_type: 1 },
{ name: "<Run>b__0 x", declaring_type: 2 },
],
fields: [],
properties: [],
events: [],
parameters: [],
string_literals: [],
binary: {
artifact: {
path: "/artifacts/libil2cpp.so",
sha256: "b".repeat(64),
bytes: 4096,
},
format: "elf",
architecture: "arm64",
image_base: "0x0",
code_gen_modules_field: "0x800",
code_gen_modules_array: "0x900",
code_gen_modules: [{ image: 0, address: "0x700", method_pointer_count: 5 }],
types_field: "0x900",
types_count: 0,
},
method_addresses: [
address("0x100"),
address("no-body"),
address("0x110"),
address("0x100"),
address("0x120"),
],
return_types: [],
field_types: [],
parameter_types: [],
limitations: [],
};
it("names linked methods Type$$Method with nested types and shifts by the base", () => {
const seeds = il2cppGhidraSeeds(report, 0x100000n);
expect(seeds).toEqual({
text:
"0x100100\tfunction\tGame.Player$$Update\n" +
"0x100110\tfunction\tGame.Player.Slot$$Add\n" +
"0x100120\tfunction\tGame.Player.Slot.<>c$$<Run>b__0_x\n" +
"# folded 0x100100 Game.Player$$Update also Game.Player.Slot$$Folded\n" +
`# metadata_sha256 ${report.artifact.sha256}\n# binary_sha256 ${report.binary!.artifact.sha256}\n# link_base 0x0\n# seed_base 0x100000\n`,
functions: 3,
folded: 1,
folded_groups: [
{
address: "0x100100",
kept: "Game.Player$$Update",
also: ["Game.Player.Slot$$Folded"],
},
],
without_address: 1,
no_body: 1,
unknown: 0,
});
const parsed = parseGhidraSeeds(seeds.text);
expect(parsed.ok && parsed.value.length).toBe(3);
});
it("seeds neither a method without a body nor one no module claims", () => {
const unknown = {
...report,
method_addresses: report.method_addresses.map((row, index) =>
index === 1 ? address("unknown") : row,
),
};
const seeds = il2cppGhidraSeeds(unknown, 0x100000n);
expect(seeds.text).toBe(il2cppGhidraSeeds(report, 0x100000n).text);
expect(seeds).toMatchObject({ without_address: 1, no_body: 0, unknown: 1 });
});
const inputFailure = (run: () => unknown): AnalysisInputError => {
try {
run();
} catch (cause) {
if (cause instanceof AnalysisInputError) return cause;
throw cause;
}
throw new Error("Expected seed input rejection");
};
it("refuses an unlinked report", () => {
const failure = inputFailure(() =>
il2cppGhidraSeeds({ ...report, binary: null }, 0n),
);
expect(failure.issues[0]?.path).toEqual(["binary_path"]);
});
it("defaults to Ghidra's load base: 0x100000 for an ELF linked at 0, else the link base", () => {
const binary = report.binary!;
expect(ghidraDefaultBase(binary)).toBe(0x100000n);
expect(ghidraDefaultBase({ ...binary, image_base: "0x200000" })).toBe(
0x200000n,
);
expect(ghidraDefaultBase({ ...binary, format: "macho" })).toBe(0n);
expect(
ghidraDefaultBase({ ...binary, format: "pe", image_base: "0x180000000" }),
).toBe(0x180000000n);
});
it("bounds folded-name retention against the actual Ghidra file admission", () => {
const count = 35000;
const repeated = {
...report,
methods: Array.from({ length: count }, () => ({
name: "M".repeat(1000),
declaring_type: 0,
})),
method_addresses: Array.from({ length: count }, () => address("0x100")),
};
expect(() => il2cppGhidraSeeds(repeated, 0n)).toThrow(/byte admission/);
});
it("refuses cyclic type ancestry before emitting a seed", () => {
const cyclic = {
...report,
types: report.types.map((type, index) => ({
...type,
declaring_type: index === 0 ? 1 : 0,
})),
};
expect(
inputFailure(() => il2cppGhidraSeeds(cyclic, 0n)).issues[0]?.message,
).toContain("Cyclic type ancestry");
});
it("refuses seed addresses that overflow after rebasing", () => {
expect(
inputFailure(() => il2cppGhidraSeeds(report, 0xffffffffffffffffn)).issues[0]
?.path,
).toEqual(["seed_base"]);
});
it("refuses names the real Ghidra seed parser cannot admit", () => {
const oversized = {
...report,
methods: [{ name: "M".repeat(2001), declaring_type: 0 }],
};
expect(() => il2cppGhidraSeeds(oversized, 0n)).toThrow(/character admission/);
});
@@ -8,6 +8,10 @@ import {
AnalysisResourceConstraintError,
} from "../../../src/domain/analysisErrorCore.js";
import { Il2cppMetadataProvider } from "../../../src/il2cpp/Il2cppMetadataProvider.js";
import {
createElfFixture,
createIl2cppBinaryFixture,
} from "../../fixtures/il2cpp/binary.js";
import { createIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -15,6 +19,16 @@ afterEach(() => {
vi.restoreAllMocks();
});
/** One-shot inspections keep the real reader's cleanup at one test boundary. */
const inspectMetadata = async (path: string, limit?: number) => {
const provider = new Il2cppMetadataProvider(limit);
try {
return await provider.inspect({ path });
} finally {
await provider.close();
}
};
/** Version 29 file whose methods all share one long name. */
const sharedNameMetadata = (
nameBytes: number | Buffer,
@@ -66,23 +80,18 @@ it("bounds repeated literal output during decoding and reuses one span instead o
const path = join(root, "global-metadata.dat");
await writeFile(path, sharedLiteralMetadata(Buffer.alloc(4096, 65), 20));
const decode = vi.spyOn(TextDecoder.prototype, "decode");
const provider = new Il2cppMetadataProvider(12_000);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expanded literals accepted");
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
validated_section_row_counts: { string_literals: 20 },
},
});
expect(
decode.mock.calls.filter(([bytes]) => bytes?.byteLength === 4096),
).toHaveLength(1);
} finally {
await provider.close();
}
const result = await inspectMetadata(path, 12_000);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expanded literals accepted");
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
validated_section_row_counts: { string_literals: 20 },
},
});
expect(
decode.mock.calls.filter(([bytes]) => bytes?.byteLength === 4096),
).toHaveLength(1);
});
it("refuses raw literal expansion before decoding and admits invalid UTF-8 only when base64 fits", async () => {
@@ -95,20 +104,13 @@ it("refuses raw literal expansion before decoding and admits invalid UTF-8 only
] as const) {
decode.mockClear();
await writeFile(path, sharedLiteralMetadata(bytes, 1));
const provider = new Il2cppMetadataProvider(limit);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
decode.mock.calls.filter(
([value]) => value?.byteLength === bytes.length,
),
).toHaveLength(expectedDecodes);
} finally {
await provider.close();
}
const result = await inspectMetadata(path, limit);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
decode.mock.calls.filter(([value]) => value?.byteLength === bytes.length),
).toHaveLength(expectedDecodes);
}
});
@@ -121,61 +123,48 @@ it("counts an unclaimed parameter table before allocating any member rows", asyn
const bytes = Buffer.from(fixture.bytes);
bytes.writeInt32LE(0x7fffffff, fixture.section("parameters").offset);
await writeFile(path, bytes);
const provider = new Il2cppMetadataProvider(4096);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
} finally {
await provider.close();
}
const result = await inspectMetadata(path, 4096);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
it("refuses an oversized report from its row lower bound before serializing it", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-budget-");
const path = join(root, "global-metadata.dat");
// Shared names make about 100 KiB of input expand beyond 64 MiB of JSON.
await writeFile(path, sharedNameMetadata(65_536, 1_100));
const stringify = vi.spyOn(JSON, "stringify");
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path });
it.each([
{
name: "shared-name row lower bound",
bytes: () => sharedNameMetadata(65_536, 1_100),
limit: undefined,
},
{
name: "JSON escaping",
bytes: () => sharedNameMetadata(4096, 1, 0x01),
limit: 8192,
},
{
name: "UTF-8 expansion",
bytes: () => sharedNameMetadata(Buffer.from("雪".repeat(4096)), 1),
limit: 8192,
},
])(
"refuses $name before allocating serialized text",
async ({ bytes, limit }) => {
const root = await createTestTempDirectory("rea-il2cpp-text-budget-");
const path = join(root, "global-metadata.dat");
await writeFile(path, bytes());
const stringify = vi.spyOn(JSON, "stringify");
const result = await inspectMetadata(path, limit);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
stringify.mock.calls.some(
([value]) =>
typeof value === "object" && value !== null && "methods" in value,
(typeof value === "string" && value.length >= 4096) ||
(typeof value === "object" && value !== null && "methods" in value),
),
).toBe(false);
} finally {
await provider.close();
}
});
it("counts escaped name characters toward the budget without escaping the name", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-escape-");
const path = join(root, "global-metadata.dat");
// 4 KiB of U+0001 is 4 KiB raw but 24 KiB once escaped as .
await writeFile(path, sharedNameMetadata(4096, 1, 0x01));
const stringify = vi.spyOn(JSON, "stringify");
const provider = new Il2cppMetadataProvider(8192);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
stringify.mock.calls.some(
([value]) => typeof value === "string" && value.length >= 4096,
),
).toBe(false);
} finally {
await provider.close();
}
});
},
);
it("admits a report whose escaped names fit the budget exactly", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-fit-");
@@ -207,15 +196,10 @@ it("refuses from row counts before decoding any row", async () => {
const bytes = Buffer.from(fixture.bytes);
bytes.writeInt32LE(0x7fff, fixture.record("methods", 0));
await writeFile(path, bytes);
const provider = new Il2cppMetadataProvider(256);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
} finally {
await provider.close();
}
const result = await inspectMetadata(path, 256);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
it.each([
@@ -317,28 +301,6 @@ it("bounds concurrent snapshots and retains active decoding until close complete
expect((await provider.inspect({ path })).ok).toBe(false);
});
it("refuses UTF-8 expansion before serializing the complete report", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-unicode-");
const path = join(root, "global-metadata.dat");
await writeFile(path, sharedNameMetadata(Buffer.from("雪".repeat(4096)), 1));
const stringify = vi.spyOn(JSON, "stringify");
const provider = new Il2cppMetadataProvider(8192);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
stringify.mock.calls.some(
([value]) =>
typeof value === "object" && value !== null && "methods" in value,
),
).toBe(false);
} finally {
await provider.close();
}
});
it.each([false, true])(
"keeps an independent failure with shared cancellation reason: %s",
async (sharedReason) => {
@@ -373,19 +335,42 @@ it("retains the exact empty source observation without claiming a decoded invent
const root = await createTestTempDirectory("rea-il2cpp-empty-");
const path = join(root, "empty.dat");
await writeFile(path, Buffer.alloc(0));
const result = await inspectMetadata(path);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unsupported empty metadata");
expect(result.error.message).toContain("unrecognized-magic");
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: {
artifact: {
path,
bytes: 0,
sha256: createHash("sha256").digest("hex"),
},
inventory_status: "not-returned-as-complete",
},
});
});
it("keeps the metadata source observation when the selected binary does not link", async () => {
const root = await createTestTempDirectory("rea-il2cpp-binary-failure-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "GameAssembly.dll");
const fixture = createIl2cppMetadataFixture();
await writeFile(path, fixture.bytes);
await writeFile(binaryPath, Buffer.from("not a native image"));
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path });
const result = await provider.inspect({ path, binary_path: binaryPath });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unsupported empty metadata");
expect(result.error.message).toContain("unrecognized-magic");
if (result.ok) throw new Error("Expected the binary to be refused");
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: {
artifact: {
path,
bytes: 0,
sha256: createHash("sha256").digest("hex"),
sha256: createHash("sha256").update(fixture.bytes).digest("hex"),
bytes: fixture.bytes.length,
},
inventory_status: "not-returned-as-complete",
},
@@ -394,3 +379,72 @@ it("retains the exact empty source observation without claiming a decoded invent
await provider.close();
}
});
it("accepts cancellation between binary linking batches and keeps the metadata source", async () => {
const root = await createTestTempDirectory("rea-il2cpp-link-cancel-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "libil2cpp.so");
const fixture = createIl2cppMetadataFixture();
await writeFile(path, fixture.bytes);
await writeFile(binaryPath, createElfFixture(createIl2cppBinaryFixture()));
// Yields taken by the metadata decode alone; later ones belong to linking.
let decodeYields = 0;
const counting = new Il2cppMetadataProvider(undefined, async () => {
decodeYields++;
});
try {
expect((await counting.inspect({ path })).ok).toBe(true);
} finally {
await counting.close();
}
const controller = new AbortController();
const reason = new Error("stop linking");
let yields = 0;
const provider = new Il2cppMetadataProvider(undefined, async () => {
if (++yields === decodeYields + 2) controller.abort(reason);
});
try {
const result = await provider.inspect(
{ path, binary_path: binaryPath },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(yields).toBe(decodeYields + 2);
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: { artifact: { path } },
});
} finally {
await provider.close();
}
});
it("retains both observed file identities and the selected architecture when native linking fails", async () => {
const root = await createTestTempDirectory("rea-il2cpp-failed-pair-");
const path = join(root, "global-metadata.dat"),
binary_path = join(root, "GameAssembly.dylib");
const bytes = createIl2cppBinaryFixture({ codeRegistration: false });
await writeFile(path, createIl2cppMetadataFixture().bytes);
await writeFile(binary_path, bytes);
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path, binary_path });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Unresolved registration accepted");
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
artifact: { path },
selected_binary: {
path: binary_path,
identity_status: "observed",
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
},
});
} finally {
await provider.close();
}
});
+331
View File
@@ -0,0 +1,331 @@
/**
* Synthetic thin arm64 Mach-O holding the IL2CPP registration tables for the
* metadata fixture: image names, code-generation modules, their method
* pointer arrays and the CodeRegistration module count and array pointer.
*/
export const FIXTURE_BINARY_BASE = 0x1_0000_0000n;
/** Method addresses by metadata method index; ToString has no body. */
export const FIXTURE_METHOD_ADDRESSES = [
FIXTURE_BINARY_BASE + 0x200n,
FIXTURE_BINARY_BASE + 0x210n,
FIXTURE_BINARY_BASE + 0x220n,
FIXTURE_BINARY_BASE + 0x230n,
"no-body",
] as const;
export interface Il2cppBinaryFixtureOptions {
/** methodPointerCount written for Assembly-CSharp.dll; 3 matches the metadata. */
readonly firstModuleCount?: number;
/** Exact module names, including names shared by different metadata images. */
readonly imageNames?: readonly [string, string];
/** Write a second record that also matches Assembly-CSharp.dll. */
readonly duplicateModule?: boolean;
/** Add an LC_DYLD_CHAINED_FIXUPS load command. */
readonly chainedFixups?: boolean;
/** Omit the CodeRegistration module count and array pointer. */
readonly codeRegistration?: boolean;
/** Write a RIP-relative `lea rax, [module array]`, as an inlined build loads it. */
readonly codeReference?: boolean;
/** Omit the MetadataRegistration type table. */
readonly metadataRegistration?: boolean;
/** Byte written before each image name instead of NUL (MSVC pads with 0xFF). */
readonly namePadding?: number;
/** Text written directly before mscorlib.dll, making it the tail of a longer name. */
readonly namePrefix?: string;
}
/** Every fixture method returns type 0, an array of a generic instance. */
export const FIXTURE_RETURN_TYPE_NAME = "System.Größe<int>[]";
const SIZE = 0x1000;
const METADATA_REGISTRATION = 0x800;
const TYPE_POINTERS = 0x900;
const TYPE_RECORDS = 0x980;
const GENERIC_CLASS = 0xa00;
const GENERIC_INSTANCE = 0xa40;
const GENERIC_ARGUMENTS = 0xa60;
const STRINGS = 0x100;
const POINTERS = 0x300;
const MODULES = 0x400;
const MODULE_ARRAY = 0x500;
const REGISTRATION = 0x600;
const DUPLICATE = 0x700;
export const createIl2cppBinaryFixture = (
options: Il2cppBinaryFixtureOptions = {},
): Buffer => {
const bytes = Buffer.alloc(SIZE);
const address = (offset: number) => FIXTURE_BINARY_BASE + BigInt(offset);
const chained = options.chainedFixups === true;
// Mach-O 64-bit header: arm64 dylib with one or two load commands.
bytes.writeUInt32LE(0xfeedfacf, 0);
bytes.writeUInt32LE(0x0100000c, 4);
bytes.writeUInt32LE(6, 12);
bytes.writeUInt32LE(chained ? 2 : 1, 16);
bytes.writeUInt32LE(chained ? 72 + 16 : 72, 20);
// LC_SEGMENT_64 __TEXT mapping the whole file read-execute.
bytes.writeUInt32LE(0x19, 32);
bytes.writeUInt32LE(72, 36);
bytes.write("__TEXT", 40, "latin1");
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE, 56);
bytes.writeBigUInt64LE(BigInt(SIZE), 64);
bytes.writeBigUInt64LE(0n, 72);
bytes.writeBigUInt64LE(BigInt(SIZE), 80);
bytes.writeUInt32LE(5, 88);
bytes.writeUInt32LE(5, 92);
if (chained) {
// linkedit_data_command pointing at an empty range.
bytes.writeUInt32LE(0x80000034, 104);
bytes.writeUInt32LE(16, 108);
}
const names = options.imageNames ?? ["Assembly-CSharp.dll", "mscorlib.dll"];
const nameAt: number[] = [];
let cursor = STRINGS;
for (const name of names) {
bytes[cursor++] = options.namePadding ?? 0;
if (name === "mscorlib.dll" && options.namePrefix !== undefined)
cursor += bytes.write(options.namePrefix, cursor, "utf8");
nameAt.push(cursor);
cursor += bytes.write(`${name}\0`, cursor, "utf8");
}
// Slots are indexed by token row id - 1 (see FIXTURE_METHOD_TOKENS).
const tables = [
[0x210n, 0x200n, 0x220n],
[0x230n, null],
];
const tableAt: number[] = [];
let pointer = POINTERS;
for (const table of tables) {
tableAt.push(pointer);
for (const slot of table) {
bytes.writeBigUInt64LE(
slot === null ? 0n : FIXTURE_BINARY_BASE + slot,
pointer,
);
pointer += 8;
}
}
const writeModule = (at: number, image: number, count: number) => {
bytes.writeBigUInt64LE(address(nameAt[image]!), at);
bytes.writeUInt32LE(count, at + 8);
bytes.writeBigUInt64LE(address(tableAt[image]!), at + 16);
};
writeModule(MODULES, 0, options.firstModuleCount ?? 3);
writeModule(MODULES + 0x40, 1, 2);
if (options.duplicateModule === true) writeModule(DUPLICATE, 0, 3);
bytes.writeBigUInt64LE(address(MODULES), MODULE_ARRAY);
bytes.writeBigUInt64LE(address(MODULES + 0x40), MODULE_ARRAY + 8);
if (options.codeReference === true) {
// lea rax, [rip + disp32]; disp is relative to the next instruction.
const at = 0xc00;
bytes.set([0x48, 0x8d, 0x05], at);
bytes.writeInt32LE(MODULE_ARRAY - (at + 7), at + 3);
}
if (options.codeRegistration !== false) {
bytes.writeBigUInt64LE(2n, REGISTRATION);
bytes.writeBigUInt64LE(address(MODULE_ARRAY), REGISTRATION + 8);
}
// Il2CppType records: 8-byte data, then attrs:16 and type:8 bits.
const types: readonly [kind: number, data: bigint][] = [
[0x1d, address(TYPE_RECORDS + 16)],
[0x15, address(GENERIC_CLASS)],
[0x12, 3n],
[0x08, 0n],
];
types.forEach(([kind, data], index) => {
const at = TYPE_RECORDS + index * 16;
bytes.writeBigUInt64LE(data, at);
bytes.writeUInt32LE(kind << 16, at + 8);
bytes.writeBigUInt64LE(address(at), TYPE_POINTERS + index * 8);
});
// Version 29+ Il2CppGenericClass: definition type, then class_inst.
bytes.writeBigUInt64LE(address(TYPE_RECORDS + 32), GENERIC_CLASS);
bytes.writeBigUInt64LE(address(GENERIC_INSTANCE), GENERIC_CLASS + 8);
bytes.writeUInt32LE(1, GENERIC_INSTANCE);
bytes.writeBigUInt64LE(address(GENERIC_ARGUMENTS), GENERIC_INSTANCE + 8);
bytes.writeBigUInt64LE(address(TYPE_RECORDS + 48), GENERIC_ARGUMENTS);
if (options.metadataRegistration !== false) {
// typesCount and types, then fieldOffsets and typeDefinitionsSizes,
// whose counts equal the 4 metadata type definitions.
const word = (slot: number, value: bigint) =>
bytes.writeBigUInt64LE(value, METADATA_REGISTRATION + slot * 8);
word(6, BigInt(types.length));
word(7, address(TYPE_POINTERS));
word(10, 4n);
word(11, address(TYPE_POINTERS));
word(12, 4n);
word(13, address(TYPE_POINTERS));
}
return bytes;
};
/** Wrap thin Mach-O slices in a 32-bit universal header, page aligned. */
export const createUniversalFixture = (
slices: readonly { readonly cpu: number; readonly bytes: Buffer }[],
): Buffer => {
const align = 0x1000;
const header = Buffer.alloc(align);
header.writeUInt32BE(0xcafebabe, 0);
header.writeUInt32BE(slices.length, 4);
const parts: Buffer[] = [header];
let offset = align;
slices.forEach((slice, index) => {
const at = 8 + index * 20;
header.writeUInt32BE(slice.cpu, at);
header.writeUInt32BE(0, at + 4);
header.writeUInt32BE(offset, at + 8);
header.writeUInt32BE(slice.bytes.length, at + 12);
header.writeUInt32BE(12, at + 16);
const padded = Buffer.alloc(Math.ceil(slice.bytes.length / align) * align);
slice.bytes.copy(padded);
parts.push(padded);
offset += padded.length;
});
return Buffer.concat(parts);
};
/** PE section with a method array ending across its file/zero-fill boundary. */
export const createSplitPointerPeFixture = (zeroTail = true): Buffer => {
const payload = createIl2cppBinaryFixture();
payload.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0xff8n, 0x450);
payload.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x230n, 0xff8);
const header = Buffer.alloc(0x200);
header.writeUInt16LE(0x5a4d);
header.writeUInt32LE(0x40, 60);
header.writeUInt32LE(0x4550, 0x40);
header.writeUInt16LE(0x8664, 0x44);
header.writeUInt16LE(1, 0x46);
header.writeUInt16LE(240, 0x54);
const optional = 0x58;
header.writeUInt16LE(0x20b, optional);
header.writeBigUInt64LE(FIXTURE_BINARY_BASE, optional + 24);
header.writeUInt32LE(0x1000, optional + 32);
header.writeUInt32LE(0x200, optional + 36);
header.writeUInt32LE(0x2000, optional + 56);
header.writeUInt32LE(0x200, optional + 60);
const section = optional + 240;
header.write(".text", section);
header.writeUInt32LE(zeroTail ? 0x1008 : 0x1000, section + 8);
header.writeUInt32LE(0x1000, section + 16);
header.writeUInt32LE(0x200, section + 20);
header.writeUInt32LE(0x60000020, section + 36);
return Buffer.concat([header, payload]);
};
/** Android ELF relocation tables appended after the fixture image. */
export interface ElfFixtureOptions {
/** e_machine; 183 is AArch64. */
readonly machine?: number;
/** Add DT_ANDROID_RELA, the packed APS2 encoding. */
readonly androidPacked?: boolean;
/** Omit PT_DYNAMIC. */
readonly dynamic?: boolean;
/** Give every relocation a symbol index, so none is RELATIVE-only. */
readonly symbolRelocations?: boolean;
/** Write DT_RELA twice. */
readonly duplicateRela?: boolean;
/** Override DT_RELASZ or DT_RELAENT. */
readonly relaSize?: bigint;
readonly relaEntry?: bigint;
/** Move mscorlib.dll's method pointers into zero-fill past the file end. */
readonly zeroFilledTable?: boolean;
}
/**
* Rewrap a fixture image as a 64-bit ELF shared object loaded at the same
* base: every pointer slot into the image is zeroed on disk and restored by
* an R_AARCH64_RELATIVE entry, as a position-independent libil2cpp.so stores it.
*/
export const createElfFixture = (
image: Buffer,
options: ElfFixtureOptions = {},
): Buffer => {
const body = Buffer.from(image);
body.fill(0, 0, 0x100);
const relocations: [slot: bigint, value: bigint][] = [];
for (let at = 0x100; at + 8 <= SIZE; at += 8) {
const value = body.readBigUInt64LE(at);
if (
value < FIXTURE_BINARY_BASE ||
value >= FIXTURE_BINARY_BASE + BigInt(SIZE)
)
continue;
relocations.push([FIXTURE_BINARY_BASE + BigInt(at), value]);
body.writeBigUInt64LE(0n, at);
}
const tagCount =
4 +
(options.duplicateRela === true ? 1 : 0) +
(options.androidPacked === true ? 1 : 0);
const fileSize = SIZE + relocations.length * 24 + tagCount * 16;
if (options.zeroFilledTable === true) {
// The second module record's methodPointers field (MODULES + 0x40 + 16).
const slot = FIXTURE_BINARY_BASE + BigInt(0x400 + 0x40 + 16);
const entry = relocations.find(([at]) => at === slot)!;
entry[1] = FIXTURE_BINARY_BASE + BigInt(fileSize);
}
const rela = Buffer.alloc(relocations.length * 24);
relocations.forEach(([slot, value], index) => {
rela.writeBigUInt64LE(slot, index * 24);
rela.writeBigUInt64LE(
(options.symbolRelocations === true ? 1n << 32n : 0n) |
(options.machine === 62 ? 8n : 1027n),
index * 24 + 8,
);
rela.writeBigInt64LE(value, index * 24 + 16);
});
const tags: [bigint, bigint][] = [
[7n, FIXTURE_BINARY_BASE + BigInt(SIZE)],
...(options.duplicateRela === true
? [[7n, FIXTURE_BINARY_BASE + BigInt(SIZE)] as [bigint, bigint]]
: []),
[8n, options.relaSize ?? BigInt(rela.length)],
[9n, options.relaEntry ?? 24n],
...(options.androidPacked === true
? [[0x60000011n, 0n] as [bigint, bigint]]
: []),
[0n, 0n],
];
const dynamic = Buffer.alloc(tags.length * 16);
tags.forEach(([tag, value], index) => {
dynamic.writeBigInt64LE(tag, index * 16);
dynamic.writeBigUInt64LE(value, index * 16 + 8);
});
const file = Buffer.concat([body, rela, dynamic]);
if (file.length !== fileSize) throw new Error("ELF fixture size drifted");
const withDynamic = options.dynamic !== false;
// ELF64 header: little-endian shared object, no section headers.
file.write("\x7fELF", 0, "latin1");
file.writeUInt8(2, 4);
file.writeUInt8(1, 5);
file.writeUInt8(1, 6);
file.writeUInt16LE(3, 16);
file.writeUInt16LE(options.machine ?? 183, 18);
file.writeUInt32LE(1, 20);
file.writeBigUInt64LE(64n, 32);
file.writeUInt16LE(64, 52);
file.writeUInt16LE(56, 54);
file.writeUInt16LE(withDynamic ? 2 : 1, 56);
// Synthetic PT_LOAD maps code and relocation data read-write-execute.
file.writeUInt32LE(1, 64);
file.writeUInt32LE(7, 68);
file.writeBigUInt64LE(FIXTURE_BINARY_BASE, 80);
file.writeBigUInt64LE(FIXTURE_BINARY_BASE, 88);
file.writeBigUInt64LE(BigInt(file.length), 96);
file.writeBigUInt64LE(
BigInt(file.length) + (options.zeroFilledTable === true ? 0x100n : 0n),
104,
);
if (withDynamic) {
const at = SIZE + rela.length;
file.writeUInt32LE(2, 120);
file.writeUInt32LE(6, 124);
file.writeBigUInt64LE(BigInt(at), 128);
file.writeBigUInt64LE(FIXTURE_BINARY_BASE + BigInt(at), 136);
file.writeBigUInt64LE(FIXTURE_BINARY_BASE + BigInt(at), 144);
file.writeBigUInt64LE(BigInt(dynamic.length), 152);
file.writeBigUInt64LE(BigInt(dynamic.length), 160);
}
return file;
};