Skip to content

Code Audit Findings — Hunt for Wrapper-Like Silent Bugs

Date: 2026-04-13 (session 7) Context: After finding the SDR+104 read overflow that hid for multiple sessions, we ran a systematic audit to find similar latent bugs. Files audited: iosmux_inject.m, iosmux_xpc_proxy.m, iosmux_xpc_wire.c, iosmux_md_proxy.m

Severity Legend

  • CRITICAL: silent corruption or inevitable failure on any code reordering
  • HIGH: likely silent corruption, "works" only by accident
  • MEDIUM: edge cases, OOM, malformed input, timing windows
  • LOW: theoretical or cosmetic

CRITICAL

C1. g_hook_wrapper baked as immediate in CDS+0xCCB0 / CDS+0x5E2D0 hook pages

Files: iosmux_inject.m:1207–1213, 1339–1345

Both CDS+0xCCB0 and CDS+0x5E2D0 hook pages bake the current value of g_hook_wrapper as an immediate constant at install time:

hc[ho++] = 0x48; hc[ho++] = 0xb8;
uint64_t wp = (uint64_t)g_hook_wrapper;
memcpy(hc + ho, &wp, 8); ho += 8;

Problem: hook is installed in Step 12, but g_hook_wrapper is set in Step 9b (after RSDDeviceWrapper.init). If install order changes, or if Step 9b fails and Step 12 still runs, the hook bakes in NULL or stale value. The recent wrapper fix happens to work because the order is correct TODAY — but a trivial edit (like enabling a previously-disabled step, or reordering for a retry) reintroduces the same class of silent bug we just fixed.

This is the exact pattern that caused the SDR+104 bug: a value captured at "wrong time" and baked into a place that doesn't update when the source changes.

Fix: machine code should do mov rax, [rip+global_addr]; ret instead of mov rax, <immediate>; ret. Alternatively, assert g_hook_wrapper != NULL before installing, and re-install on update.

C2. Hardcoded orig_target = cds_base + 0xB0ECE in CDS+0x5E2D0 hook

File: iosmux_inject.m:1298

uint64_t orig_target = cds_base + 0xB0ECE; // original callq target

Hardcoded offset. Different from Step 19 which correctly reads the rel32 displacement from the patched callq. Any CoreDevice version bump changes the offset. Silent breakage.

Fix: compute from the rel32 at callq_addr+1 like Step 19 does.

C3. (formerly C3 merged into C1, see above)

C4. iosmux_hook_with_wrapper / iosmux_with_wrapper_replacement dead code

File: iosmux_inject.m:498–507

Dead code (not called in current flow), but uses wrong Swift calling convention (treats Swift thick closure as C function pointer). Danger: a future developer re-enables it and gets silent crash.

Fix: wrap in #if 0 with explanation comment.

C5. Inline asm block in updateIdentifier call has incomplete clobber list

File: iosmux_inject.m:1014–1031

pushq %%r13
pushq %%rbx
pushq %%r12
movq  %[a3], %%r13
...
callq *%[fn]
popq  %%r12 / %%rbx / %%r13 / %%rbp

Clobber list doesn't mention r12, r13, rbx even though we use them. GCC/Clang may cache values in these registers across the asm block — garbage after.

Fix: add "r12","r13","rbx" to clobber list, or convert to __attribute__((naked)) helper like other Swift ABI wrappers.

C6. connected_callback block invoke without type/retain check

File: iosmux_inject.m:1073–1075

void *cb = ((void **)rd_base)[11]; // offset 88
void (*invoke)(void *) = ((void **)cb)[2]; // block invoke function
invoke(cb);

Reads offset 88 as a Block pointer, blindly assumes ABI.2020 layout (invoke at offset 2 / 0x10), invokes without Block_copy. If CDS releases the block before our 3-second polling loop — use-after-free.

Fix: check isa signature (_NSConcreteStackBlock / _NSConcreteMallocBlock), Block_copy(cb) before invoke, release after.

HIGH

H1. create_remote_device direct ivar writes by absolute offset

File: iosmux_inject.m:581–621

((char **)base)[1] = strdup("iPhone (iosmux)");   // offset 8
*(uint32_t *)((uint8_t *)base + 28) = 2;          // offset 28
*(uint32_t *)((uint8_t *)base + 32) = 14;         // offset 32
((void **)base)[5] = (void *)dq;                  // offset 40
((void **)base)[7] = device_uuid;                 // offset 56 — STORES POINTER
((uint64_t *)base)[9] = 72057594037927942ULL;     // offset 72
((void **)base)[10] = conn;                       // offset 80

Issues:

  1. No class_getInstanceSize() verification before writing at these offsets. The code logs ivars but doesn't check that the object is big enough. Exact same pattern that caused the SDR+104 bug.
  2. UUID slot at offset 56 stores a uint8_t * pointer, not inline 16 bytes. If the _uuid ivar has type uuid_t (16-byte inline array), we're writing a pointer where inline data should go. Consumers reading ((uuid_t*)&ivar)[i] get the low 8 bytes of our stack pointer instead of UUID bytes.
  3. strdup leak (minor).

