StaticDeviceRepresentationBrowser — Disassembly Research¶
Session: s1c follow-up.
Host: read-only ssh to havoc (macOS VM).
Binaries inspected:
- /Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/CoreDevice
- /Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/XPCServices/CoreDeviceService.xpc/Contents/MacOS/CoreDeviceService (CDS)
Q1 — Verify StaticDeviceRepresentationBrowser exists¶
Location: CoreDeviceService (CDS) binary only. Not in the CoreDevice framework itself.
Mangled name (ObjC runtime): _TtC17CoreDeviceService33StaticDeviceRepresentationBrowser
Demangled: CoreDeviceService.StaticDeviceRepresentationBrowser
Symbols found in CDS:
0x1000d4e08 _OBJC_CLASS_$__TtC17CoreDeviceService33StaticDeviceRepresentationBrowser
0x1000d4dc8 _OBJC_METACLASS_$__TtC17CoreDeviceService33StaticDeviceRepresentationBrowser
0x1000d2a78 __DATA__TtC17CoreDeviceService33StaticDeviceRepresentationBrowser
0x1000d2a50 __IVARS__TtC17CoreDeviceService33StaticDeviceRepresentationBrowser
0x1000b5f10 _OBJC_IVAR_$__TtC17CoreDeviceService33StaticDeviceRepresentationBrowser.deviceRepresentations
ObjC class data dump (otool -ov):
data 0x1000d2a7a Swift class
flags 0x80
instanceStart 16
instanceSize 24 ← ONLY 24 bytes
name _TtC17CoreDeviceService33StaticDeviceRepresentationBrowser
baseMethods 0x0 ← NO ObjC methods exposed
baseProtocols 0x0
ivars __IVARS... ← 1 ivar
offset 16 name=deviceRepresentations size=8 alignment=3
baseProperties 0x0
Class instance size: 24 bytes. 16-byte Swift class header + single 8-byte ivar deviceRepresentations at offset 16. Contrast with RestorableDeviceRefDeviceRepresentationBrowser (instanceSize 32, 2 ivars _state + _mobileDeviceInteractionQueue).
Public methods: baseMethods=0x0 — no ObjC-dispatched methods. All protocol conformance goes through Swift witness tables. All CDS-internal Swift symbols are stripped (nm -a reports 1651 entries as <redacted function NNN>, including every method on this class), so individual method addresses cannot be looked up by name from the binary.
The only class-related symbol that survived is the symbolic reference at 0x1000b791c: _symbolic _____ 17CoreDeviceService06StaticB21RepresentationBrowserC.
Q2 — Instantiation site of StaticDeviceRepresentationBrowser in CDS startup¶
Result: not locatable by symbol from the current binary.
Rationale: the class has no ObjC +alloc/+init exposure, and every Swift __allocating_init and caller in the CDS module is stripped to <redacted function N>. nm -a shows only the symbolic-reference entry. No cross-references to the class symbol survive in a form that can be resolved without a full textual disassembly + control-flow recovery (which is beyond the scope of this read-only session).
The only practical dynamic approach from the injector:
- Scan _OBJC_CLASS_$__TtC17CoreDeviceService33StaticDeviceRepresentationBrowser at address 0x1000d4e08 (absolute after slide) and hook objc_allocWithZone / swift_allocObject while filtering by that class pointer
- Or, after CDS has finished startup, find the instance by reading SDM's browsers array (see Q5)
The research-doc claim that CDS instantiates "three browsers at startup" is consistent with the existence of the class, but the exact callsite chain (main → setup_browsers → Static.init) cannot be confirmed from stripped symbols alone.
Q3 — Ivar layout of StaticDeviceRepresentationBrowser¶
From the __IVARS__ list dumped above:
| offset | name | size | type |
|---|---|---|---|
| 0x00 | isa / Swift class hdr | 16 | (standard) |
| 0x10 | deviceRepresentations | 8 | Swift Array<…> |
Total: 24 bytes. This is the whole class.
There is no stored discovery callback ivar. There is no secondary array, no queue, no lock field, no state field. The class has exactly one storage slot: an 8-byte Swift Array bridge-object pointer at offset 16, holding the precomputed device representations.
Implication for Q4: since start(...) receives a callback parameter but the class has nowhere to persist it, the callback MUST be consumed synchronously inside the body of start(...) or passed off into an async task captured in a heap closure. It cannot be stored on the browser for later passive use.
Q4 — start(on:invokingWithDiscoveredDeviceRepresentation:invokingIfCancelled:)¶
Protocol symbol (CoreDevice, external):
0x256540 _$s10CoreDevice0B21RepresentationBrowserP5start2on022invokingWithDiscoveredbC00G11IfCancelled...Fj
<redacted function N> entries — we cannot locate its exact address by symbol.
Indirect evidence (strong) about its behavior:
Given Q3 — the class has NO storage for a callback — start(...) has only two possible shapes:
- Synchronous iteration. Walk
self.deviceRepresentationsand invoke the discovery callback for each element, then return. - Task-enqueue. Wrap the callback in a heap-allocated closure box and schedule an async task on the passed-in queue. The closure captures
(self, callback)and on execution walksdeviceRepresentationsand invokes the callback.
Both shapes result in active, one-shot invocation — there is no passive waiting state. Once start(...) completes (or its enqueued task runs), the browser forgets the callback.
Implication for iosmux: Appending a new entry to deviceRepresentations after start(...) has already been called will NOT automatically cause that entry to be reported to SDM — nothing is waiting on the array. To register a late device, the injector has to invoke the discovery callback directly. That callback does not live on the Static browser; it lives in SDM, which leads us to Q5.
Q5 — Where ServiceDeviceManager stores the browser list and the discovery callback¶
install(browser:)¶
Symbol: _$s10CoreDevice07ServiceB7ManagerC7install7browseryAA0B21RepresentationBrowser_p_tF
Address in CoreDevice: 0x27d7d0
Prologue:
0x27d7d0 pushq %rbp
0x27d7d1 movq %rsp, %rbp
...
0x27d7ec movq %rdi, %r14 ; r14 = self (SDM)
0x27d7e8 movq %rsi, -0x38(%rbp) ; save browser witness table
Flow:
- Log
"Installing browser into service device manager: %{public}s"(literal at0xf9d65(%rip)from0x27d954). Confirms we have the right function. - Allocate three Swift heap closures (
swift_allocObjectsize 0x18 / 0x28 / 0x18 / 0x30) and weak-init them with%r14(= SDM self). These are the(weak self, browser)capture contexts used to invoke the discovery callback later. This is the closure construction for the browser's discovery callback. - Indirect
callq *-0x40(%rbp)at0x27db56— this is the Swift protocol-witness call intobrowser.start(on:invokingWithDiscoveredDeviceRepresentation:invokingIfCancelled:), with the heap-allocated closure passed as the second-word capture of the discovery-callback existential. SDM callsbrowser.start(...)from insideinstall(browser:). - On success (
je 0x27dd4a), append the browser to a list:0x27dd4a movq -0x58(%rbp), %rax 0x27dd4e movq 0x28(%rax), %r12 ; rax→0x28 = "browsers list" wrapper 0x27dd52 leaq 0x18(%r12), %r14 0x27dd57 callq _os_unfair_lock_lock ; wrapper+0x18 = unfair lock 0x27dd5f movq 0x10(%r12), %rbx ; wrapper+0x10 = Swift Array buffer 0x27dd64 callq _swift_isUniquelyReferenced ; COW 0x27dd79 movq 0x10(%rbx), %r13 ; array.count 0x27dd7d movq 0x18(%rbx), %rax ; array.capacity ...grow if needed... 0x27dd94 movq %r15, 0x10(%rbx) ; count = count+1 0x27dd98 shlq $0x4, %r13 ; ×16 (existentials = 2 words) 0x27dd9c movq -0x68(%rbp), %rdi ; the new element word 0 0x27dda0 movq %rdi, 0x20(%rbx,%r13) ; store at buf+0x20+idx*16 0x27dda5 movq -0x38(%rbp), %rax ; witness table 0x27dda9 movq %rax, 0x28(%rbx,%r13) ; store at buf+0x28+idx*16 0x27ddb3 callq _swift_unknownObjectRetain 0x27ddb8 callq _os_unfair_lock_unlock
Key findings about the container:
- SDM itself does not store the array inline. The array lives inside a separate wrapper object reached via some captured pointer at -0x58(%rbp) dereferenced at +0x28. The wrapper layout is:
- wrapper + 0x10 → Swift Array<DeviceRepresentationBrowser> bridge-object (count at buf+0x10, capacity at buf+0x18, elements begin at buf+0x20, each element is a 2-word existential {instance, witnessTable})
- wrapper + 0x18 → os_unfair_lock
- The array element type is Array<DeviceRepresentationBrowser> — confirmed by the 16-byte existential stride (shlq $0x4, %r13) and the -0x68/-0x38 split between instance pointer and witness table.
- SDM stores this wrapper in one of its own ivars. The strings found via strings in the CoreDevice binary confirm the ivar names:
- _browsers
- browserLock
- Strings also include browser, so SDM has three relevant stored properties. Without the CoreDevice __swift5_fieldmd field-descriptor walker (doable but more time than we had), I could not extract the exact numeric offsets of _browsers inside SDM itself. The instrumentation-friendly approach from the inject is:
- Read the disasm of install(browser:) live, grab -0x58(%rbp) at the point just before 0x27dd4a, or
- Hook install(browser:) directly and capture the browser existential plus the SDM self pointer
The discovery callback — where it lives¶
At CDS 0x286cc6 we find the string "Browser %{public}s discovered new device representation %s". The function containing that call is the closure body that SDM passes as the discovery callback to every browser. Salient shape:
- Signature (from closure prologue at
0x286b70):(%rdi=?, %rsi=?, %rdx=?, %rcx=capture-ctx), i.e. a Swiftpartial_applywith the capture context in%rcx. - After the log,
0x286d6f/0x286d7dcallswift_beginAccess+swift_weakLoadStrongon a slot in the capture context — weak self (SDM). This is the weak capture added at install time. - If the weak load succeeds, the closure calls a CoreDevice-internal function at
0x27e850withrdi = strongly-held SDM self, passing the discovered device. This is the real SDM discovery handler. 0x27e850(partial disasm inspected): takes an unfair lock atr12+0x50wherer12 = *(r13+0x10)(r13 coming via closure state), reads flagr12+0x40, refs at0x38and0x48, builds a 64-byte capture record with{x, SDM, state, r13}, and tail-calls the full registration routine at CoreDevice0x507f0. That is the function that eventually updatesmanagedServiceDevices.
Most direct runtime path from "we have g_sdm" to "our SDR appears in managedServiceDevices"¶
There are three viable paths, from most-to-least invasive:
Path A — Call the discovery callback directly.
1. From our inject, hook install(browser:) (address CoreDevice + 0x27d7d0).
2. Observe every call, capture the browser existential passed in. One of them will have isa == _OBJC_CLASS_$__TtC17CoreDeviceService33StaticDeviceRepresentationBrowser — cache that pointer as g_static_browser.
3. Also capture the heap-allocated discovery-callback closure SDM builds just before calling browser.start(...). Either by hooking the swift_allocObject(size=0x28) sequence inside install(browser:), or by grabbing argument 2 of the indirect witness call at 0x27db56, or — easiest — by hooking the StaticDeviceRepresentationBrowser's own start(...) method (which the thunk at CoreDevice 0x256540 dispatches into via the witness table slot).
4. To announce our virtual SDR: invoke the captured discovery closure with our ServiceDeviceRepresentation as argument. That closure drops into 0x27e850, which calls the full registration machinery that inserts into managedServiceDevices.
Path B — Append to the static browser's deviceRepresentations array before start(...) runs. Possible iff we can inject our entry before CDS startup finishes calling install(browser:) for Static. Requires racing CDS startup; fragile.
Path C — Walk SDM's _browsers array, find the static one, then call its start(...) again with our own callback that we fully control. Requires knowing _browsers ivar offset on SDM; we don't have it by name but could recover it via install(browser:) hook (the 1-arg hook sees SDM self and can compare before/after the function to find which ivar got mutated — or just trace -0x58(%rbp) at entry).
Blockers¶
- Stripped symbols in CDS. Every Swift function in the CDS module is anonymized (
<redacted function N>), so the exact address ofStaticDeviceRepresentationBrowser.start(...)and of its initializer cannot be resolved vianm. We must find them via runtime hooking (class pointer match onswift_allocObject) or via field-descriptor walking + vtable reading. install(browser:)local layout is async-function-like. On the success branch the append uses-0x58(%rbp), which at function entry was written from%r13. I could not determine from static disasm alone what%r13carried at entry — plausibly a Swiftpartial_apply-style implicit first-argument, or a task context. This doesn't block runtime hooking but makes a pure static derivation of the_browsersoffset on SDM unreliable.- Exactness of 0x27e850's registration semantics** not verified**. I confirmed it enters via the discovery closure and calls a bigger function at CoreDevice
0x507f0, but did not disasm0x507f0to confirm it mutatesmanagedServiceDevices. The name of the container (managedServiceDevices) is per the s1c research doc, not re-verified here.
No tool was missing on havoc. All primitives (nm, otool, strings, swift demangle) worked. dyld_info was not used; not needed for this task.
Q4 — install(browser:) timing vs inject ctor¶
Question: does our inject's __attribute__((constructor)) run BEFORE CoreDeviceService calls ServiceDeviceManager.install(browser:) for its three browsers? If not, we miss the install-time capture of the discovery callback closure.
Answer: YES. Our ctor runs approximately 66–70 ms before the first install(browser:) invocation. There is ample margin to install a synchronous hook on CoreDevice + 0x27d7d0 from inside the ctor and catch every install call.
Evidence — session 8, CoreDeviceService pid 1301, startup trace¶
From log show --predicate 'process == "CoreDeviceService"' --debug --info:
18:41:45.023 libsystem_info "Retrieve User by ID" ← process main() entry-ish
18:41:45.033 iosmux_inject "INJECT loaded in CoreDeviceService (pid=1301)" ← our ctor, +10 ms
18:41:45.044 iosmux_inject ServiceSidePairingSession class lookup ← our ctor still running
18:41:45.048 iosmux_inject "Swizzled ServiceSidePairingSession +alloc" ← end of synchronous ctor body
18:41:45.077 [pluginmanager] getPluginDirectories() error
18:41:45.082 [PluginLoader] findMatchingBundles ...
18:41:45.099 [coredevice:main] "Enabling AMRestorableDeviceRef device representations"
18:41:45.102 [restorabledeviceref] "RestorableDeviceRefDeviceRepresentationBrowser:
Registering for device notifications ..." ← this is INSIDE Restorable.start(...),
which SDM invoked from install(browser:)
- Δ(ctor → first install) ≈ 69 ms.
- dyld runs
LC_LOAD_DYLIBconstructors before handing control tomain(). CoreDevice/MobileDevice/plugin loader and the Swiftmainthat callsinstall(browser:)all run AFTER our ctor finishes. - Confirmed for 4 distinct CDS launches (pids 1301, 1785, 2233, 2299). In every case
INJECT loadedprecedes the first browser-related log line by tens of ms.
The symbol for our ctor is iosmux_inject_init in inject/iosmux_inject.m:1709. Its first action is the LOG("INJECT loaded …") call visible above.
Timing margin caveat — the 3-second dispatch_after¶
iosmux_inject_init currently does its real work via:
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC),
dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
iosmux_register_device();
});
By t+3000 ms all three install(browser:) calls have long completed (they run in the first ~100 ms). Anything we want to do at install time MUST be installed SYNCHRONOUSLY in the ctor body, not inside the dispatch_after block. The install-hook (on CoreDevice + 0x27d7d0) must be set up before iosmux_install_pair_session_swizzle() returns.
Static-browser reachability if we're too late¶
Per Q3/Q5 above: the StaticDeviceRepresentationBrowser has only the deviceRepresentations ivar (24-byte class, no callback slot). If the install hook is missed, the discovery callback closure is captured solely on SDM's side in the heap-allocated closure box constructed at 0x27d7f0–0x27db56 inside install(browser:). SDM does not store that box on the browser; it is passed into browser.start(...) as a parameter and consumed there. Walking SDM._browsers → browser does not recover it.
Mitigation paths (ranked)¶
- Primary (viable, recommended). Install the
install(browser:)hook in the ctor body beforedispatch_after. Capture all three browsers + their closure pointers. Keep the 3-second deferred work unchanged. Ctor has ~69 ms margin — more than enough for a single function-entry patch. - Fallback (if the primary hook ever gets displaced). Hook
swift_allocObjecttransiently during ctor+install window and filter for the 0x28-sized allocations insideinstall(browser:); these are the closure boxes. Higher noise. - Last resort (Path A in Q5). Synthesize a 16-byte
{funcptr, ctx}existential manually. Requires the closure invoke funcptr symbol (a redacted<function N>) which we'd have to locate by pattern-matching — currently unknown.
Path C ("re-invoke start on the static browser") is NOT viable: the three-browser list we'd want to iterate lives in SDM's _browsers wrapper whose exact offset is not yet derived, and re-calling start() would install a second callback in parallel to the real one, doubling discoveries.
Conclusion¶
Ctor-vs-install timing is not a blocker. The existing inject already runs early enough. The required code change is purely additive: add a trampoline install on CoreDevice + 0x27d7d0 in the synchronous portion of iosmux_inject_init, before the dispatch_after that schedules iosmux_register_device. No race, no need to defeat a deferred dispatch inside CDS — install(browser:) itself runs synchronously on the main thread at startup (confirmed: the Restorable browser logs from TID 6e99, same synchronous chain as the :main subsystem log at 18:41:45.099).
Q2 — SDM lifetime + closure capture safety¶
Research goal: decide whether capturing SDM's discovery-callback closure (funcptr+ctx with a weak-self capture) is safe across the injection timeline, or whether we need a self-retain / fresh-closure workaround. This supersedes the earlier "Q2 — Instantiation site" section above (narrower focus); that section remains for historical context.
SDM is a regular Swift class with a normal init/deinit¶
From nm on /Library/Developer/PrivateFrameworks/CoreDevice.framework/.../CoreDevice, demangled:
0x27cae0 CoreDevice.ServiceDeviceManager.__allocating_init(clientManager:
pluginManager: fullInitializationTime:) -> ServiceDeviceManager
0x2899a0 dispatch thunk of __allocating_init
0x27cb40 init(...)
0x287e10 CoreDevice.ServiceDeviceManager.deinit
Stored-property ivars (OBJC_IVAR_$_CoreDevice.ServiceDeviceManager.*): _state, _browsers, devicesUpdateHandlerQ, clientManager. Ordinary Swift class — it has a real deinit, so it is NOT a hard singleton by type. It can be retained/released from C via swift_retain/swift_release; its isa is standard (already used by our heap-scan object_setClass compare).
SDM is instantiated once, at CDS startup, by main¶
__allocating_init is called through the dispatch thunk at 0x2899a0 from CDS. The exact callsite is not statically recoverable — all CDS Swift symbols are stripped. In practice:
- Our heap scan (
inject/iosmux_inject.m:955-967) runs at ctor + 3 s and always finds exactly one instance, consistent across every PoC run → one SDM per CDS process, created during startup. - CDS holds SDM strongly from its root service graph. The closures built in
install(browser:)hold only weak SDM refs, so the strong ref must live elsewhere (the root object whose init calledinstall(browser:)in the first place). - SDM therefore lives for the entire CDS process lifetime.
deinitruns only at CDS teardown. There is no per-session / per-connection lifecycle that creates+destroys SDM.
Our inject holds a borrowed pointer, not a retained one¶
inject/iosmux_inject.m:966:
Raw pointer from heap enumeration — no swift_retain, no CFRetain. Safe today because (a) SDM lives until process exit and (b) we use sdm only synchronously within _init_iosmux. If we ever stash it in a global for deferred use, lack of our own retain becomes a latent defense-in-depth bug.
install(browser:) timing¶
Already answered in the Q4 section above: install(browser:) is invoked synchronously during CDS startup, ~69 ms after our ctor runs, on the main thread, from inside SDM's own init path. There is no lazy/on-demand install. For our hook we need only that DYLD_INSERT_LIBRARIES + __attribute__((constructor)) — which runs before main() — installs the trampoline before CDS reaches install(browser:). Satisfied.
Weak-self nil path in the closure body (CoreDevice 0x286b70)¶
Full disasm of the closure body (verified on havoc, otool -tV of CoreDevice):
0x286d6f leaq -0x88(%rbp), %rsi ; weak storage scratch
0x286d76 movq %r14, %rdi ; %r14 = ptr to weak capture slot in ctx
0x286d7d callq _swift_beginAccess
0x286d85 callq _swift_weakLoadStrong ; returns +1 strong, or 0
0x286d8a testq %rax, %rax
0x286d8d je 0x286da3 ; nil → SKIP to epilogue
0x286d8f movq %rax, %r13
0x286d92 movq -0x30(%rbp), %rdi ; discovered device-rep arg (strong-held earlier)
0x286d96 callq 0x27e850 ; SDM handleDiscovered helper (rdi=strong SDM)
0x286d9b movq %r13, %rdi
0x286d9e callq _swift_release
0x286da3 ...epilogue (pop, ret)
Nil branch is a clean no-op: on swift_weakLoadStrong returning 0, the closure jumps straight to its epilogue (0x286da3) without calling 0x27e850. No crash.
The earlier body (0x286bc2..0x286d4b) that emits the "Browser %{public}s discovered new device representation %s" log does NOT touch SDM self: it uses the browser existential stashed in -0x38(%rbp) / -0x58(%rbp), which the prologue strong-retained via swift_retain(-0x30(%rbp)) and swift_bridgeObjectRetain(%r15) at the top of the function. So the log path is self-contained and independent of SDM liveness. The only SDM-dependent call in the whole closure is 0x27e850, and it is correctly gated by the weak-load nil check.
Answer to Q4 (weak-ref behavior): if SDM is freed before we invoke the captured closure, the call is safe — it silently no-ops. Registration fails quietly, no crash.
Self-retain workaround (Q5): trivially available¶
SDM is a plain Swift class with a standard ObjC class object (_TtC10CoreDevice21ServiceDeviceManager), no actor executor, no tagged isa weirdness. swift_retain(sdm) from C — or equivalently CFRetain((__bridge CFTypeRef)sdm) via its ObjC isa — is well-defined. One-line belt-and-braces fix.
Fresh-closure alternative (Q6): possible but fragile¶
We could allocate a new closure box (swift_allocObject of 0x28 bytes, mimicking the one install(browser:) builds) and populate it with {strong_self_slot, browser, ...}. The funcptr we'd need is the closure entry at 0x286b70. However the exact capture layout changes between macOS builds and the closure uses swift_weakLoadStrong on a slot initialized via swift_weakInit — replicating that from C requires calling swift_weakInit(ctx+off, sdm), which is fine but ties us to a specific layout. Not recommended as primary, only as fallback if retaining fails on future macOS.
Recommendation¶
Keep the weak-ref closure (capture it real from install(browser:)) AND swift_retain(sdm) in the ctor immediately after heap-scan succeeds. Rationale:
- Uses Apple's own closure construction → layout changes across macOS versions don't break us.
- Nil path is already proven safe (clean no-op), so even without retain we don't crash — retaining just removes the silent-failure mode.
swift_retainon SDM is a one-instruction-cost pin; zero downside given SDM never deallocates in normal CDS operation anyway.
Timing constraints we must respect¶
- Inject ctor must run before CDS's
ServiceDeviceManager.__allocating_initto observeinstall(browser:). DYLD_INSERT_LIBRARIES + constructor attribute already satisfies this (see Q4 section, ~69 ms margin). swift_retain(sdm)must happen before any deferred closure invocation. It can happen any time after heap-scan succeeds — SDM's strong ref from CDS guarantees liveness at scan time, no race.- The captured closure itself must be retained too. It is a heap-allocated Swift closure box; at the moment we pluck it out of the
install(browser:)hook we mustswift_retainthe context (2nd word of the{funcptr, ctx}pair), otherwiseinstall(browser:)may drop its only ref on return and the box gets deallocated while we still hold a dangling funcptr+ctx.
Key file references¶
- Weak-load + nil branch: CoreDevice
0x286d85..0x286da3(/tmp/cd.son havoc, lines ~634014-634030) install(browser:)entry: CoreDevice0x27d7d0- SDM
__allocating_init: CoreDevice0x27cae0(dispatch thunk0x2899a0) - SDM
deinit: CoreDevice0x287e10 - SDM ivar symbols (
_browsers,_state,clientManager,devicesUpdateHandlerQ):nmon CoreDevice,OBJC_IVAR_$_CoreDevice.ServiceDeviceManager.* - Inject heap scan (no retain):
inject/iosmux_inject.m:294-320,inject/iosmux_inject.m:955-972
Q3 — verifying the actual managedServiceDevices insertion path¶
Read-only follow-up disasm of CoreDevice + 0x507f0 and the downstream code, plus a live log show cross-check on havoc.
0x507f0 is NOT the mutation function — it is an async-task creator¶
Disasm of 0x507f0 prologue to return:
- Allocates a stack buffer of size
0x40(metadata)from a type metadata resolved via__swift_instantiateConcreteTypeFromMangledNameV2— a bound generic (the SDM actor's_statedict or an iterator thereof). - Performs
swift_allocObject(size=0x20)twice (one per if/else branch at 0x50937 / 0x50990) to build an async-task closure context storing{captured_flag, sdm_self=rbx, state, r13-ctx}at+0x10 / +0x18 / .... - Terminates with
_swift_task_createin each branch (0x509f9, 0x50a3a). Returns the created task handle. - No array/dict write. No unfair_lock. No log string. 0x507f0 only schedules an async task on the SDM actor.
So 0x507f0 is the Swift-async swift_task_create thunk for the SDM-actor-bound method invocation from the discovery callback. Its role is enqueue, not insert.
The real insertion work runs later, on the SDM actor, inside the closure function whose pointer swift_task_create received — that function's symbol is stripped (<redacted function N>), but its body is the code at ~0x27e9a0..0x27ee23 and its continuation 0x27ee40.. (verified by the log-format string and call shape).
Demangled context — the public method surface¶
nm | swift-demangle enumerated every ServiceDeviceManager.* symbol. Relevant:
managedServiceDevices.getter : [DeviceIdentifier : [ServiceDeviceRepresentation]]@ 0x27e5c0 — confirmsmanagedServiceDevicesis a computed property, not a stored ivar. The dictionary lives inside the_stateivar.updateIdentifier(_:forIdentityManagedDevice:) -> ()@ 0x27c7e0 — public mutation path used by our inject's fallback call.install(browser:)@ 0x27d7d0 — already covered in Q5 above.
No symbol _offer(...) exists — stripped. But the string "_offer(discoveredDeviceRepresentation:to:)" does appear in __cstring, along with the proximate log strings ("Deferring processing…", "Resuming processing of newly discovered…", "Offering identity management of newly discovered device representation to…"). This pins the identity of the task body at ~0x27e9a0.. as ServiceDeviceManager._offer(discoveredDeviceRepresentation:to:) — an internal Swift async method.
SDM ivar names — confirmed from Swift ivar-offset symbols¶
nm on CoreDevice reports Swift-emitted ivar offset symbols with unmangled suffixes:
0x361d50 property descriptor ServiceDeviceManager.managedServiceDevices (computed, no slot)
0x361d58 _OBJC_IVAR_$_ ServiceDeviceManager._state
0x361d60 _OBJC_IVAR_$_ ServiceDeviceManager.devicesUpdateHandlerQ
0x361d68 _OBJC_IVAR_$_ ServiceDeviceManager.clientManager
0x361d70 _OBJC_IVAR_$_ ServiceDeviceManager._browsers
So the SDM ivars exist only for _state, devicesUpdateHandlerQ, clientManager, _browsers. managedServiceDevices is computed from the dict stored inside the _state wrapper. (Wrapper layout observed dynamically via the disasm of the task body: dict at +0x10, publisher slot at +0x20, plugin-ready flag at +0x40, retained object at +0x48, os_unfair_lock at +0x50.)
Where the array/dict actually gets mutated¶
Following the task body at ~0x27e9a0, the section right around and after the log call:
; format literals, build a %os_log_impl args blob …
0x27ec4e leaq -0x27ec55(%rip), %rdi ; os_log handle
0x27ec55 leaq 0xf8e44(%rip), %rcx ; "ServiceDeviceManager - New device representation added to %s: %s"
0x27ec6b callq __os_log_impl
; blob dealloc …
0x27ed06 movq -0xa0(%rbp), %r13 ; %r13 = _state wrapper (saved at entry)
0x27ed0d movq 0x10(%r13), %rbx ; %rbx = managedServiceDevices dict bridge-object
0x27ed11 leaq 0x10(%rbx), %r14 ; &dict header for mutation
0x27ed15 addq $0x50, %rbx ; &_state.os_unfair_lock
0x27ed19 callq _os_unfair_lock_lock
0x27ed2b movq -0x38(%rbp), %rdi ; new SDR pointer
0x27ed2f movq -0x78(%rbp), %rsi ; key (DeviceIdentifier)
0x27ed33 movq %r14, %rdx ; dict header
0x27ed36 callq 0x2829e0 ; <-- INSERT (Dictionary._modify/setter)
0x27ed3e callq _os_unfair_lock_unlock
0x27ed4d movq %r15, %rdi ; sdm self
0x27ed50 callq 0x284030 ; <-- PUBLISH devicesUpdateHandlerQ
The tail of the task body falls through into 0x27ee40, which rebuilds 4 heap closures (swift_allocObject size 0x18 / 0x18 / 0x20 / 0x18 / 0x20 / 0x20), swift_weakInits them against sdm and _state, and calls 0x28da40 / 0x28d2a0 under a second unfair lock. This is the _registeredHandlers-walk that dispatches the deviceManagerDevicesUpdate(.added) broadcast to every subscriber (string _registeredHandlers also present in __cstring).
Insertion helper: CoreDevice + 0x2829e0 (dict setter — only non-stub call between lock acquire/release).
Publish helper: CoreDevice + 0x284030 (invokes devicesUpdateHandlerQ publisher).
Broadcast walker: CoreDevice + 0x28d2a0 / 0x28da40 (iterate _registeredHandlers, call each).
Event type emitted: deviceManagerDevicesUpdate (string confirmed twice in __cstring).
Runtime cross-check — log show on havoc¶
/usr/bin/log show --predicate 'process == "CoreDeviceService"' --info --last 2h:
T+0 iosmux-inject: handleDiscoveredSDR at 0x108ec9850 (our hook of 0x27e850 fires)
T+0 CoreDevice: ServiceDeviceManager - New device representation added to ecid_… : <SDR …>
T+0 iosmux-inject: Calling updateIdentifier(devId, sdr, sdm) to register in managedServiceDevices...
The CoreDevice "added" log precedes our updateIdentifier call. That proves the 0x27e850 → 0x507f0 → swift_task_create → (async task body with insert at 0x2829e0) → publisher at 0x284030 chain is the real path populating managedServiceDevices. The updateIdentifier call afterwards is extra and reaches the separate identity-managed-device structure, not managedServiceDevices.
Revised registration path¶
CDS: StaticDeviceRepresentationBrowser discovery closure (CDS+0x286b70)
├─ weak-load SDM self
└─ call CoreDevice+0x27e850 (discovery handler: reads _state flag/lock, builds ctx)
└─ call CoreDevice+0x507f0 (async-task-create thunk, SDM actor)
└─ [async task body] ~CoreDevice+0x27e9a0..0x27ee23
(= ServiceDeviceManager._offer(discoveredDeviceRepresentation:to:))
├─ os_log "New device representation added to …"
├─ os_unfair_lock(_state+0x50)
├─ call 0x2829e0 ; Dict setter → managedServiceDevices mutation
├─ os_unfair_unlock(_state+0x50)
└─ call 0x284030 ; publish deviceManagerDevicesUpdate.added
└─ 0x28d2a0 / 0x28da40 walk _registeredHandlers
Answers to the six Q3 subquestions¶
- Role of 0x507f0: REFUTED as the mutation function. It is a Swift-async
swift_task_createthunk that schedules the real work on the SDM actor. The insertion and log happen inside the task body (~0x27e9a0..0x27ee23). - Demangled name: task body has no symbol (stripped). By the neighbouring log strings and the public
__cstringentry, it isServiceDeviceManager._offer(discoveredDeviceRepresentation:to:)async. - Published event:
deviceManagerDevicesUpdate— confirmed as cstring, broadcast viaCoreDevice + 0x284030(publisher) then0x28d2a0/0x28da40(handler walk) directly after theos_unfair_lock_unlockon the dict. - SDM ivar layout: confirmed by Swift-emitted
_OBJC_IVAR_$_symbols:_state,devicesUpdateHandlerQ,clientManager,_browsers.managedServiceDeviceshas a property descriptor only (computed, reads dict at_state_wrapper+0x10)._state-wrapper inner layout from disasm: dict@+0x10, publisher@+0x20, plugin-ready flag@+0x40, retained obj@+0x48, unfair_lock@+0x50. - Runtime cross-check: confirmed — "New device representation added" log fires in the same tick that our
handleDiscoveredSDRhook at 0x27e850 is entered, before the fallbackupdateIdentifiercall. The hook of0x27e850is therefore a sufficient and correct trigger. - If 0x507f0 were wrong: it is correct as scheduler, with the real mutation one hop further inside the async body. The current inject already hooks 0x27e850 (the right entry), so no code change is required for S1.C.2 on the basis of this verification.
Implications for S1.C.2¶
- The inject's existing call path into
CoreDevice + 0x27e850with{sdm_self, sdr}is confirmed as the correct single-call trigger. Do not try to reach0x2829e0/0x284030directly — they are inside an actor-bound async closure and racing them would bypass_state-flag handling ("Deferring … until plugins have loaded") and lock acquisition. - The subsequent
updateIdentifiercall in the inject is not what populatesmanagedServiceDevices; it remains necessary for the identity-managed-device path, but S1.C should not conflate the two. - The
deviceManagerDevicesUpdate.addedbroadcast is emitted "for free" by the async body. The inject never needs to synthesise a publish call. - To poll
managedServiceDevicesfrom the inject later, call the public getter atCoreDevice + 0x27e5c0with SDM self rather than reading_statedirectly — the getter acquires the right locks.
Source references¶
- 0x507f0 disasm (full, through 0x50a4d return):
/tmp/cd.asmon havoc, lines scoped to00000000000507f0..00000000000509fe - 0x27e850 disasm (through the tail call into 0x507f0 at 0x27e983): same file, lines scoped to
000000000027e850..000000000027e99c - Task body with log + insert + publish: same file, lines scoped to
000000000027e9a0..000000000027ee23 - Broadcast walker: same file,
000000000027ee40..000000000027f0db - SDM ivar offset symbols:
nm /Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/CoreDevice | grep OBJC_IVAR_.*ServiceDeviceManager - Runtime evidence:
log show --predicate 'process == "CoreDeviceService"' --info --last 2h
Q1 — closure ABI (deep disasm)¶
Correction to earlier note: the discovery-closure body is in CoreDevice, not CDS. Log string "Browser %{public}s discovered new device representation %s" lives in CoreDevice (strings -a), referenced at CoreDevice 0x286cc6. The body function starts at CoreDevice 0x286b70. "CDS+0x286b70" in the prior doc was a mis-attribution.
1. Closure representation & construction (in install(browser:))¶
Swift thick closure = { funcptr, ctx } 16-byte tuple. In install(browser:) (0x27d7d0) the discovery closure is built between 0x27da6c and 0x27db05:
0x27da6c alloc inner1 size=0x18 → %r15; swift_weakInit(r15+0x10, task_ctx) — weak SDM self
0x27da91 alloc inner2 size=0x28 → %r14
[r14+0x10] = instantiated generic type pointer
[r14+0x18] = metadata word
[r14+0x20] = inner1
0x27dabd alloc inner3 size=0x18 → %rbx; swift_weakInit(rbx+0x10, task_ctx)
0x27dae2 alloc OUTER size=0x30 → %r15 ← THE CLOSURE CONTEXT PASSED TO start(...)
[r15+0x10] = inner_0x28
[r15+0x18] = metadata
[r15+0x20] = inner3 (weak-self)
[r15+0x28] = raw SDM self (strong, cached at -0x68 earlier)
The call *-0x40(%rbp) at 0x27db56 dispatches the witness for browser.start(on:invokingWith:invokingIfCancelled:) — NOT a direct call to the closure body. The discovery closure is one (funcptr, ctx) pair among the args. Register layout at 0x27db2a–0x27db56:
rdi = -0x90(%rbp) ; "on:" queue
rsi = leaq 0x288e70(%rip) ; discovery closure FUNCPTR (static reabstraction thunk)
rcx = leaq 0x288ee0(%rip) ; cancellation closure FUNCPTR
r8 = r15 ; discovery closure CTX = outer 0x30 heap obj
r9 = -0x30(%rbp) ; cancellation closure CTX
push -0x38(%rbp) ; browser's protocol witness table
call *-0x40(%rbp) ; browser.start(...) witness
Discovery closure tuple = {funcptr = CoreDevice+0x288e70, ctx = r15 (0x30 heap obj)}. 16-byte thick-closure shape confirmed.
2. Calling convention — R13 is the Swift context register¶
Reabstraction thunk at CoreDevice + 0x288e70:
0x288e70 pushq %rbp
0x288e71 movq %rsp, %rbp
0x288e74 movq 0x10(%r13), %rsi
0x288e78 movq 0x18(%r13), %rdx
0x288e7c movq 0x20(%r13), %rcx
0x288e80 popq %rbp
0x288e81 jmp 0x286b70
Captures are loaded from %r13 at the exact offsets install(browser:) wrote into the outer 0x30 object. Swift sets R13 = closure context before indirect *funcptr. The caller ABI is:
RSI/RDX/RCX are NOT set by the caller — the thunk fabricates them from the ctx. R8/R9 unused. RAX unused on return.
3. Closure body argument signature¶
0x286b70 prologue:
0x286b81 movq %rcx, %r14 ; from thunk (inner3)
0x286b84 movq %rdx, %r15 ; from thunk (metadata)
0x286b87 movq %rsi, -0x38(%rbp) ; from thunk (browser String)
0x286b8b movq %rdi, -0x30(%rbp) ; ← discovered SDR (user arg)
...
0x286bed callq _swift_retain ; retain(-0x30) → SDR is refcounted class
Discovered ServiceDeviceRepresentation is passed as a single class-instance pointer in RDI. Not indirect, not split. Confirmed by the swift_retain.
4. Return value¶
0x286b57 retq, no RAX setup. Signature (SDR) -> Void. Nothing to handle in RAX.
5. Capture-context refcounting — BLOCKER unless mitigated¶
- Outer 0x30
swift_allocObjectat 0x27dae2 → refcount 1. - Between construction and witness call:
swift_retainon inner_0x28 and inner3,swift_bridgeObjectRetain. No retain on the outer 0x30. - After the witness call, 0x27db5d–0x27db79 releases: inner1, inner3, inner_0x28, outer. Outer drops to 0 → deallocated via
objectdestroy.3Tmat 0x288ea0. - For
StaticDeviceRepresentationBrowser.start(synchronous per Q3/Q4) the closure is invoked inline N times and dropped — fine for CDS. A naive post-install(funcptr, ctx)cache will point at freed memory.
Required mitigation in the install(browser:) hook:
// Grab r8 (outer ctx) before the 0x27db56 witness call, or intercept
// install(browser:) entry and re-derive r15 from r8 at the patched callsite:
swift_retain(ctx_0x30); // pin outer
swift_retain(*(void**)((char*)ctx_0x30 + 0x10)); // defensive: pin inner_0x28
swift_retain(*(void**)((char*)ctx_0x30 + 0x20)); // defensive: pin inner3
The outer's destructor at 0x288ea0 releases +0x18 (bridge) and +0x20 (class) but does NOT touch +0x10, so pinning the outer alone is sufficient for the captures the thunk reads; the extra retains are defensive. Weak SDM self is handled by the body (swift_weakLoadStrong) — if SDM dies our cached invocation no-ops safely.
6. Exact x86-64 to invoke a saved closure for our SDR¶
With g_closure_fp = CoreDevice+0x288e70 and g_closure_ctx = pinned 0x30 heap obj:
C trampoline (must be inline asm — C ABI won't preserve R13 across a call):
typedef void (*disco_fn)(void * /*rdi=sdr, r13=ctx*/);
extern void *g_closure_ctx;
extern disco_fn g_closure_fp;
static inline void iosmux_invoke_discovery(void *sdr) {
__asm__ volatile (
"movq %[ctx], %%r13\n\t"
"callq *%[fp]\n\t"
:
: [ctx] "r"(g_closure_ctx),
[fp] "r"(g_closure_fp),
"D"(sdr)
: "rax","rcx","rdx","rsi","r8","r9","r10","r11",
"r12","r14","r15","memory","cc"
);
}
R13 must be set in the same asm block as the call. Splitting it lets the compiler clobber R13 between statements.
Blockers / open items¶
- Lifetime (real blocker unless mitigated): outer 0x30 ctx is NOT retained past the witness call. Hook must
swift_retainit before the release burst at 0x27db5d–0x27db79. - Slide:
0x288e70and0x286b70are RVAs. Resolve via the RIP-relativeleaq 0xb33b(%rip), %rsiat 0x27db2e or computeCoreDevice_slide + 0x288e70. - Async assumption: R13 doubles as Swift async task-context register. Static.start(...) is synchronous (Q4), so no collision at our callsite. If Apple rebuilds Static as async, this ABI breaks — re-verify per OS revision.
- CDS Swift symbols still stripped; we rely on CoreDevice-side entry points read from live binaries.
Q-A — initialDeviceSnapshots construction and filters¶
Question: Why does devicectl list devices return empty initialDeviceSnapshots even though the system log confirms our SDR was added to CDS's managedServiceDevices?
The function¶
CoreDevice.ServiceDeviceManager.handle(clientCheckInRequest:from:) at CoreDevice + 0x286470 (mangled:
_$s10CoreDevice07ServiceB7ManagerC6handle20clientCheckInRequest4fromyAA0bdghI0V_7Mercury23SystemXPCPeerConnectionCtF).
Disasm structure:
- 0x286470–0x28657b — stack/metadata setup for DeviceManagerCheckInCompleteEvent, [DeviceStateSnapshot] type (at call to 0x29f020 / 0x29efd0).
- 0x286589 — single call into sub_0x289060(self, client) → returns the synthesized [DeviceStateSnapshot] (stored in %r12, then into the event struct at offset %rdx+0x14).
- 0x2865d0 — %al (a single byte — this is serviceFullyInitialized: Bool) written at event offset %rdx+0x18. The byte comes from -0xf0(%rbp), loaded earlier from an SDM ivar. It is read at 0x2865b9 — before sub_0x289060 even runs — i.e. it's a snapshot of a pre-existing isFullyInitialized flag on ServiceDeviceManager, not something recomputed per-request.
- 0x28663c–0x2867b8 — os_log "Published DeviceManagerCheckInCompleteEvent..." + actual publish via the witness table in -0x58(%rbp) at offset 0x8.
- sub_0x287e80 is the event-publish helper (called just before the log). sub_0x289060 is the snapshot-builder. Both have stripped Swift symbols (nm reports <redacted function NNNN>), so their demangled names are unavailable.
The filtering logic — and why it doesn't matter¶
We don't need to enumerate the filter predicate inside sub_0x289060 because the runtime timeline from system log makes the question moot. Fresh CDS pid 3411, 21:00 UTC+7:
| Timestamp | Event |
|---|---|
| 20:59:59.140 | inject dylib loads |
| 20:59:59.209208 | "Client connected: [3409] (no name). Handling DeviceManagerCheckInRequest ... 248D8BC9..." |
| 20:59:59.209683 | "Published DeviceManagerCheckInCompleteEvent for ... 248D8BC9..." (reply already sent) |
| 21:00:02.158 | "=== iosmux device registration starting ===" |
| 21:00:02.371 | handleDiscoveredSDR called |
| 21:00:02.371507 | "New device representation added to ecid_11836855534199284200" |
| 21:00:02.372 | "Received identity update request for device representation that we're not tracking for external identity management" |
Gap: 3.162 seconds between the check-in reply and our SDR add. The DeviceManagerCheckInCompleteEvent that devicectl consumed was frozen the instant sub_0x289060 ran over managedServiceDevices — a dictionary that was empty at that moment. Whatever filter predicate lives inside sub_0x289060 (state filter, identity filter, DeviceInfo-non-nil filter) is irrelevant: there was nothing to filter.
serviceFullyInitialized¶
Also a red herring for snapshot contents. It is sourced from an SDM instance ivar (the byte at -0xf0(%rbp) loaded at 0x2865b9, well before the snapshot builder runs), and — per the log — is flipped true only later via the separate DeviceManagerFullyInitializedEvent publish path:
21:00:02.273209 ServiceDeviceManager - Marked service manager as fully initialized.
Publishing DeviceManagerFullyInitializedEvent.
So at check-in time (20:59:59.209) the reply carried serviceFullyInitialized=false and initialDeviceSnapshots=[]. CDS's contract is clearly "check-in returns what we have right now; additional devices arrive asynchronously via separate events." devicectl, for its one-shot list devices invocation, exits after the check-in reply without staying subscribed.
Secondary issue (will matter once timing is fixed)¶
At 21:00:02.372105 CDS rejects our updateIdentifier call:
"Received identity update request for device representation that we're not tracking for external identity management"
This is a separate filter: CDS segregates SDRs into "externally-identity-managed" vs. internally-managed. Our SDR was added (via handleDiscoveredSDR) to managedServiceDevices, but it was not registered in the parallel external-identity tracking set that updateIdentifier gates on. The snapshot builder sub_0x289060 very likely cross-references this same set — meaning even if we fix the timing, the identity-management filter will still exclude our SDR from initialDeviceSnapshots.
Concrete next-step recommendation¶
Two independent problems, must fix both:
- Race fix — inject earlier. The SDR must be in
managedServiceDevicesbefore CDS processes the firstDeviceManagerCheckInRequest. Options, roughly in order of preference: - (a) Hook
handle(clientCheckInRequest:from:)at 0x286470 itself — on entry, synchronously run our registration path, then tail-call the original. This guarantees ordering per-request and also re-injects on every devicectl invocation, not just on CDS first-launch. - (b) Hook the earlier SDM init path (
Marked service manager as fully initializedlog site) to register our SDR at initialization, before any client can connect. Fragile: requires tunneld + iPhone ready at CDS startup. - © Pre-register synchronously from
__attribute__((constructor))in the dylib — but we already do something close and it races the first client because CDS accepts connections on a separate thread immediately after XPC listener activation. -
(a) is the correct fix. It removes the race entirely and doesn't depend on any framework timing.
-
Identity-management registration. We need to find and call the "track for external identity management" entrypoint that
updateIdentifierchecks against — i.e. the function that would legitimately add an entry to the external-identity set. Candidates to disasm: - The log string "Received identity update request for device representation that we're not tracking for external identity management" — find its
os_logcall site in CoreDevice, then look backward from the conditional that produced the rejection to see which dictionary/set is being probed. The "add" function is the one that mutates the same collection. - Likely lives inside the
RemoteServiceDiscovery-driven registration path: real SDRs are born through a different constructor that simultaneously inserts into bothmanagedServiceDevicesand the external-identity set. - Once found, call it instead of (or in addition to)
handleDiscoveredSDR, so our SDR passes theupdateIdentifierprecondition and — by implication — the snapshot builder's identity filter.
Once both are in place, sub_0x289060 will iterate managedServiceDevices, find our SDR, find it in the external-identity set, pass whatever remaining predicates (state/DeviceInfo — we already set state=connected, DeviceInfo fully populated, preparednessState=.all), and emit a DeviceStateSnapshot. devicectl list devices should then return our device.
Files / offsets referenced¶
/Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/CoreDevice0x286470—ServiceDeviceManager.handle(clientCheckInRequest:from:)0x286589— call to snapshot builder0x287e80— event publish helper (stripped Swift)0x289060— snapshot builder (stripped Swift, contains the filter predicate — not yet disassembled in detail since timing fix makes it a later concern)0x29f020— type metadata accessor forDeviceManagerCheckInRequest0x29efd0— type metadata accessor forDeviceManagerCheckInCompleteEvent
Q-F — closure body validation flow¶
Full disasm of CoreDevice + 0x286b70 (closure body), from prologue to the tail call at 0x286d96. Verified read-only on havoc VM against /Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/CoreDevice.
Register map on entry¶
rdi= SDR (strong) → spilled to-0x30(%rbp)rsi= closure context (browser name storage?) → spilled to-0x38(%rbp)rdx= bridged string (browser identifier, retained via_swift_bridgeObjectRetain) →r15rcx= weak-self storage base →r14
Block 1 — stack setup and generic-opaque-value buffer prep (0x286b70 – 0x286bde)¶
Standard Swift generic setup: fetch type metadata (via 0x34984a, a type metadata accessor), compute vwt->size via chkstk_darwin, allocate a VLA at r13 = rsp, and call the type's "initializeBufferWithCopyOfBuffer" via *0x10(%rbx) (vwt method 2). This is a copy of a generic-indirect closure capture, not SDR validation. r14 is bumped by +0x10 at 0x286bc2 — advancing past a WeakReference<Swift> header to the actual weak-pointer slot used later.
Block 2 — retain browser-name and SDR (0x286be1 – 0x286bed)¶
_swift_bridgeObjectRetain(r15)— browser name string_swift_retain([-0x30(%rbp)])— SDR (the one we're tracking)
No reads of any SDR field yet. Just a retain.
Block 3 — log gate (0x286bf6 – 0x286c13)¶
- Fetches the
os_log_thandle via helpers0x34983e/0x349e68 _os_log_type_enabled(log, type)→testb %al,%al→je 0x286d4b
This is the only branch in the body. It does not test any SDR property. It only asks "is the info-level log channel enabled?" If no, jump to Block 6 (skip-log path). If yes, take Block 4 (log-and-call path). Both paths ultimately reach the same epilogue at 0x286d6f → weak-load self → tail call.
Block 4 — log formatting (0x286c19 – 0x286ce5)¶
Two _swift_slowAlloc calls (0x16 and 0x40 bytes) to build the os_log argv buffer and an "arg pack" at -0x88(%rbp) (used as indirect return buffer passed via r15 into 0x11670, which is the Swift String → CVarArg/NSString bridge for %s). Called twice: once for the browser-name string (rsi/rdx from the spilled r15) and once for the SDR (rsi/rdx from 0x28e550 — that's a Swift-accessor stub producing the SDR's description/debugDescription via a protocol witness).
This is an SDR read, but it's description-only — invoked strictly for logging, not validation. Its return isn't tested. Then _os_log_impl fires with the fmt string at 0x286cc6: "Browser %{public}s discovered new device representation %s".
After logging, the temp buffers are destroyed (_swift_arrayDestroy + two _swift_slowDealloc), the description NSString is released via objc_release, and then the vwt destroy method is called via *0x8(%rbx) on the generic-buffer copy from Block 1 — this cleans up the captured generic value that Block 1 duplicated. Fallthrough jmp 0x286d6f to Block 6.
Block 5 — fast path, log disabled (0x286d4b – 0x286d6c)¶
Mirror cleanup: objc_release on the log handle, _swift_release on the SDR (note: balanced against the earlier _swift_retain at 0x286bed), _swift_bridgeObjectRelease on the browser name, and the same vwt destroy via *0x8(%rbx). Falls through to 0x286d6f.
Important note on refcount: In Block 5 the SDR is released before the weak-self load. But -0x30(%rbp) still holds the pointer (no clobber) and the closure's outer caller presumably holds another reference (the SDR is owned by the browser that invoked the closure). So using the pointer after release is fine — this is Swift guaranteeing the parameter is +1 on entry and releasing the +1 here.
Block 6 — weak-self load and dispatch (0x286d6f – 0x286da3)¶
beginAccess(&self_weak_storage)
self = swift_weakLoadStrong(&self_weak_storage)
if (self == nil) goto epilogue ; 0x286d8d je 0x286da3
call 0x27e850(sdr=-0x30(%rbp), self=rax in rdi→... actually rdi=sdr, r13=self)
swift_release(self)
Wait — reading the code carefully: at 0x286d92 movq -0x30(%rbp), %rdi loads SDR into rdi, then callq 0x27e850. At that point r13 holds the loaded strong self. So 0x27e850 is called with rdi = SDR and presumably reads r13 as well (non-ABI — this is a Swift private calling convention; 0x27e850's body at 0x27e8c9 does movq 0x10(%r13), %r12 confirming it treats r13 as self / browser).
Validation summary¶
There is no validation of the SDR in this closure body. The only branch (0x286c13) is gated on _os_log_type_enabled. No reads of SDR ivars, no nil checks on DeviceInfo fields, no state-machine check, no identifier-shape check, no filter predicate. The closure is unconditionally "log it and hand it off to self._offer-dispatch".
No "Skipping", "Ignoring", "Filter", or "Invalid" strings live in this range.
0x27e850 — brief¶
Also disassembled. Structure:
- Prologue allocates a generic-buffer VLA (same idiom, for
_DispatchData-ish type from mangled name at 0x27e869). - Checks a byte at
rbx + *(0x1a31e1 rel)— this is a static-ivar offset lookup into SDR. If non-zero, calls0x27e9a0(a detailed dispatcher we did not fully read) and tail-jumps to0x27ee40. This is a fast-path early branch based on an SDR flag. Our SDRs would take theje 0x27e8c9path (fall-through), assuming the flag is zero. - Fall-through (0x27e8c9):
mov 0x10(%r13), %r12— loads the browser'sDeviceManager-like field (offset 0x10) into r12. - Acquires
os_unfair_lockatr12 + 0x50. - Reads
r12 + 0x40(byte flag). If it equals 1, retain-captures the bridged string atr12 + 0x38(a stored browser name / filter string). Otherwise captures nil. - Retains
r12 + 0x48(a sub-manager ref). Unlocks. swift_allocObject— allocates a 0x40 (64 byte) heap-capture with flags (rdx=7):+0x00– Swift object header (isa + refcount)+0x10– zeroed (xmm0)+0x20– the optional bridged string captured under the lock (or nil)+0x28– SDR pointer (rbx)+0x30– ther12+0x48sub-manager ref+0x38– self (r13)- Retains SDR and self again (for the capture), and calls
0x507f0with: - rdi=0, rsi=0 (dispatch_queue? nil → default queue / attached semantics)
- rdx=r14 (the generic-buffer for the DispatchData/Task metadata)
- rcx = a small function-pointer-table at
0xe3477(%rip)— this is the async task / dispatch_after entry with the closure descriptor - r8 = the capture object
- Releases the return value and returns.
So 0x27e850 is a "build an async task capture and submit it" function. The only runtime check it performs is the cmpb $0x0, (%rbx,%rax) test on an SDR flag — and that's the fast-path early return which doesn't queue the async task. For our bug, that path being taken would actually skip _offer, which would be very interesting.
However, since our runtime log at 21:00:02.371 shows "New device representation added" (which per Q3 fires from inside the async _offer body), we know the async task was queued and did run, meaning the fall-through path at 0x27e8c9 was taken (SDR flag = 0) and the capture was built and dispatched.
Conclusion¶
- The closure body (0x286b70) has zero SDR validation. The only branch is a log-level check. The SDR is unconditionally forwarded to 0x27e850.
- 0x27e850 has one early-exit based on a single SDR byte-flag; the alternate path (taken by our SDR) builds a 64-byte capture {nil-or-string, sdr, submgr, self} and dispatches it to
_offer. - Since "New device representation added" did fire for our SDR, the async body reached the add-to-dict call, confirming both paths succeeded: closure body → 0x27e850 fall-through → async
_offer→ dict insert. - Validation is NOT the bug. The SDR passes all static CDS filtering. Whatever is blocking the pair-action dispatch from reaching our SDR is downstream of the "added" event — in the publish/snapshot/subscriber-notification path (likely the
0x287e80event publisher or0x289060snapshot filter noted in the earlier offset map), or in the NSBrowser's internal "ready for action" state — not in this closure.
Q-D — serviceFullyInitialized and snapshot data source¶
Binary: /Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/CoreDevice (on havoc VM).
Symbols of interest (Swift-demangled)¶
ServiceDeviceManager.init(clientManager:pluginManager:fullInitializationTime: DispatchTimeInterval)at0x27cb40— init takes a fixed DispatchTimeInterval ("full initialization time"). There is a default value for this argument.ServiceDeviceManager.handle(clientCheckInRequest:from:)at0x286470— handler that logs "Handling DeviceManagerCheckInRequest" and "Published DeviceManagerCheckInCompleteEvent".DeviceManagerCheckInCompleteEvent.init(checkInRequestIdentifier:initialDeviceSnapshots:serviceFullyInitialized:)at0x29db40— the event struct init; three stored properties, Bool is a single byte.DeviceManagerFullyInitializedEvent— a separate event, published exactly once when the "fully initialized" flag flips.
Log literals found in __cstring:
- "ServiceDeviceManager - Marked service manager as fully initialized. Publishing DeviceManagerFullyInitializedEvent." at 0x27e511
- "ServiceDeviceManager - Deferring processing of newly discovered device representation until plugins have loaded: ..."
- "ServiceDeviceManager - Published DeviceManagerCheckInCompleteEvent ..." at 0x286741
What sets serviceFullyInitialized¶
The "mark fully initialized" routine is at 0x27e440, invoked via a weak-self wrapper at 0x27e3e0 (swift_weakLoadStrong; only runs if self still alive) — this is the classic pattern for a DispatchQueue.asyncAfter(deadline: fullInitializationTime) closure captured at init time.
Body of 0x27e440:
movq 0x10(%r13), %rbx ; rbx = self->stateBox (field at offset 0x10 of self)
leaq 0x50(%rbx), %r15 ; lock = stateBox+0x50
callq _os_unfair_lock_lock
movb $0x1, 0x10(%rbx) ; stateBox.fullyInitialized = true (byte at +0x10)
callq _os_unfair_lock_unlock
... os_log "Marked ... fully initialized. Publishing DeviceManagerFullyInitializedEvent." ...
callq 0x28a8e0 ; build DeviceManagerFullyInitializedEvent
callq 0x303b60 / 0x287e80 ; publish
Predicate: none. The flag is flipped unconditionally once the deadline fires. It is time-based, not "all browsers finished scanning". There is no scanComplete/browser-gate check at this site — just a timer + a lock + a store. The fullInitializationTime default arg constant is at 0x10f90.
This rules out hypothesis (4) ("wait for browsers to finish" gate): CDS does NOT wait for a per-browser completion flag before flipping serviceFullyInitialized. It's just a grace window after daemon start.
Does the flag gate the snapshot list?¶
No. In handle(clientCheckInRequest:) at 0x286470:
- 0x286589:
callq 0x289060— build snapshot array. Called unconditionally. - 0x2865b9:
movzbl -0xf0(%rbp), %eax— load the Bool byte (which was populated earlier by a projection call at 0x286567 reading through0x10(self)→ lock-protected state box). - Both values are passed to the event builder. No branch skips snapshot construction based on the bool.
So serviceFullyInitialized and initialDeviceSnapshots are independent. A false bool does NOT empty the list, and a true bool does NOT populate it.
What does the snapshot builder iterate?¶
Snapshot builder at 0x289060 (caller passes self, clientIdentity, out-array):
movq 0x10(%r14), %r15 ; r15 = self->stateBox (SAME field as the flag-setter uses)
testq %r15, %r15
je 0x289345 ; if nil → return _swiftEmptyArrayStorage
... swift dictionary iteration (callq 0xb32c0 = Dictionary.next) ...
... for each (key, value): callq 0x284030 (per-entry snapshot builder)
... append to result array (0x16690 = Array.append) ...
The iteration walks a Swift Dictionary living inside the state-box at offset 0x10(self). The managedServiceDevices getter at 0x27e5c0 also projects through 0x10(self) using the same lock-protected accessor (callq 0x349838 is the Swift key-path / managed-state accessor thunk used by both the getter and the handler at 0x286567 / 0x285df6).
Conclusion: the snapshot builder iterates managedServiceDevices directly, not a derived/cached/"published" set. There is no second structure. Hypothesis (6) is ruled out.
Per-entry call at 0x284030 is makeSnapshot(for: ServiceDeviceRepresentation[]) — it takes the array of SDRs for one DeviceIdentifier key and produces a single DeviceStateSnapshot. If that function filters entries (e.g. requires at least one SDR with a specific flag), a device with only our SDR could produce an "empty" snapshot, but would still contribute an entry — not an entirely empty array. The fact that our test shows devicectl list devices printing "No devices found" means the array is either empty or all entries are rejected client-side.
devicectl-side handling of serviceFullyInitialized=false¶
Did not fully reverse /usr/bin/devicectl, but the two-event design (DeviceManagerCheckInCompleteEvent{serviceFullyInitialized: Bool, initialDeviceSnapshots: [...]} + separate DeviceManagerFullyInitializedEvent) strongly implies clients that want "wait for full init" subscribe to the second event, while list devices just prints initialDeviceSnapshots and exits. If the bool were gating devicectl output, you'd see a "waiting for CoreDevice..." message — which does not appear in our logs.
Most likely root cause¶
Given:
- Snapshot list is built from
managedServiceDevicesdirectly (Q-D). - Our SDR is in
managedServiceDevices(confirmed by "New device representation added" log at 21:00:02.371, >100 ms before the CheckInComplete at 21:00:02.476). serviceFullyInitializeddoes not gate the list.- Builder iterates the dict and calls per-entry
makeSnapshotat 0x284030.
The bug is almost certainly inside 0x284030 (per-entry snapshot builder) or in how the entry's SDR array is normalized before snapshot build. Candidates:
- 0x284030 calls a method on each SDR (probably
DeviceStateSnapshot.init(from: [ServiceDeviceRepresentation])or similar) that reads fields our SDR does not populate (e.g. a non-optionaltransport,identityProvider, pairing state), and either traps, returns nil, or returns a sentinel the caller drops. - Alternatively: the dict key under which we inserted the SDR is a
DeviceIdentifierwhoseHashable/equality differs from what devicectl's client identity filter at 0x286567 (theclientIdentityprojection passed into the snapshot builder) accepts — so the entry is iterated but filtered out by a per-client visibility predicate inside 0x289060's loop (note: 0x289060 does take the client identity via the-0x80(%rbp)stash and forwards it into 0x284030 as r13).
Next step (Q-A territory): disassemble 0x284030 end-to-end and identify (a) which SDR fields it reads, (b) whether it consults any per-client visibility filter, and © the failure mode when those reads see our partially-constructed SDR. That is the only remaining path that can silently drop our device from the snapshot list.
Q-E — reading managedServiceDevices from inject¶
Binary: /Library/Developer/PrivateFrameworks/CoreDevice.framework/CoreDevice (macOS 26.3.2, x86_64 slice). __TEXT base 0x0c000.
Confirmed getter at CoreDevice + 0x27e5c0:
- Mangled symbol: _$s10CoreDevice07ServiceB7ManagerC07managedC7DevicesSDyAA0B10IdentifierOSayAA0cB14RepresentationCGGvg
- Demangled: CoreDevice.ServiceDeviceManager.managedServiceDevices.getter : [CoreDevice.DeviceIdentifier : [CoreDevice.ServiceDeviceRepresentation]]
- Property descriptor at +0x361d50.
Critical correction: dict value type is [ServiceDeviceRepresentation] (Swift Array), not a single SDR. If we ever write into this dict we must insert an Array wrapping the SDR under a DeviceIdentifier key.
ABI (x86_64):
- self passed in %r13 (Swift swiftself). Observed: movq 0x10(%r13), %rdi at +0x27e628.
- Direct return in %rax — one-word Dict bridgeObject. Epilogue at +0x27e832: movq -0x30(%rbp), %rax then ret. No indirect sret.
- Caller owns a +1 retain; must swift_bridgeObjectRelease(rax) after use.
Key type: CoreDevice.DeviceIdentifier — a Swift enum (mangling code O), defined in CoreDevice itself. Not UUID, not String. Looking up a specific key from C would need its type metadata and Swift runtime helpers — out of scope for a count-only sanity check.
Reading _state + 0x10 directly is NOT safe. The disasm at +0x27e628..+0x27e692 shows the getter does not simply load a bridgeObject from self+0x10. It passes 0x10(%r13) (a wrapper pointer, likely lock-protected State) into a runtime call at +0x349838 which copies a 128-byte State struct onto the stack; the dict bridgeObject is then extracted from stack slot -0xb8 and the concrete type is materialized via __swift_instantiateConcreteTypeFromMangledNameV2. There is locking and a struct copy. Raw-read of self+0x10 would race against mutators like 0x2829e0. Use the getter.
No Dictionary.count getter exported from CoreDevice (Dictionary is generic, lives in libswiftCore in the dyld shared cache). But the returned bridgeObject points at __RawDictionaryStorage, and count sits at a stable offset: mask the top byte (ptr & 0x00ffffffffffffff), then count = *(int64_t *)(storage + 0x10) — Swift 5 ABI, _count is the first stored property after the 16-byte HeapObject header.
C helper to drop into inject (call from any thread that already holds a valid self pointer to the SDM instance):
// Resolve once at init (strip leading underscore for dlsym):
typedef void (*sdm_getter_fn)(void); // real ABI takes self in R13, we wrap in asm
static sdm_getter_fn sdm_managed_getter;
extern void swift_bridgeObjectRelease(uintptr_t);
// void *cd = dlopen("/Library/Developer/PrivateFrameworks/CoreDevice.framework/CoreDevice", RTLD_LAZY);
// sdm_managed_getter = dlsym(cd, "$s10CoreDevice07ServiceB7ManagerC07managedC7DevicesSDyAA0B10IdentifierOSayAA0cB14RepresentationCGGvg");
// Fallback: sdm_managed_getter = (void*)((uintptr_t)cd_base + 0x27e5c0);
static uintptr_t call_sdm_managed_getter(void *sdm) {
uintptr_t rax;
__asm__ volatile (
"movq %1, %%r13\n\t"
"callq *%2\n\t"
: "=a"(rax)
: "r"((uintptr_t)sdm), "r"((uintptr_t)sdm_managed_getter)
: "r13","rcx","rdx","rsi","rdi","r8","r9","r10","r11","memory","cc"
);
return rax;
}
void iosmux_dump_managed_service_devices(void *sdm) {
if (!sdm || !sdm_managed_getter) { LOG("sdm_dump: unresolved"); return; }
uintptr_t dict = call_sdm_managed_getter(sdm); // +1 bridgeObject
uintptr_t storage = dict & 0x00ffffffffffffffULL; // strip tag byte
int64_t count = storage ? *(int64_t *)(storage + 0x10) : -1;
LOG("managedServiceDevices: dict=0x%lx storage=0x%lx count=%lld",
(unsigned long)dict, (unsigned long)storage, (long long)count);
swift_bridgeObjectRelease(dict);
}
Notes:
- Wrap the call in its own function so the inline asm's clobber list is self-contained; the compiler then reloads anything it cached in %r13.
- If Swift represents an empty dict with a tagged singleton bridgeObject, storage+0x10 still reads a valid zero _count.
- Prefer the offset fallback (cd_base + 0x27e5c0) over the mangled symbol for resilience across macOS updates — log both resolutions.
Why the previous log said managedServiceDevices getter at 0x0: the inject used a wrong mangled name in dlsym. Copy the symbol string above verbatim (no leading underscore for dlsym), or switch to offset resolution.
Cross-check path: if the count helper reports 0 while our "New device representation added" log fires for the same SDR, the mutation is not landing in the public dict. Hook the mutator at CoreDevice + 0x2829e0 (per Q3) and log before/after count. If the mutator runs but the getter still returns 0, the State wrapper has two sub-storages and our insert went to the wrong branch — at that point the fix is to call the mutator with the SDM's own State path instead of poking internal storage.
Q-C — SDR UUID assignment¶
Binary: /Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/CoreDevice (x86_64 slice).
DeviceIdentifier is a two-case enum, not a UUID¶
nm + swift-demangle on the CoreDevice framework:
0x361440 enum case for CoreDevice.DeviceIdentifier.ecid(...) -> (Swift.UInt64) -> DeviceIdentifier
0x361444 enum case for CoreDevice.DeviceIdentifier.uuid(...) -> (Foundation.UUID, Swift.String) -> DeviceIdentifier
0x25b650 T CoreDevice.DeviceIdentifier.uuidRepresentation.getter : Foundation.UUID
So DeviceIdentifier has exactly two cases:
- .ecid(UInt64) — 8-byte payload
- .uuid(Foundation.UUID, Swift.String) — 32-byte payload (16-byte UUID + 16-byte String header)
Total enum size = max(8, 32) + 1 byte tag = 33 bytes — matches our dev_id_buf[33] in inject/iosmux_inject.m:857-861.
The smoking gun: our tag=0 is the ecid case¶
Our inject does (iosmux_inject.m:857-861):
memcpy(dev_id_buf, uuid, 16); // 16 UUID bytes at offset 0
dev_id_buf[32] = 0; // enum tag = "UUID variant" — WRONG
Case tags are declaration-ordered. The "enum case for" symbols appear in declaration order (ecid at 0x361440, uuid at 0x361444), so tag 0 = ecid, tag 1 = uuid.
When the runtime reads .ecid(UInt64) from our buffer, it consumes the first 8 bytes of our UUID as a UInt64. For E8A190DD-64F5-44A4-8D57-28E99E316D60:
- First 8 bytes LE: 0xA444F564DD90A1E8
- Decimal: 11836855534199284200
- Observed log: ecid_11836855534199284200 ✓
The ECID in the log is literally the first 8 bytes of our UUID reinterpreted as a UInt64. Proof that we pass the .ecid case.
Where B9BE8F31-... comes from: uuidRepresentation.getter¶
Disasm of DeviceIdentifier.uuidRepresentation.getter at CoreDevice + 0x25b650:
0x25b688 callq 0x25b050 ; copy enum value into temp
0x25b693 callq _swift_getEnumCaseMultiPayload
0x25b698 cmpl $0x1, %eax
0x25b69b jne 0x25b6d7 ; tag != 1 (i.e. ecid) → fallback path
; tag == 1 (uuid): extract Foundation.UUID from payload.0, return directly
0x25b6ab callq __swift_instantiateConcreteTypeFromMangledNameV2 ; Swift.String
0x25b6b4 movq 0x8(%r14,%rax), %rdi ; load String header
0x25b6b9 callq _swift_bridgeObjectRelease
0x25b6c0 callq 0x349280 ; Foundation.UUID metatype
0x25b6d2 callq *0x20(%rcx) ; copy UUID to return slot
uuid case): just return payload.0 (the stored UUID) — the literal we set.
Tag==0 (ecid case) falls to 0x25b720 — a large function that calls _swift_getKeyPath with the string literal "hasAMRestorableDeviceRef" (visible at 0x25b7f8) and then performs a lookup chain. This path derives a UUID from AMDevice/RestorableDevice state using the ECID as a key — effectively a deterministic UUID-from-ECID hash/lookup. B9BE8F31-6FD1-5ED4-83B0-4DD1CD9B0265 is the output of that lookup for ECID 11836855534199284200. It is deterministic (same ECID → same UUID across reruns) — no runtime verification performed, but the code path has no randomness source visible in the prologue and uses a keypath into a system table.
SDR stores deviceIdentifier verbatim, no transformation¶
Disasm of ServiceDeviceRepresentation.__allocating_init at 0x28c7b0 and init at 0x28cb50:
prologue saves incoming args:
rdi → deviceIdentifier pointer (33-byte value) → saved in r14 / -0x58(%rbp)
rsi → deviceInfo → -0x30(%rbp)
rdx → capabilityImplementations dict → -0x48(%rbp)
ecx → controlsUnderlying bool
...
0x28c8a8 leaq -0x311bf(%rip), %rdx ; DeviceIdentifier VWT
0x28c8b3 movq %r14, %rdi ; src = incoming deviceIdentifier
0x28c8b6 callq 0x19350 ; initializeWithTake → ivar at self + 0x194e49(rip)
selfReportedDeviceIdentifier ivar (the only DeviceIdentifier ivar on the class, confirmed by _OBJC_IVAR_$_CoreDevice.ServiceDeviceRepresentation suffix scan — only one match: .selfReportedDeviceIdentifier).
deviceIdentifier.getter (public) just returns this ivar. So what we pass IS what downstream consumers read. Our problem is entirely in what we pass, not in anything CDS does on top.
Why the description shows BOTH ecid_... AND uuid: ...¶
ServiceDeviceRepresentation.description.getter at 0x28e550 calls helper 0x28b7a0 to format the id = ... portion. 0x28b7a0 reads 0x20(self) (= selfReportedDeviceIdentifier), then branches on the enum tag:
- tag==0 (ecid) → format "ecid_<UInt64>" from payload
- tag==1 (uuid) → format from Foundation.UUID.description
- default → "([pending]" literal at 0x28bc1d
Then, regardless of branch, it appends , uuid: (literal 0x203a64697575202c = ", uuid: ") followed by the result of uuidRepresentation.getter — which, as shown above, for the ecid case does the AMRestorableDeviceRef keypath lookup. That's how a single selfReportedDeviceIdentifier produces both ecid_NNN and uuid: BBBB... in one log line.
Downstream consequence — does devicectl see our UUID?¶
Any consumer that:
- Calls sdr.selfReportedDeviceIdentifier or sdr.deviceIdentifier → sees .ecid(11836855534199284200). If they format it or use it as a dict key, they key on the ECID integer, not our UUID.
- Calls sdr.deviceIdentifier.uuidRepresentation → gets B9BE8F31-... from the AMDevice lookup, not our E8A190DD-....
- Calls sdr.deviceInfo.serviceDeviceIdentifier → reads the DeviceInfo field. Our inject constructs DeviceInfo via CoreDeviceProtocols.DeviceInfo.init(identifier: UUID) (line 681 of inject) — that identifier goes into a DeviceInfo-side UUID field, which is likely where hostname manager picks up E8A190DD-... (explains the hostname log).
So the system ends up with:
- SDR identity path (what CDS, devicectl list devices, and most lookups use): the synthetic B9BE8F31-... whenever anything calls uuidRepresentation, or the raw ECID 11836855534199284200 whenever anything compares DeviceIdentifier by case.
- DeviceInfo identity path: our E8A190DD-.... Used by hostname manager and any other consumer that reads DeviceInfo directly.
This explains why our serviceDeviceRepresentations(forDeviceIdentifiedBy: UUID) hook keyed on E8A190DD-... never fires — PairAction (and everything else) queries with the effective uuidRepresentation, which is B9BE8F31-..., because the SDR's DeviceIdentifier is .ecid, not .uuid.
Fix¶
Change inject/iosmux_inject.m:857-861 to emit a .uuid(Foundation.UUID, Swift.String) variant:
// Enum layout (33 bytes):
// bytes [0..16) = Foundation.UUID (16 bytes, raw)
// bytes [16..32) = Swift.String (2-word inline; empty string = both words zero
// for small-string tag, OR use a known String literal)
// byte [32] = tag = 1 (uuid case)
memset(dev_id_buf, 0, 33);
memcpy(dev_id_buf, uuid, 16);
// Empty Swift.String: { _countAndFlagsBits = 0, _object = 0xE000000000000000 }
// (tagged small-string, zero length). Store as two 64-bit words at offset 16.
*(uint64_t *)(dev_id_buf + 16) = 0;
*(uint64_t *)(dev_id_buf + 24) = 0xE000000000000000ULL;
dev_id_buf[32] = 1; // tag = uuid case
Verify by reading the empty-string constant the SDR init itself produces when it builds transient Swift.String values — search for movabsq $-0x2000000000000000 (= 0xE000000000000000) in the description getter disasm; it's used as the empty-String _object at 0x28e608 and again at 0x28bafc. That is the canonical empty-tagged-string bit pattern for x86_64 Swift 5+.
After the fix:
- selfReportedDeviceIdentifier will be .uuid(E8A190DD..., "")
- uuidRepresentation returns E8A190DD-... directly (fast path, no AMDevice lookup)
- description logs { id = E8A190DD-64F5-44A4-8D57-28E99E316D60, uuid: E8A190DD-64F5-44A4-8D57-28E99E316D60, name = ... }
- The hostname manager path still works because DeviceInfo already carries the same UUID
- PairAction's serviceDeviceRepresentations(forDeviceIdentifiedBy:) lookup now hits our hook, because the SDR's public UUID now equals what PairAction asks for
What we cannot control (for the .ecid variant)¶
If for some reason we had to keep the .ecid variant, the B9BE8F31-... UUID is not something we assign — it's produced by AMRestorableDeviceRef keypath lookup. It is deterministic w.r.t. the ECID (same ECID always yields the same UUID), so we could in principle compute it offline (hash the ECID the same way AMDevice does) and use it everywhere, but that's needlessly fragile. The .uuid variant is the correct fix.
Files / addresses referenced¶
- Inject source to edit:
/home/op/dev/myrepos/iosmux/inject/iosmux_inject.mlines 857-861 (dev_id_bufconstruction) DeviceIdentifier.uuidRepresentation.getter:CoreDevice + 0x25b650- Fallback keypath path:
CoreDevice + 0x25b720(loads"hasAMRestorableDeviceRef"at+0x25b7f8) SDR.__allocating_init:CoreDevice + 0x28c7b0SDR.init:CoreDevice + 0x28cb50SDR.selfReportedDeviceIdentifier.getter:CoreDevice + 0x28c3c0(inline ivar read)SDR.description.getter:CoreDevice + 0x28e550SDR.descriptionhelper (formatsid = ..., uuid: ...):CoreDevice + 0x28b7a0- Ivar symbol:
_OBJC_IVAR_$_CoreDevice.ServiceDeviceRepresentation.selfReportedDeviceIdentifieratCoreDevice + 0x421a70
Q-B — external identity management¶
Read-only disasm against /Library/Developer/PrivateFrameworks/CoreDevice.framework/CoreDevice (x86_64 slice, the slice running on the Intel macOS VM).
Log emission site¶
- String (vmaddr
0x37bca7/0x37bee7): ServiceDeviceManager - Offering identity management of newly discovered device representation to device representation provider %{public}s: %{public}sServiceDeviceManager - Received identity update request for device representation that we're not tracking for external identity management: %s- "Not tracking" log is emitted from closure
sub_0x2811a0, which is invoked from insideCoreDevice.ServiceDeviceManager.updateIdentifier(_:forIdentityManagedDevice:)atCoreDevice + 0x27c8ba. The closure is the "key-not-found" branch of aDictionary.subscript { ... }inout operation onself.managedServiceDevices([DeviceIdentifier : [ServiceDeviceRepresentation]], getter at0x27e5c0, storage is the ivar read as0x10(%rbx)after taking_os_unfair_lock_lockonself+0x50).
updateIdentifier control flow (CoreDevice + 0x27c7e0)¶
_os_unfair_lock_lock(self+0x50).- Call
Dictionary.subscriptonself.managedServiceDevices[deviceRepresentation]passing closure0x2811a0as the "miss" handler. When the SDR/ECID key is absent,0x2811a0formats and emits the "not tracking" log viaos_log_impl, does NOT mutate anything, and returns an empty Optional. _os_unfair_lock_unlock.movq 0x10(%rdi), %rcx ; testq %rcx,%rcx ; je 0x27ca2e— if the dict entry was empty (our case), jump to the short epilogue. No clients are iterated, no XPC traffic, no state change.- Success path (entry present): iterates the
[ServiceDeviceRepresentation]array, rewrites each SDR's stored identifier, and forwards the update to downstream consumers. Not hit by our inject.
Conclusion: the failure branch of updateIdentifier is pure log-and-return; it has no crash, no corruption, and no observable side effects. Matches the runtime evidence (call returns, daemon keeps running).
What tracks "external identity management"¶
- The tracking data structure is the private ivar
CoreDevice.ServiceDeviceManager.managedServiceDevices : [DeviceIdentifier : [ServiceDeviceRepresentation]](getterCoreDevice + 0x27e5c0, property descriptor+0x361d50). This is also the dictupdateIdentifierqueries. - An SDR is added to this dict only when a
DeviceRepresentationProvideraccepts anDeviceRepresentationIdentityManagementOffer: CoreDevice.DeviceRepresentationIdentityManagementOffer.accept() -> DeviceRepresentationIdentityControlatCoreDevice + 0x25ae90.decline()at+0x25ae70(pure release).- The registration helper right after
accept()at+0x25aee0is the fn that ends up callingServiceDeviceManager.updateIdentifierdirectly once the accept-path has registered the SDR. - The Offer itself is created and offered by CDS on the browser path: when a
DeviceRepresentationBrowser(installed viaServiceDeviceManager.install(browser:) +0x27d7d0) surfaces a newly discovered SDR, CDS wraps it in aDeviceRepresentationIdentityManagementOfferand calls the provider'sconsider(offer:)(thunk+0x2e57a0, extension default impl+0x2e5780). The "Offering identity management..." log is emitted inside this path. - There is no public
addToIdentityTracking/registerExternalIdentity/manageExternalIdentitysymbol onServiceDeviceManager. Outside the Offer→accept() pipeline there is no way to insert an SDR intomanagedServiceDevices.
Relationship to registration chain (0x286b70 → 0x27e850 → 0x507f0 → async _offer)¶
- That chain is the
handle(clientCheckInRequest:...)/ additional-metadata-provider install path which ultimately builds the initial-device snapshot broadcast. It constructsServiceDeviceRepresentations and publishes them through the snapshot channel, but it does NOT touchmanagedServiceDevices. Identity management is an orthogonal concern, entered only through the browser→provider→Offer→accept() chain. It is opt-in per-SDR.
Does identity-management tracking gate devicectl list devices visibility?¶
No. List visibility is driven by the deviceStateSnapshot/initialDeviceSnapshots broadcast (the Q-A territory), which reads from a separate collection unrelated to managedServiceDevices. managedServiceDevices only governs which SDRs are eligible to receive the later updateIdentifier(_:forIdentityManagedDevice:) pairing-record rewrite. updateIdentifier is, as its Swift signature suggests, a post-registration hook for providers that already took over identity management of a device — it rewrites their stored DeviceIdentifier. It is unrelated to making the SDR show up in list devices.
Recommendation¶
(a) Stop calling updateIdentifier from the inject. At inject/iosmux_inject.m:1011 the call is a confirmed no-op:
- It cannot succeed because our inject never participated in the
DeviceRepresentationProvider.consider(offer:)pipeline for our synthetic SDR, so the SDR is absent frommanagedServiceDevices. - The failure branch is log-and-return with no side effects — safe but noise.
- There is no alternate entry point ("option b") to pre-register our SDR into
managedServiceDeviceswithout implementing a fullDeviceRepresentationProvider, receiving the Offer from CDS's own browser, and callingaccept(). Doing that would require standing up a fake provider object and having CDS itself create and deliver the Offer to us — much bigger than the goal of Stage S1.C and almost certainly not what we want, since accept() would then route all subsequent identity updates through our provider, conflicting with the realcoredevicedpairing flow.
Remove the updateIdentifier(devId, sdr, sdm) call. This will eliminate the "not tracking for external identity management" log line. Our separate snapshot/broadcast injection (which makes devicectl list devices see the device) is unaffected — identity management and list visibility live on disjoint code paths.
Symbols / offsets referenced¶
| symbol | offset (x86_64) |
|---|---|
ServiceDeviceManager.updateIdentifier(_:forIdentityManagedDevice:) |
+0x27c7e0 |
→ "not tracking" closure (anon sub_0x2811a0) |
+0x2811a0 (called from +0x27c8ba) |
ServiceDeviceManager.managedServiceDevices.getter |
+0x27e5c0 |
ServiceDeviceManager.install(browser:) |
+0x27d7d0 |
ServiceDeviceManager.handle(clientCheckInRequest:from:) |
+0x286470 |
DeviceRepresentationIdentityManagementOffer.accept() |
+0x25ae90 |
DeviceRepresentationIdentityManagementOffer.decline() |
+0x25ae70 |
accept→updateIdentifier bridge (post-accept registration helper) |
+0x25aee0 |
DeviceRepresentationProvider.consider(offer:) dispatch thunk |
+0x2e57a0 |
| extension default impl | +0x2e5780 |