[GH-ISSUE #4541] [Bug] Empty Objective-C category class reference name escapes decode boundary and crashes metadata validation #2365

Closed
opened 2026-10-17 21:03:47 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @kevinjosh10 on GitHub (Oct 16, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4541

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

A Mach-O binary containing an Objective-C category referencing a local class whose name is empty (\0) causes decodeAppleDispatchMetadata to fail with an unhandled ZodError (too_small: expected string to have >=1 characters at objc_categories[0].class_name).

While unmapped class references are caught by #4012 and recorded as category_class_unresolved, a mapped class record with an empty name pointer returns "" from localClassName without throwing. This empty string is assigned to class_name and used as the owner for category method dispatch implementations, bypassing the category error isolation and aborting the entire metadata decode.

Reproduction

  1. In-memory Mach-O reproduction script (repro_cat_classname.mts):
import { decodeAppleDispatchMetadata } from "./src/native/AppleDispatchMetadata.js";

const base = 0x100000000n;
const bytes = Buffer.alloc(4096);
const u32 = (offset: number, value: number) => bytes.writeUInt32LE(value, offset);
const ptr = (offset: number, target: number) => bytes.writeBigUInt64LE(base + BigInt(target), offset);

// Mach-O arm64 header + __DATA segment + __objc_catlist
u32(0, 0xfeedfacf);
u32(4, 0x0100000c);
u32(16, 1);
u32(20, 152);
u32(32, 0x19);
u32(36, 152);
bytes.write("__DATA", 40);
ptr(56, 0);
bytes.writeBigUInt64LE(4096n, 64);
bytes.writeBigUInt64LE(4096n, 80);
u32(92, 5);
u32(96, 1);
bytes.write("__objc_catlist", 104);
bytes.write("__DATA", 120);
ptr(136, 0x180);
bytes.writeBigUInt64LE(8n, 144);
u32(152, 0x180);

// catlist slot pointing to category_t at 0x200
ptr(0x180, 0x200);

// category_t at 0x200:
ptr(0x200, 0x300); // name pointer -> "ValidCategory\0"
bytes.write("ValidCategory\0", 0x300);
ptr(0x208, 0x500); // cls pointer -> local class_t at 0x500

// local class_t at 0x500:
ptr(0x520, 0x600); // class_ro_t pointer at offset 32

// class_ro_t at 0x600:
ptr(0x618, 0x700); // name pointer -> empty string "\0"
bytes.write("\0", 0x700);

decodeAppleDispatchMetadata(bytes, 100, {
  path: "/fixture/app",
  sha256: "a".repeat(64),
});
  1. Run with Node / tsx:
npx tsx repro_cat_classname.mts
  1. Observe ZodError thrown during top-level schema parsing:
[
  {
    "origin": "string",
    "code": "too_small",
    "minimum": 1,
    "inclusive": true,
    "path": [
      "objc_categories",
      0,
      "class_name"
    ],
    "message": "Too small: expected string to have >=1 characters"
  }
]

Expected behavior

If the referenced local class name is empty, localClassName should throw or be treated as an unresolved class reference. The category should be retained with class_name: null, class_source: "unresolved", and a recorded category_class_unresolved issue, preserving the category's valid name, methods, and protocols without crashing top-level metadata decode.

Impact and scope

Any Mach-O binary containing an Objective-C category that points to an anonymous, corrupted, or obfuscated class symbol causes decodeAppleDispatchMetadata to crash completely, losing all other classes and metadata.

REA version

8194695144045e0a950f2989190e7337890342f7 (6.5.0)

Runtime and platform

Node v24.13.0; Windows x64

Target and artifact context

Mach-O arm64 binary; Objective-C metadata (__objc_catlist and category_t)

Interface

Other

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

In src/native/AppleObjcCategories.ts:55-69:

let className = external ?? null;
if (external === undefined && local !== 0n)
  try {
    className = input.localClassName(local);
  } catch (cause: unknown) {
    input.issues.push(
      issue(
        "objc_properties_categories",
        "category_class_unresolved",
        `Category ${name} class reference 0x${local.toString(16)}: ${cause instanceof Error ? cause.message : String(cause)}`,
        `0x${(category + 8n).toString(16)}`,
      ),
    );
  }

And in src/native/AppleObjcDispatchFacets.ts:35-36:

const localClassName = (read: ObjcDispatchReaders, address: bigint): string =>
  read.string(read.pointer((read.pointer(address + 32n) & ~7n) + 24n));

localClassName performs read.string(...) without asserting length > 0. Because read.string returns "" for an empty NUL-terminated string, no exception is thrown, bypassing the catch block. objcCategorySchema (src/domain/native/objcSwiftMetadata.ts:177) specifies class_name: z.string().min(1).nullable(), rejecting "" with a ZodError at objcSwiftMetadataSchema.parse(result).

Typed outcome or recovery expected

The category should be returned with class_name: null, class_source: "unresolved", and a recorded category_class_unresolved issue, and decodeAppleDispatchMetadata should return a valid ObjcSwiftMetadata result.

Analysis or suspected cause

localClassName does not validate that the class name is non-empty. Enforcing if (name.length === 0) throw new TypeError(...) inside localClassName (matching readClass in AppleObjcDispatchFacets.ts:265) ensures that unreadable/empty class names are caught and converted to unresolved class issues.

Originally created by @kevinjosh10 on GitHub (Oct 16, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4541 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior A Mach-O binary containing an Objective-C category referencing a local class whose name is empty (`\0`) causes `decodeAppleDispatchMetadata` to fail with an unhandled `ZodError` (`too_small: expected string to have >=1 characters at objc_categories[0].class_name`). While unmapped class references are caught by #4012 and recorded as `category_class_unresolved`, a mapped class record with an empty name pointer returns `""` from `localClassName` without throwing. This empty string is assigned to `class_name` and used as the `owner` for category method dispatch implementations, bypassing the category error isolation and aborting the entire metadata decode. ### Reproduction 1. In-memory Mach-O reproduction script (`repro_cat_classname.mts`): ```ts import { decodeAppleDispatchMetadata } from "./src/native/AppleDispatchMetadata.js"; const base = 0x100000000n; const bytes = Buffer.alloc(4096); const u32 = (offset: number, value: number) => bytes.writeUInt32LE(value, offset); const ptr = (offset: number, target: number) => bytes.writeBigUInt64LE(base + BigInt(target), offset); // Mach-O arm64 header + __DATA segment + __objc_catlist u32(0, 0xfeedfacf); u32(4, 0x0100000c); u32(16, 1); u32(20, 152); u32(32, 0x19); u32(36, 152); bytes.write("__DATA", 40); ptr(56, 0); bytes.writeBigUInt64LE(4096n, 64); bytes.writeBigUInt64LE(4096n, 80); u32(92, 5); u32(96, 1); bytes.write("__objc_catlist", 104); bytes.write("__DATA", 120); ptr(136, 0x180); bytes.writeBigUInt64LE(8n, 144); u32(152, 0x180); // catlist slot pointing to category_t at 0x200 ptr(0x180, 0x200); // category_t at 0x200: ptr(0x200, 0x300); // name pointer -> "ValidCategory\0" bytes.write("ValidCategory\0", 0x300); ptr(0x208, 0x500); // cls pointer -> local class_t at 0x500 // local class_t at 0x500: ptr(0x520, 0x600); // class_ro_t pointer at offset 32 // class_ro_t at 0x600: ptr(0x618, 0x700); // name pointer -> empty string "\0" bytes.write("\0", 0x700); decodeAppleDispatchMetadata(bytes, 100, { path: "/fixture/app", sha256: "a".repeat(64), }); ``` 2. Run with Node / `tsx`: ```sh npx tsx repro_cat_classname.mts ``` 3. Observe `ZodError` thrown during top-level schema parsing: ```json [ { "origin": "string", "code": "too_small", "minimum": 1, "inclusive": true, "path": [ "objc_categories", 0, "class_name" ], "message": "Too small: expected string to have >=1 characters" } ] ``` ### Expected behavior If the referenced local class name is empty, `localClassName` should throw or be treated as an unresolved class reference. The category should be retained with `class_name: null`, `class_source: "unresolved"`, and a recorded `category_class_unresolved` issue, preserving the category's valid name, methods, and protocols without crashing top-level metadata decode. ### Impact and scope Any Mach-O binary containing an Objective-C category that points to an anonymous, corrupted, or obfuscated class symbol causes `decodeAppleDispatchMetadata` to crash completely, losing all other classes and metadata. ### REA version `8194695144045e0a950f2989190e7337890342f7` (6.5.0) ### Runtime and platform Node v24.13.0; Windows x64 ### Target and artifact context Mach-O arm64 binary; Objective-C metadata (`__objc_catlist` and `category_t`) ### Interface Other ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence In `src/native/AppleObjcCategories.ts:55-69`: ```ts let className = external ?? null; if (external === undefined && local !== 0n) try { className = input.localClassName(local); } catch (cause: unknown) { input.issues.push( issue( "objc_properties_categories", "category_class_unresolved", `Category ${name} class reference 0x${local.toString(16)}: ${cause instanceof Error ? cause.message : String(cause)}`, `0x${(category + 8n).toString(16)}`, ), ); } ``` And in `src/native/AppleObjcDispatchFacets.ts:35-36`: ```ts const localClassName = (read: ObjcDispatchReaders, address: bigint): string => read.string(read.pointer((read.pointer(address + 32n) & ~7n) + 24n)); ``` `localClassName` performs `read.string(...)` without asserting `length > 0`. Because `read.string` returns `""` for an empty NUL-terminated string, no exception is thrown, bypassing the catch block. `objcCategorySchema` (`src/domain/native/objcSwiftMetadata.ts:177`) specifies `class_name: z.string().min(1).nullable()`, rejecting `""` with a `ZodError` at `objcSwiftMetadataSchema.parse(result)`. ### Typed outcome or recovery expected The category should be returned with `class_name: null`, `class_source: "unresolved"`, and a recorded `category_class_unresolved` issue, and `decodeAppleDispatchMetadata` should return a valid `ObjcSwiftMetadata` result. ### Analysis or suspected cause `localClassName` does not validate that the class name is non-empty. Enforcing `if (name.length === 0) throw new TypeError(...)` inside `localClassName` (matching `readClass` in `AppleObjcDispatchFacets.ts:265`) ensures that unreadable/empty class names are caught and converted to unresolved class issues.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#2365
No description provided.