fix(native): recover malformed Objective-C names at decode boundaries

Validate protocol and implementation selectors, ivar names and category
names before publishing records. Share validated local class identity
between class, superclass and category consumers, retaining unresolved
addresses and the decoder's failure reason.

Byte fixtures reproduced the reported validation failures. Final focused
4-file run: 118 passed; npm run check passed. Six existing CLI property
controls remain blocked before decoding by deep-provider auto-selection
ambiguity, as exposed by improved assertion diagnostics (#4555).
No Windows runtime or real malformed-application verification performed.

Refs #4540, #4541, #4533, #4534, #4115, #4555
This commit is contained in:
morluto
2026-10-17 03:02:26 +08:00
parent f31b231a92
commit a70c42e03d
5 changed files with 177 additions and 18 deletions
+144
View File
@@ -501,7 +501,151 @@ describe("malformed Objective-C property records", () => {
);
});
describe("Objective-C malformed identity recovery", () => {
it.each(["class selector", "category name"] as const)(
"isolates an empty %s before result validation",
(malformed) => {
const bytes = fixture(false, "unmapped-class");
bytes[malformed === "class selector" ? 0x460 : 0xbc0] = 0;
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_categories).toEqual([]);
if (malformed === "category name") {
expect(result.objc_classes).toHaveLength(2);
expect(result.objc_protocols).toMatchObject([
{ name: "FixtureProtocol" },
]);
}
expect(
result.coverage.find(
({ facet }) => facet === "objc_properties_categories",
),
).toMatchObject({
status: "partial",
reason: expect.stringContaining(
malformed === "class selector" ? "empty selector" : "empty name",
),
});
},
);
it("keeps an unresolved superclass address when its class name is empty", () => {
const bytes = fixture();
bytes.writeBigUInt64LE(base + 0xc00n, 0x208);
bytes.writeBigUInt64LE(base + 0xc40n, 0xc20);
bytes.writeBigUInt64LE(base + 0xc80n, 0xc58);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes[0]).toMatchObject({
name: "Fixture",
super_class: null,
});
expect(
result.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
),
).toMatchObject({
status: "partial",
reason: expect.stringContaining("Superclass pointer 0x100000c00"),
});
});
it("keeps a category whose local owner has an empty name", () => {
const bytes = fixture(false, "unmapped-class");
bytes.writeBigUInt64LE(base + 0xc00n, 0xb48);
bytes.writeBigUInt64LE(base + 0xc40n, 0xc20);
bytes.writeBigUInt64LE(base + 0xc80n, 0xc58);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes.map(({ name }) => name)).toEqual([
"Fixture",
"Fixture",
]);
expect(result.objc_categories).toMatchObject([
{
name: "FixtureCategory",
class_name: null,
class_source: "unresolved",
instance_methods: ["performAction:"],
decode: { status: "partial", reason: "category_class_unresolved" },
},
]);
expect(result.objc_dispatch_implementations).toContainEqual(
expect.objectContaining({
category: "FixtureCategory",
class_name: "(FixtureCategory)",
selector: "performAction:",
implementation_address: "0x100000700",
}),
);
expect(
result.coverage.find(
({ facet }) => facet === "objc_properties_categories",
),
).toMatchObject({
status: "partial",
reason: expect.stringContaining("empty name"),
});
});
it.each([0x920, 0x940])(
"isolates an empty protocol selector at %i while keeping adopted declarations and classes",
(selector) => {
const bytes = fixture();
bytes[selector] = 0;
// Decode the independent adopted declaration before the malformed parent.
bytes.writeBigUInt64LE(base + 0xa00n, 0x750);
bytes.writeBigUInt64LE(1n, 0xa00);
bytes.writeBigUInt64LE(base + 0xa40n, 0xa08);
bytes.writeBigUInt64LE(base + 0xac0n, 0xa48);
bytes.writeUInt32LE(72, 0xa80);
bytes.write("AdoptedProtocol\0", 0xac0);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes.map(({ name }) => name)).toEqual([
"Fixture",
"Fixture",
]);
expect(result.objc_protocols.map(({ name }) => name)).toEqual([
"AdoptedProtocol",
]);
expect(result.objc_protocol_records.map(({ name }) => name)).toEqual([
"AdoptedProtocol",
]);
expect(
result.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
),
).toMatchObject({
status: "partial",
reason: expect.stringContaining("empty selector"),
});
},
);
});
describe("malformed Apple dispatch metadata", () => {
it("retains valid class siblings when one class has an empty ivar name", () => {
const bytes = partialSiblingsFixture();
bytes.writeBigUInt64LE(base + 0x700n, 0x570);
bytes.writeUInt32LE(32, 0x700);
bytes.writeUInt32LE(1, 0x704);
bytes.writeBigUInt64LE(base + 0x800n, 0x708);
bytes.writeBigUInt64LE(base + 0x820n, 0x710);
bytes.writeBigUInt64LE(base + 0x840n, 0x718);
bytes.write("i\0", 0x840);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes.map(({ name }) => name)).toEqual([
"KeptClass",
"LaterClass",
]);
expect(result.objc_ivars).toEqual([]);
expect(
result.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
),
).toMatchObject({
status: "partial",
reason: expect.stringContaining("empty name"),
});
});
it("retains valid class siblings when one class has an empty name", () => {
const result = decodeAppleDispatchMetadata(
partialSiblingsFixture({ emptyMiddleClass: true }),
+2
View File
@@ -50,6 +50,8 @@ export const decodeObjcCategories = (input: {
try {
const category = read.pointer(field);
const name = read.string(read.pointer(category));
if (name.length === 0)
throw new TypeError("Objective-C category has an empty name");
const external = boundClassName(read, category + 8n);
const local = read.pointer(category + 8n);
let className = external ?? null;
+23 -15
View File
@@ -31,9 +31,16 @@ export interface ObjcDispatchReaders {
const hex = (value: bigint) => `0x${value.toString(16)}`;
const decoded = { status: "decoded" as const, reason: null };
/** Name of a class defined in this image, from its class_ro_t. */
const localClassName = (read: ObjcDispatchReaders, address: bigint): string =>
read.string(read.pointer((read.pointer(address + 32n) & ~7n) + 24n));
/** Admit the local class identity once for classes, superclasses and categories. */
const readLocalClass = (read: ObjcDispatchReaders, address: bigint) => {
const ro = read.pointer(address + 32n) & ~7n;
const name = read.string(read.pointer(ro + 24n));
if (name.length === 0)
throw new TypeError(
`Objective-C class at ${hex(address)} has an empty name`,
);
return { ro, name };
};
/**
* Resolve a class's superclass: a bind to `_OBJC_CLASS_$_Name` names a class
@@ -50,12 +57,13 @@ const resolveSuperclass = (
const superclass = read.pointer(address + 8n);
if (superclass === 0n) return { name: null, address: null };
try {
return { name: localClassName(read, superclass), address: hex(superclass) };
return {
name: readLocalClass(read, superclass).name,
address: hex(superclass),
};
} catch (cause: unknown) {
// Superclass resolution failure is recorded; the cause adds no identity.
void cause;
failures.push(
`Superclass pointer ${hex(superclass)} of ${className} requires unsupported binding/fixup resolution`,
`Superclass pointer ${hex(superclass)} of ${className} could not be decoded: ${cause instanceof Error ? cause.message : String(cause)}`,
);
return { name: null, address: hex(superclass) };
}
@@ -163,6 +171,8 @@ export const decodeObjcDispatchFacets = (input: {
void cause;
}
const selector = read.string(selectorAddress);
if (selector.length === 0)
throw new TypeError("Objective-C method has an empty selector");
selectors.push(selector);
result.objc_dispatch_implementations.push({
class_name: className,
@@ -260,12 +270,7 @@ export const decodeObjcDispatchFacets = (input: {
const readClass = (address: bigint, meta = false) => {
if (address === 0n || visited.has(hex(address)) || !budget.admit()) return;
visited.add(hex(address));
const ro = read.pointer(address + 32n) & ~7n;
const name = read.string(read.pointer(ro + 24n));
if (name.length === 0)
throw new TypeError(
`Objective-C class at ${hex(address)} has an empty name`,
);
const { ro, name } = readLocalClass(read, address);
const root = (read.u32(ro) & 2) !== 0;
const superclass = resolveSuperclass(read, address, name, failures);
if (superclass.name === null && superclass.address === null && !root)
@@ -292,10 +297,13 @@ export const decodeObjcDispatchFacets = (input: {
void cause;
failures.push(`Unresolved ivar offset pointer ${hex(encodedOffset)}`);
}
const ivarName = read.string(read.pointer(entry + 8n));
if (ivarName.length === 0)
throw new TypeError("Objective-C ivar has an empty name");
const alignment = read.u32(entry + 24n);
result.objc_ivars.push({
class_name: name,
name: read.string(read.pointer(entry + 8n)),
name: ivarName,
type_encoding: read.string(read.pointer(entry + 16n)),
offset: value,
size: read.u32(entry + 28n),
@@ -415,7 +423,7 @@ export const decodeObjcDispatchFacets = (input: {
admit: () => budget.admit(),
methods,
protocols: (list) => protocolReader.list(list),
localClassName: (address) => localClassName(read, address),
localClassName: (address) => readLocalClass(read, address).name,
});
return {
failures,
+6 -1
View File
@@ -50,8 +50,13 @@ export const createObjcProtocolReader = (input: {
: read.pointer(entry);
if (relative && (flags & 0x40000000) === 0)
selector = read.pointer(selector);
const selectorName = read.string(selector);
if (selectorName.length === 0)
throw new TypeError(
"Objective-C protocol method has an empty selector",
);
output.push({
selector: read.string(selector),
selector: selectorName,
method_type: instance ? "instance" : "class",
address: null,
is_required: required,
@@ -25,7 +25,7 @@ describe.skipIf(process.platform !== "darwin")(
arguments: ["inspect-native-dispatch-metadata", path, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(output.exitCode).toBe(0);
expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0);
const property = {
atomicity,
is_readonly: readonly,
@@ -86,7 +86,7 @@ describe.skipIf(process.platform !== "darwin")(
arguments: ["inspect-native-dispatch-metadata", path, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(output.exitCode).toBe(0);
expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0);
expect(output.json).toMatchObject({
normalized_result: {
result: {