Fix: use ivar_getOffset(class_getInstanceVariable(RDClass, "_uuid")) instead of hardcoded offsets, and for _uuid do memcpy(base + off, device_uuid, 16), not pointer assignment.

H2. SWIFT_BRIDGED_STRING tag bits likely wrong

File: iosmux_inject.m:745–751

#define SWIFT_BRIDGED_STRING(nsstr) ({ \
    swift_optional_string_t _r; \
    size_t _len = [(nsstr) length]; \
    _r.word0 = 0xC000000000000000ULL | _len; \
    _r.word1 = 0xC000000000000000ULL | (uint64_t)(nsstr); \
    _r; \
})

Swift String _StringObject discriminator bits for bridged NSString are likely 0x4000000000000000 (ObjC-bridged flag), not 0xC0. Also [nsstr length] returns UTF-16 length, but Swift String caches UTF-8 byte count. Silent mismatch: setter writes OK, reader crashes later on strlen-like iteration.

Used for marketingName = "iPhone SE (3rd generation)". If this bites: random garbage in Xcode UI, or strlen crash in Mercury Codable encoding path.

Fix: use small-string representation where possible (though 26 chars exceeds 15-char inline limit), or skip marketingName entirely, or call through a proper String(_:) constructor via Swift helper.

H3. preparedness_setter direct-vs-indirect ABI unverified

File: iosmux_inject.m:843–845

int64_t prep_val = 0xF;
iosmux_call_setter(preparedness_setter, &prep_val, NULL, device_info_buf);

iosmux_call_setter puts arg1 in RDI. For a Swift OptionSet (Int rawValue), the setter likely takes the value directly in RDI — not a pointer to it. Passing &prep_val → setter writes the stack address as the rawValue → future reads get a random bit pattern.

