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
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:
- 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. - UUID slot at offset 56 stores a
uint8_t *pointer, not inline 16 bytes. If the_uuidivar has typeuuid_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. strdupleak (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
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):
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
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
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:
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
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:
- C1 — value baked as immediate at hook install time from a variable that may change later. Order-of-operations sensitivity without assertion.
- H1 — hardcoded ivar offsets without
class_getInstanceSizeverification, plus UUID written as pointer where inline bytes expected. - 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_deviceivar 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.