From a70c42e03dc526e76f360ecec9f1002187ffb23e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:02:26 +0800 Subject: [PATCH] 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 --- src/native/AppleDispatchMetadata.test.ts | 144 ++++++++++++++++++ src/native/AppleObjcCategories.ts | 2 + src/native/AppleObjcDispatchFacets.ts | 38 +++-- src/native/AppleObjcProtocols.ts | 7 +- .../nativeDispatchPropertyAttributes.test.ts | 4 +- 5 files changed, 177 insertions(+), 18 deletions(-) diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 4bfc2d7edf..5e532e8d69 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -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 }), diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index 7b3b37882a..f81369d4dc 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -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; diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 21cda550fe..089d1906b1 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -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, diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 19d35872be..5e41e3c386 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -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, diff --git a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts index 7aa614afc4..6d90fb57fe 100644 --- a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts +++ b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts @@ -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: {