fix(il2cpp): validate structural record extents (#7861)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 01:55:47 +08:00
1 parent 5119d08daf
commit 183e2e2d1e
6 files changed
+227 -81

No files matched your search

+6
View File
@@ -528,6 +528,12 @@ message names one reason:
| `image-range-overlap` | Two images claim the same type definitions. |
| `invalid-string` | A name index is outside the strings section, its string is unterminated, or it is not valid UTF-8. |
Record extents are checked for the decoded images, types, methods, fields,
properties, events, parameters, string literals, nested types, generic
containers, generic parameters and interface offsets. Other header sections
are checked for file bounds and overlap; their internal record layouts are not
decoded or validated by this inventory.
A version field that disagrees with the file's layout, for example a version
39 file relabeled as 29, usually surfaces as `unexpected-header-size` or
`record-size-mismatch`.
+44 -54
View File
@@ -1,5 +1,6 @@
import {
checkRange,
checkedSectionCount,
readIndex,
Il2cppMetadataFormatFailure,
} from "./Il2cppMetadataFormat.js";
@@ -236,16 +237,6 @@ const recordBytes = (section: Il2cppMetadataSection): number | null => {
return section.size / count;
};
/** Entry count of a section whose header records only its size. */
const entriesIn = (section: Il2cppMetadataSection, entryBytes: number) => {
if (section.size % entryBytes !== 0)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section ${section.name} size ${section.size} is not a whole multiple of its ${entryBytes}-byte records`,
);
return section.size / entryBytes;
};
const FIXED_WIDTHS: Il2cppMetadataDecoded["index_widths"] = {
type_definition: 4,
generic_container: 4,
@@ -260,15 +251,6 @@ const resolveWidths = (
section: (name: SectionName) => Il2cppMetadataSection,
): Il2cppMetadataDecoded["index_widths"] => {
const genericContainers = section("genericContainers");
const genericContainerBytes = recordBytes(genericContainers);
if (
genericContainerBytes !== null &&
genericContainerBytes !== GENERIC_CONTAINER_RECORD_BYTES
)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section genericContainers records are ${genericContainerBytes} bytes, but each record is ${GENERIC_CONTAINER_RECORD_BYTES}`,
);
const type_definition = il2cppIndexWidth(
headerCount(section("typeDefinitions")),
);
@@ -308,36 +290,54 @@ const recordCounts = (
version: Il2cppMetadataVersion,
section: (name: SectionName) => Il2cppMetadataSection,
expected: Readonly<Record<RecordSection, number>>,
): Readonly<Record<RecordSection | "nestedTypes", number>> => {
widths: Il2cppMetadataDecoded["index_widths"],
): Readonly<
Record<
RecordSection | "nestedTypes" | "genericContainers" | "genericParameters",
number
>
> => {
const genericContainers = checkedSectionCount(
section("genericContainers"),
GENERIC_CONTAINER_RECORD_BYTES,
);
const genericParameters = checkedSectionCount(
section("genericParameters"),
widths.generic_container + 12,
);
const names = ["typeDefinitions", "images", "methods"] as const;
if (VERSION_LAYOUTS[version].indexing === "fixed") {
checkedSectionCount(section("interfaceOffsets"), widths.type + 4);
const sized = Object.fromEntries(
names.map((name) => [name, entriesIn(section(name), expected[name])]),
names.map((name) => [
name,
checkedSectionCount(section(name), expected[name]),
]),
) as Record<RecordSection, number>;
return {
...sized,
nestedTypes: entriesIn(section("nestedTypes"), NESTED_TYPE_ENTRY_BYTES),
nestedTypes: checkedSectionCount(
section("nestedTypes"),
NESTED_TYPE_ENTRY_BYTES,
),
genericContainers,
genericParameters,
};
}
for (const name of names) {
const actual = recordBytes(section(name));
if (actual !== null && actual !== expected[name])
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section ${name} records are ${actual} bytes, but the index widths imply ${expected[name]}`,
);
}
const nestedTypes = section("nestedTypes");
if (nestedTypes.size !== headerCount(nestedTypes) * NESTED_TYPE_ENTRY_BYTES)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section nestedTypes size ${nestedTypes.size} does not hold ${String(nestedTypes.count)} 4-byte entries`,
);
const counted = Object.fromEntries(
names.map((name) => [
name,
checkedSectionCount(section(name), expected[name]),
]),
) as Record<RecordSection, number>;
return {
typeDefinitions: headerCount(section("typeDefinitions")),
images: headerCount(section("images")),
methods: headerCount(section("methods")),
nestedTypes: headerCount(nestedTypes),
...counted,
nestedTypes: checkedSectionCount(
section("nestedTypes"),
NESTED_TYPE_ENTRY_BYTES,
),
genericContainers,
genericParameters,
};
};
@@ -522,9 +522,7 @@ function* readTypes(tables: MetadataTables): Generator<
events: [] as MetadataOwnerRange[],
};
const genericContainerCount =
tables.linkFacts === undefined
? 0
: entriesIn(section("genericContainers"), GENERIC_CONTAINER_RECORD_BYTES);
tables.linkFacts === undefined ? 0 : tables.counts.genericContainers;
for (let index = 0; index < counts.typeDefinitions; index++) {
if (index % 256 === 0) yield;
const at = typeDefinitions.offset + index * sizes.typeDefinitions;
@@ -810,11 +808,8 @@ const readGenericContainer = (tables: MetadataTables, index: number) => {
/** Type and method container indices and container records, read on demand. */
const createGenericContainerReaders = (tables: MetadataTables) => {
const typeCount = tables.counts.typeDefinitions;
const containers = tables.section("genericContainers");
const containerCount =
tables.indexing === "counted"
? 0
: Math.floor(containers.size / GENERIC_CONTAINER_RECORD_BYTES);
tables.indexing === "counted" ? 0 : tables.counts.genericContainers;
const inRange = (index: number, count: number) =>
Number.isInteger(index) && index >= 0 && index < count;
const methods = tables.section("methods"),
@@ -845,12 +840,7 @@ const createGenericContainerReaders = (tables: MetadataTables) => {
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",
);
const count = tables.counts.genericParameters;
return (index: number): string | undefined => {
if (!Number.isInteger(index) || index < 0 || index >= count)
return undefined;
@@ -911,7 +901,7 @@ function* readMetadataCoreSteps(
section,
widths,
recordBytes: expected,
counts: recordCounts(version, section, expected),
counts: recordCounts(version, section, expected, widths),
typeDefinitionTypeIndices: layout.typeDefinitionTypeIndices,
indexing: layout.indexing,
methodReturnParameterToken: layout.methodReturnParameterToken,
+20
View File
@@ -38,6 +38,25 @@ export class Il2cppMetadataFormatFailure extends Error {
}
}
/** Validate a section against a known serialized record width once. */
export const checkedSectionCount = (
section: Il2cppMetadataSection,
recordBytes: number,
): number => {
if (section.size % recordBytes !== 0)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section ${section.name} size ${section.size} is not a whole multiple of its ${recordBytes}-byte records`,
);
const count = section.size / recordBytes;
if (section.count !== null && count !== section.count)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section ${section.name} size ${section.size} does not hold ${section.count} ${recordBytes}-byte records`,
);
return count;
};
export const checkRange = (
label: string,
start: number,
@@ -64,3 +83,4 @@ export const readIndex = (bytes: Buffer, offset: number, width: 1 | 2 | 4) => {
}
return bytes.readInt32LE(offset);
};
import type { Il2cppMetadataSection } from "../domain/il2cpp/il2cppMetadata.js";
+7 -27
View File
@@ -1,9 +1,7 @@
import type {
Il2cppMetadata,
Il2cppMetadataSection,
} from "../domain/il2cpp/il2cppMetadata.js";
import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js";
import {
checkRange,
checkedSectionCount,
readIndex,
Il2cppMetadataFormatFailure,
} from "./Il2cppMetadataFormat.js";
@@ -24,21 +22,6 @@ export interface MetadataMemberOwners {
readonly parameters: readonly MetadataOwnerRange[];
}
const entries = (
indexing: MetadataTables["indexing"],
section: Il2cppMetadataSection,
recordBytes: number,
) => {
const count =
indexing === "counted" ? (section.count ?? 0) : section.size / recordBytes;
if (!Number.isInteger(count) || section.size !== count * recordBytes)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section ${section.name} size ${section.size} does not hold whole ${recordBytes}-byte records`,
);
return count;
};
/** Own member record widths and validate all counts before any row allocation. */
export const readIl2cppMemberTables = ({
widths,
@@ -52,8 +35,7 @@ export const readIl2cppMemberTables = ({
parameters: 8 + widths.type,
stringLiterals: indexing === "counted" ? 4 : 8,
};
const literalRecords = entries(
indexing,
const literalRecords = checkedSectionCount(
section("stringLiterals"),
recordBytes.stringLiterals,
);
@@ -61,15 +43,13 @@ export const readIl2cppMemberTables = ({
recordBytes,
literalRecords,
counts: {
fields: entries(indexing, section("fields"), recordBytes.fields),
properties: entries(
indexing,
fields: checkedSectionCount(section("fields"), recordBytes.fields),
properties: checkedSectionCount(
section("properties"),
recordBytes.properties,
),
events: entries(indexing, section("events"), recordBytes.events),
parameters: entries(
indexing,
events: checkedSectionCount(section("events"), recordBytes.events),
parameters: checkedSectionCount(
section("parameters"),
recordBytes.parameters,
),
@@ -0,0 +1,61 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createIl2cppMetadataFixture } from "../fixtures/il2cpp/metadata.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest, runFileCli } from "../support/cli/cliFixture.js";
cliTest(
"rejects partial IL2CPP table extents through CLI and MCP without a binary",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-il2cpp-extent-");
const { call } = await connectLocalToolsMcp();
for (const [version, name] of [
[31, "genericContainers"],
[31, "genericParameters"],
[31, "interfaceOffsets"],
[39, "genericParameters"],
] as const) {
const fixture = createIl2cppMetadataFixture({
version,
genericOwners: true,
});
const path = join(root, `${version}-${name}.dat`);
await writeFile(path, fixture.bytes);
const intact = await runFileCli(cli, {
arguments: ["inspect-il2cpp-metadata", path, "--json"],
});
expect(intact.exitCode, intact.stderr).toBe(0);
const damaged = Buffer.from(fixture.bytes);
damaged.writeInt32LE(
fixture.section(name).size - 1,
fixture.header(name, "size"),
);
await writeFile(path, damaged);
const invalid = await runFileCli(cli, {
arguments: ["inspect-il2cpp-metadata", path, "--json"],
});
const expected = {
code: "invalid_request",
details: {
issues: [
{
path: ["path"],
reason: "invalid_format",
message: expect.stringContaining("record-size-mismatch"),
},
],
},
};
expect(invalid.exitCode).toBe(1);
expect(invalid.json).toMatchObject(expected);
const result = await call("inspect_il2cpp_metadata", { path });
expect(result.isError).toBe(true);
expect(parseMcpToolError(result).error).toMatchObject(expected);
}
},
30000,
);
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { Il2cppMetadataFormatFailure } from "../../../src/il2cpp/Il2cppMetadataFormat.js";
import { createIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
import {
readIl2cppMetadata,
readIl2cppMetadataForLinking,
} from "../../fixtures/il2cpp/readers.js";
describe("IL2CPP structural section extents", () => {
it.each([24, 27, 29, 31, 39] as const)(
"validates generic records before inventory or linking for version %i",
(version) => {
const fixture = createIl2cppMetadataFixture({
version,
genericOwners: true,
});
for (const reader of [readIl2cppMetadata, readIl2cppMetadataForLinking]) {
expect(() => reader(fixture.bytes)).not.toThrow();
for (const name of [
"genericContainers",
"genericParameters",
] as const) {
const damaged = Buffer.from(fixture.bytes);
damaged.writeInt32LE(
fixture.section(name).size - 1,
fixture.header(name, "size"),
);
expect(() => reader(damaged)).toThrow(Il2cppMetadataFormatFailure);
expect(() => reader(damaged)).toThrow(/record-size-mismatch/);
}
}
},
);
it.each([24, 27, 29, 31] as const)(
"rejects partial fixed-width interface offsets in version %i",
(version) => {
const fixture = createIl2cppMetadataFixture({
version,
genericOwners: true,
});
const damaged = Buffer.from(fixture.bytes);
damaged.writeInt32LE(6, fixture.header("interfaceOffsets", "size"));
for (const reader of [readIl2cppMetadata, readIl2cppMetadataForLinking]) {
expect(() => reader(fixture.bytes)).not.toThrow();
expect(() => reader(damaged)).toThrow(/record-size-mismatch/);
}
},
);
it("rejects a counted generic-parameter extent with the wrong declared count", () => {
const fixture = createIl2cppMetadataFixture({
version: 39,
genericOwners: true,
});
const damaged = Buffer.from(fixture.bytes);
damaged.writeInt32LE(3, fixture.header("genericParameters", "count"));
expect(() => readIl2cppMetadata(damaged)).toThrow(/record-size-mismatch/);
});
it.each([31, 39] as const)(
"uses the same extent validation for decoded member sections in version %i",
(version) => {
const fixture = createIl2cppMetadataFixture({
version,
genericOwners: true,
propertiesAndEvents: true,
});
for (const name of [
"fields",
"properties",
"events",
"parameters",
"stringLiterals",
"nestedTypes",
] as const) {
expect(fixture.section(name).size, name).toBeGreaterThan(0);
const damaged = Buffer.from(fixture.bytes);
damaged.writeInt32LE(
fixture.section(name).size - 1,
fixture.header(name, "size"),
);
expect(() => readIl2cppMetadata(damaged), name).toThrow(
/record-size-mismatch/,
);
}
},
);
});