Inconsistent with are_ddi_setter right above (H3's neighbor):

iosmux_call_setter(are_ddi_setter, (void *)1, NULL, device_info_buf);
// direct value ↑

One of these must be wrong. Swift bool setter takes value directly; Swift Int OptionSet setter also takes value directly unless the type is wrapped. Need to disassemble the specific setter to confirm.

Fix: iosmux_call_setter(preparedness_setter, (void*)(intptr_t)0xF, NULL, device_info_buf); — after disassembly confirmation.

H4. Enum setters pass &val where value semantics expected

File: iosmux_inject.m:801–825

uint8_t val = 2;
iosmux_call_setter(pairing_setter, &val, NULL, device_info_buf);

Same issue as H3 but for pairingState, visibilityClass, state. Single-tag no-payload Swift enums pass as small Int in RDI (loaded via movzbl). Passing &val → setter reads the stack pointer as Int → low 8 bits happen to be low byte of address → might randomly match desired enum tag.

If this is the bug, behavior depends on stack layout at call time. Bad.

Fix (after disassembly verification): iosmux_call_setter(pairing_setter, (void*)(intptr_t)2, NULL, device_info_buf);

H5. (cross-reference to H3/H4 — verify each setter individually with disassembly)

For each Swift setter symbol, disassemble first 8 bytes and confirm direct vs indirect. Add inline comment // VERIFIED direct or // VERIFIED indirect next to each call. Currently all inferred without verification.

H6. iosmux_sdr_lookup_replacement reads 16 bytes from uuid_ptr that may be a value

File: iosmux_inject.m:342–353

if (uuid_ptr) {
    uuid_lo = ((uint64_t *)uuid_ptr)[0];
    uuid_hi = ((uint64_t *)uuid_ptr)[1];
}

Called from CDS+0xCCB0 thunk. For Swift serviceDeviceRepresentations(forDeviceIdentifiedBy: Foundation.UUID), the UUID is a 16-byte value type — on x86_64 Swift passes it in (rdi, rsi), NOT as an indirect pointer in rdi.

If this is true, our thunk receives the low 8 bytes of the UUID in RDI and treats it as a pointer, then dereferences it. "Works" when the low 8 bytes happen to be a readable address (which they can be for UUIDs starting with bytes that look like pointers in the shared cache region).

Fix: verify ABI with disassembly. If value-type passing is confirmed, rebuild the thunk to construct the 16-byte UUID from (rdi, rsi) directly:

uuid_lo = rdi_value;
uuid_hi = rsi_value;
or update the thunk to capture both registers before calling the C function.

H7. NSData dataWithContentsOfURL: blocking without timeout

File: iosmux_xpc_proxy.m:463, 561

Blocking sync HTTP request to Go relay. No timeout. If Go relay doesn't respond, blocks for default 60s, shifts constructor+init pipeline by a minute.

Severity: MEDIUM in practice (Go relay is deprecated anyway), but cleanup warranted.

Fix: NSURLSession with dataTaskWithCompletionHandler + 2s timeout.

MEDIUM

M1. buf_grow doesn't check realloc return

File: iosmux_xpc_wire.c:43–48

while (b->len + need > b->cap) {
    b->cap *= 2;
    b->buf = realloc(b->buf, b->cap);
}

NULL return from realloc: leaks old buffer, next memcpy crashes on NULL. Also cap *= 2 can overflow on huge inputs.

Fix: check return, cap at some reasonable limit (64MB).

M2. decode_dict doesn't bound-check strings

File: iosmux_xpc_wire.c:289–300

const char *key = (const char *)(r->data + r->pos);
uint32_t klen = (uint32_t)strlen(key) + 1;
r->pos += klen;

If wire message is malformed (no null terminator), strlen reads past the buffer — potential info leak or SIGSEGV. Data is network-driven from RSD peer.

Fix: bounded memchr for null within the remaining buffer.

M3. decode_dict / decode_array don't bound the nested parse to total_len

File: iosmux_xpc_wire.c:283–286, 305

total_len read but not used to limit further parsing. Nested malformed dict can over-read.

Fix: pass a bounded reader context into recursive decode.

M4. relay_fds uses volatile without barriers

File: iosmux_xpc_proxy.m:103–113

Actually fine because sources run on serial queue → no races.

M5. read_len_prefixed blocking read without timeout

File: iosmux_xpc_proxy.m:176–191

If Go relay stalls, whole CDS hangs on blocking read. Go relay being deprecated — less urgent.

M6. fetch_device_properties cached result race

File: iosmux_md_proxy.m:30–44

Concurrent callers from different queues may race on the cache. ARC may lose a reference under concurrent writes. Minor.

Fix: dispatch_once for the fetch.

M7. iosmux_fetch_services doesn't check nil from [info[@"name"] UTF8String]

File: iosmux_xpc_proxy.m:524

NULL src to strlcpy crashes. Also deprecated (Go relay).

M8. 128-listener limit silently leaks retain at overflow

File: iosmux_xpc_proxy.m:206–210

After 128 services, listeners aren't retained, released prematurely → use-after-free in XPC event handler. Unlikely to hit 128 services in PoC, but classic latent UAF.

Fix: remove the array and just xpc_retain leak-once, or use a dynamic array.

M9. malloc(40) for dev_id_heap unchecked

File: iosmux_inject.m:1095

Minor.

M10. find_instance_of_class won't work on arm64e due to PAC

File: iosmux_inject.m:281–289

Reads raw isa pointer and compares to Class. On arm64e the isa is PAC-signed — comparison fails. OK on current x86_64 macOS VM, breaks on arm64e port.

LOW

L1. Inconsistent dlsym NULL-check discipline

Some uses check, some don't. Not outright broken today, but pattern to enforce.

L2. Hook mmap pages never munmap

Leaks across reinstalls. Accumulates. Not a bug today (constructor runs once).

L3. xpc_copy_description result leaks in LOG

File: iosmux_xpc_proxy.m:215, 219

Memory leak in log lines. Minor.

L4. interpose_remote_device_heartbeat hardcoded _Bool callback signature

File: iosmux_inject.m:63–69

Correct for current callback shape. Silent mismatch if signature changes.

L5. g_interpose_service_names lazy init race

Initialized in Step 8c, read from interpose. If heartbeat fires before Step 8c, xpc_retain(NULL) crash. Unlikely with 3-second delay.

Summary of the SDR+104 pattern found elsewhere

Three bugs of the exact same class as SDR+104:

  1. C1 — value baked as immediate at hook install time from a variable that may change later. Order-of-operations sensitivity without assertion.
  2. H1 — hardcoded ivar offsets without class_getInstanceSize verification, plus UUID written as pointer where inline bytes expected.
  3. H6 — treating a register that holds a value (via Swift ABI) as if it held a pointer to that value, then dereferencing. "Works" when the value happens to look like a readable address.

All three currently "work" only because Apple's surrounding code happens to not trigger the path that reads/writes the corrupted data. Any version bump, hook install order change, or async scheduling change → silent corruption.

Plus suspected but disasm-needed ABI bugs H3 and H4 (Swift enum setters called via &val vs value).

Immediate fix priority

# Effort Severity Fix
1 5 min CRIT C2 — compute orig_target from rel32 instead of hardcoded
2 15 min CRIT C1 — mov rax, [rip+global] instead of immediate bake; assert non-NULL
3 15 min HIGH H1 — ivar_getOffset() + memcpy for uuid bytes
4 1 min CRIT C4 — #if 0 dead code
5 30 min HIGH H3/H4 — disassemble each setter, fix direct/indirect
6 30 min HIGH H6 — disassemble serviceDeviceRepresentations, fix UUID extraction
7 5 min CRIT C5 — add registers to asm clobber list
8 10 min CRIT C6 — Block_copy in connected_callback polling
9 30 min MED M1-M3 — wire decoder input validation
10 5 min MED M8 — remove 128-listener array

What I did NOT verify (requires disassembler)

  • Exact ABI of each Swift setter symbol (direct vs indirect) for H3/H4
  • Exact ABI of serviceDeviceRepresentations(forDeviceIdentifiedBy:) for H6
  • Current OS_remote_device ivar offsets in iOS 26.4.1 (code logs them; should just use the logged values instead of hardcoded)
  • Swift String bridged NSString layout for H2

Runtime verification via test deploy + otool -tv would answer all of these in under an hour.