[GH-ISSUE #4369] [Bug] An empty Swift vtable class name prevents returning valid sibling metadata #1726

Closed
opened 2026-10-16 21:09:31 +00:00 by bsakaguchi · 0 comments
Owner

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

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

Calling the production decodeAppleDispatchMetadata byte decoder on a synthetic arm64 Mach-O containing two class vtables throws ZodError when the second class name is an immediately NUL-terminated string. The error identifies swift_dispatch_slots[1].owner; no metadata result is returned, including the independently valid first vtable.

The current default-branch and published-release sources below reproduce this behavior. This is an executed production byte-decoder check without substituted readers. Public CLI/MCP behavior was not executed; file-inspection reachability is source-derived.

Reproduction

Use Node.js 24.18.0 and npm 11.16.0, matching the repository pins. Prepare a source checkout and its locked dependencies:

git clone https://github.com/morluto/rea.git rea-source
cd rea-source
git checkout --detach 8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd
npm ci
export REA_CHECKOUT="$PWD"
cd ..

Save this complete script as repro.mts beside the checkout. It constructs a mapped arm64 Mach-O with two non-generic, non-resilient Swift class vtables. Cases differ only in the second class name or its pointer.

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
const root = process.env.REA_CHECKOUT;
if (!root) throw new Error('Set REA_CHECKOUT to the source checkout');
const { decodeAppleDispatchMetadata } = await import(pathToFileURL(resolve(root, 'src/native/AppleDispatchMetadata.ts')).href);
// A mapped 4096-byte arm64 Mach-O with two non-generic, non-resilient
// Swift class descriptors, one synchronous vtable slot per descriptor.
function fixture(mode: 'valid' | 'empty' | 'unmapped') {
 const b = Buffer.alloc(4096), base = 0x100000000n;
 const u32 = (at:number,n:number) => b.writeUInt32LE(n,at);
 const ptr = (at:number,n:number) => b.writeBigUInt64LE(base+BigInt(n),at);
 const rel = (at:number,n:number) => b.writeInt32LE(n-at,at);
 u32(0,0xfeedfacf); u32(4,0x0100000c); u32(12,2); u32(16,1); u32(20,152);
 u32(32,0x19); u32(36,152); b.write('__TEXT',40); ptr(56,0);
 b.writeBigUInt64LE(4096n,64); b.writeBigUInt64LE(4096n,80);
 u32(88,5); u32(92,5); u32(96,1);
 b.write('__swift5_types',104); b.write('__TEXT',120); ptr(136,0x200);
 b.writeBigUInt64LE(8n,144); u32(152,0x200);
 for (const [field,descriptor,name,implementation] of [[0x200,0x300,0x500,0x800],[0x204,0x380,0x520,0x810]]) {
  rel(field,descriptor); u32(descriptor,0x80000010); rel(descriptor+8,name);
  u32(descriptor+28,1); u32(descriptor+44,0); u32(descriptor+48,1);
  u32(descriptor+52,0); rel(descriptor+56,implementation);
 }
 b.write('KeptClass\0',0x500);
 if (mode==='valid') b.write('OtherClass\0',0x520);
 if (mode==='unmapped') rel(0x388,0x5000);
 return b;
}
for (const mode of ['valid','empty','unmapped'] as const) {
 const bytes=fixture(mode), provenance={path:'/synthetic/swift-vtable.macho',sha256:createHash('sha256').update(bytes).digest('hex')};
 try {
  const result=decodeAppleDispatchMetadata(bytes,100,provenance);
  const coverage=result.coverage.find((c:any)=>c.facet==='swift_class_vtable_descriptors');
  const summary={mode,outcome:'returned',owners:result.swift_dispatch_slots.map((s:any)=>s.owner),status:coverage.status};
  console.log(JSON.stringify(summary));
  assert.notEqual(mode,'empty','Empty owner should fail on affected source');
  assert.deepEqual(summary.owners, mode==='valid'?['KeptClass','OtherClass']:['KeptClass']);
  assert.equal(summary.status,mode==='valid'?'complete':'partial');
  if (mode==='unmapped') assert.match(coverage.reason,/vtable_decode_failed at 0x100000204/);
 } catch (e:any) {
  console.log(JSON.stringify({mode,outcome:'threw',name:e.name,issues:e.issues?.map((i:any)=>({code:i.code,path:i.path}))??e.message}));
  assert.equal(mode,'empty'); assert.equal(e.name,'ZodError');
  assert.deepEqual(e.issues.map((i:any)=>({code:i.code,path:i.path})),[{code:'too_small',path:['swift_dispatch_slots',1,'owner']}]);
 }
}

Run:

"$REA_CHECKOUT/node_modules/.bin/tsx" repro.mts

I ran this script once per source baseline below against prepared locked dependency installations, using the default-branch checkout's tsx runner for both; every assertion passed and both processes exited 0. Fresh clone/dependency installation is a prerequisite, not part of the observed decoder checks. For the release comparison, select its source revision in a second checkout and set REA_CHECKOUT to it while using the same runner.

Expected behavior

Retain the valid KeptClass vtable and report the malformed second descriptor through partial coverage with a located failure. The decoder's existing per-descriptor catch already records vtable_decode_failed, and the unmapped-name control demonstrates this recovery behavior. The repository's partial-fact preservation requirement supports retaining independent facts when a descriptor is malformed.

Impact and scope

One malformed class-name string causes this static metadata operation to return no result despite a valid sibling. The observed failure is bounded to this input shape. The probe establishes loss of the valid sibling vtable from the returned result; it does not measure real-world frequency or test other metadata families. A workaround preserving the original bytes and the same metadata result was not verified.

REA version

Published rea-agents 6.5.0 source at 3b79af28e80785afce9f5998d8f03289402a1847, and current main at 8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd.

Runtime and platform

Node.js v24.18.0; macOS 27.0.1 arm64. No Hopper or Ghidra involved.

Target and artifact context

Synthetic 4096-byte arm64 Mach-O with two Swift class descriptors and one vtable slot each. Static byte parsing only; no target code executed.

Interface

Other

Direct production TypeScript byte-decoder API. The file-inspection wrapper calls this decoder, as established by source inspection; public adapters were not executed.

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Both revisions printed exactly:

{"mode":"valid","outcome":"returned","owners":["KeptClass","OtherClass"],"status":"complete"}
{"mode":"empty","outcome":"threw","name":"ZodError","issues":[{"code":"too_small","path":["swift_dispatch_slots",1,"owner"]}]}
{"mode":"unmapped","outcome":"returned","owners":["KeptClass"],"status":"partial"}

These observations establish the failure and controls at the production byte-decoder boundary. Public CLI/MCP invocation, compiler-produced Swift artifacts, and real-artifact prevalence were not tested.

Typed outcome or recovery expected

Return an ObjcSwiftMetadata result containing the valid sibling slot and a partial swift_class_vtable_descriptors facet with a located failure for the malformed descriptor. A restoration check should use the unchanged empty-name fixture and require this result, while retaining the two existing controls.

Analysis or suspected cause

The raw string reader accepts a mapped empty string. The vtable decoder reads and stores its owner without checking nonemptiness. The slot schema requires a nonempty owner, so final whole-result validation throws outside the descriptor's catch. This cause follows from source inspection and the control comparison.

Additional context

The related closed Objective-C class report, closed protocol report, and open category report concern different record decoders and schema fields. The category PRs #4120 and #4119 change AppleObjcCategories.ts, not AppleSwiftVtables.ts; the protocol PR #4078 changes AppleObjcProtocols.ts. Their fixes do not cover this Swift class-vtable owner case. Open/closed issue and PR body searches found no matching Swift-owner report. This case is independent of empty Swift conformance type/protocol names: its fixture has class-vtable descriptors and no conformance records.

This report came from AI-assisted source review with local reproduction where stated above. No target code was executed.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A malformed class-name string reproducibly aborts the static metadata decoder and prevents returning a valid sibling vtable. The demonstrated consequence is bounded metadata-analysis failure, without evidence of broader data loss or real-artifact prevalence.
  • Workaround status: unverified
  • Workaround basis: No alternative preserving the unchanged artifact and the same binary metadata result was exercised; changing its name or omitting records would change the evidence or lose required facts.
Originally created by @coygeek on GitHub (Oct 16, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4369 ### 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 Calling the production `decodeAppleDispatchMetadata` byte decoder on a synthetic arm64 Mach-O containing two class vtables throws `ZodError` when the second class name is an immediately NUL-terminated string. The error identifies `swift_dispatch_slots[1].owner`; no metadata result is returned, including the independently valid first vtable. The current default-branch and published-release sources below reproduce this behavior. This is an executed production byte-decoder check without substituted readers. Public CLI/MCP behavior was not executed; file-inspection reachability is source-derived. ### Reproduction Use Node.js 24.18.0 and npm 11.16.0, matching the repository pins. Prepare a source checkout and its locked dependencies: ```sh git clone https://github.com/morluto/rea.git rea-source cd rea-source git checkout --detach 8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd npm ci export REA_CHECKOUT="$PWD" cd .. ``` Save this complete script as `repro.mts` beside the checkout. It constructs a mapped arm64 Mach-O with two non-generic, non-resilient Swift class vtables. Cases differ only in the second class name or its pointer. ```ts import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; const root = process.env.REA_CHECKOUT; if (!root) throw new Error('Set REA_CHECKOUT to the source checkout'); const { decodeAppleDispatchMetadata } = await import(pathToFileURL(resolve(root, 'src/native/AppleDispatchMetadata.ts')).href); // A mapped 4096-byte arm64 Mach-O with two non-generic, non-resilient // Swift class descriptors, one synchronous vtable slot per descriptor. function fixture(mode: 'valid' | 'empty' | 'unmapped') { const b = Buffer.alloc(4096), base = 0x100000000n; const u32 = (at:number,n:number) => b.writeUInt32LE(n,at); const ptr = (at:number,n:number) => b.writeBigUInt64LE(base+BigInt(n),at); const rel = (at:number,n:number) => b.writeInt32LE(n-at,at); u32(0,0xfeedfacf); u32(4,0x0100000c); u32(12,2); u32(16,1); u32(20,152); u32(32,0x19); u32(36,152); b.write('__TEXT',40); ptr(56,0); b.writeBigUInt64LE(4096n,64); b.writeBigUInt64LE(4096n,80); u32(88,5); u32(92,5); u32(96,1); b.write('__swift5_types',104); b.write('__TEXT',120); ptr(136,0x200); b.writeBigUInt64LE(8n,144); u32(152,0x200); for (const [field,descriptor,name,implementation] of [[0x200,0x300,0x500,0x800],[0x204,0x380,0x520,0x810]]) { rel(field,descriptor); u32(descriptor,0x80000010); rel(descriptor+8,name); u32(descriptor+28,1); u32(descriptor+44,0); u32(descriptor+48,1); u32(descriptor+52,0); rel(descriptor+56,implementation); } b.write('KeptClass\0',0x500); if (mode==='valid') b.write('OtherClass\0',0x520); if (mode==='unmapped') rel(0x388,0x5000); return b; } for (const mode of ['valid','empty','unmapped'] as const) { const bytes=fixture(mode), provenance={path:'/synthetic/swift-vtable.macho',sha256:createHash('sha256').update(bytes).digest('hex')}; try { const result=decodeAppleDispatchMetadata(bytes,100,provenance); const coverage=result.coverage.find((c:any)=>c.facet==='swift_class_vtable_descriptors'); const summary={mode,outcome:'returned',owners:result.swift_dispatch_slots.map((s:any)=>s.owner),status:coverage.status}; console.log(JSON.stringify(summary)); assert.notEqual(mode,'empty','Empty owner should fail on affected source'); assert.deepEqual(summary.owners, mode==='valid'?['KeptClass','OtherClass']:['KeptClass']); assert.equal(summary.status,mode==='valid'?'complete':'partial'); if (mode==='unmapped') assert.match(coverage.reason,/vtable_decode_failed at 0x100000204/); } catch (e:any) { console.log(JSON.stringify({mode,outcome:'threw',name:e.name,issues:e.issues?.map((i:any)=>({code:i.code,path:i.path}))??e.message})); assert.equal(mode,'empty'); assert.equal(e.name,'ZodError'); assert.deepEqual(e.issues.map((i:any)=>({code:i.code,path:i.path})),[{code:'too_small',path:['swift_dispatch_slots',1,'owner']}]); } } ``` Run: ```sh "$REA_CHECKOUT/node_modules/.bin/tsx" repro.mts ``` I ran this script once per source baseline below against prepared locked dependency installations, using the default-branch checkout's `tsx` runner for both; every assertion passed and both processes exited 0. Fresh clone/dependency installation is a prerequisite, not part of the observed decoder checks. For the release comparison, select its source revision in a second checkout and set `REA_CHECKOUT` to it while using the same runner. ### Expected behavior Retain the valid `KeptClass` vtable and report the malformed second descriptor through partial coverage with a located failure. The decoder's existing [per-descriptor catch](https://github.com/morluto/rea/blob/8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd/src/native/AppleSwiftVtables.ts#L136-L147) already records `vtable_decode_failed`, and the unmapped-name control demonstrates this recovery behavior. The repository's [partial-fact preservation requirement](https://github.com/morluto/rea/blob/8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd/AGENTS.md#L31) supports retaining independent facts when a descriptor is malformed. ### Impact and scope One malformed class-name string causes this static metadata operation to return no result despite a valid sibling. The observed failure is bounded to this input shape. The probe establishes loss of the valid sibling vtable from the returned result; it does not measure real-world frequency or test other metadata families. A workaround preserving the original bytes and the same metadata result was not verified. ### REA version Published rea-agents 6.5.0 source at `3b79af28e80785afce9f5998d8f03289402a1847`, and current main at `8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd`. ### Runtime and platform Node.js v24.18.0; macOS 27.0.1 arm64. No Hopper or Ghidra involved. ### Target and artifact context Synthetic 4096-byte arm64 Mach-O with two Swift class descriptors and one vtable slot each. Static byte parsing only; no target code executed. ### Interface Other Direct production TypeScript byte-decoder API. The file-inspection wrapper calls this decoder, as established by [source inspection](https://github.com/morluto/rea/blob/8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd/src/native/AppleDispatchMetadata.ts#L215-L229); public adapters were not executed. ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Both revisions printed exactly: ```json {"mode":"valid","outcome":"returned","owners":["KeptClass","OtherClass"],"status":"complete"} {"mode":"empty","outcome":"threw","name":"ZodError","issues":[{"code":"too_small","path":["swift_dispatch_slots",1,"owner"]}]} {"mode":"unmapped","outcome":"returned","owners":["KeptClass"],"status":"partial"} ``` These observations establish the failure and controls at the production byte-decoder boundary. Public CLI/MCP invocation, compiler-produced Swift artifacts, and real-artifact prevalence were not tested. ### Typed outcome or recovery expected Return an `ObjcSwiftMetadata` result containing the valid sibling slot and a partial `swift_class_vtable_descriptors` facet with a located failure for the malformed descriptor. A restoration check should use the unchanged empty-name fixture and require this result, while retaining the two existing controls. ### Analysis or suspected cause The [raw string reader](https://github.com/morluto/rea/blob/8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd/src/native/AppleDispatchMetadata.ts#L69-L79) accepts a mapped empty string. The vtable decoder [reads and stores its owner without checking nonemptiness](https://github.com/morluto/rea/blob/8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd/src/native/AppleSwiftVtables.ts#L68-L91). The [slot schema requires a nonempty owner](https://github.com/morluto/rea/blob/8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd/src/domain/native/objcSwiftMetadata.ts#L199-L211), so [final whole-result validation](https://github.com/morluto/rea/blob/8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd/src/native/AppleDispatchMetadata.ts#L180) throws outside the descriptor's catch. This cause follows from source inspection and the control comparison. ### Additional context The related closed [Objective-C class report](https://github.com/morluto/rea/issues/2611), closed [protocol report](https://github.com/morluto/rea/issues/3873), and open [category report](https://github.com/morluto/rea/issues/4115) concern different record decoders and schema fields. The category PRs [#4120](https://github.com/morluto/rea/pull/4120) and [#4119](https://github.com/morluto/rea/pull/4119) change `AppleObjcCategories.ts`, not `AppleSwiftVtables.ts`; the protocol PR [#4078](https://github.com/morluto/rea/pull/4078) changes `AppleObjcProtocols.ts`. Their fixes do not cover this Swift class-vtable owner case. Open/closed issue and PR body searches found no matching Swift-owner report. This case is independent of empty Swift conformance type/protocol names: its fixture has class-vtable descriptors and no conformance records. This report came from AI-assisted source review with local reproduction where stated above. No target code was executed. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: A malformed class-name string reproducibly aborts the static metadata decoder and prevents returning a valid sibling vtable. The demonstrated consequence is bounded metadata-analysis failure, without evidence of broader data loss or real-artifact prevalence. - Workaround status: unverified - Workaround basis: No alternative preserving the unchanged artifact and the same binary metadata result was exercised; changing its name or omitting records would change the evidence or lose required facts.
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#1726
No description provided.