Skip to content

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
That's the Swift dispatch thunk for the protocol requirement. Callers go through this thunk and then indirect-call via the witness table of whatever concrete browser was passed in. The StaticDeviceRepresentationBrowser witness implementation is inside the CDS binary and is one of the 1651 stripped <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:

  1. Synchronous iteration. Walk self.deviceRepresentations and invoke the discovery callback for each element, then return.
  2. 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 walks deviceRepresentations and 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:

  1. Log "Installing browser into service device manager: %{public}s" (literal at 0xf9d65(%rip) from 0x27d954). Confirms we have the right function.
  2. Allocate three Swift heap closures (swift_allocObject size 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.
  3. Indirect callq *-0x40(%rbp) at 0x27db56 — this is the Swift protocol-witness call into browser.start(on:invokingWithDiscoveredDeviceRepresentation:invokingIfCancelled:), with the heap-allocated closure passed as the second-word capture of the discovery-callback existential. SDM calls browser.start(...) from inside install(browser:).
  4. 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 + 0x18os_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 Swift partial_apply with the capture context in %rcx.
  • After the log, 0x286d6f / 0x286d7d call swift_beginAccess + swift_weakLoadStrong on 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 0x27e850 with rdi = strongly-held SDM self, passing the discovered device. This is the real SDM discovery handler.
  • 0x27e850 (partial disasm inspected): takes an unfair lock at r12+0x50 where r12 = *(r13+0x10) (r13 coming via closure state), reads flag r12+0x40, refs at 0x38 and 0x48, builds a 64-byte capture record with {x, SDM, state, r13}, and tail-calls the full registration routine at CoreDevice 0x507f0. That is the function that eventually updates managedServiceDevices.

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 of StaticDeviceRepresentationBrowser.start(...) and of its initializer cannot be resolved via nm. We must find them via runtime hooking (class pointer match on swift_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 %r13 carried at entry — plausibly a Swift partial_apply-style implicit first-argument, or a task context. This doesn't block runtime hooking but makes a pure static derivation of the _browsers offset 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 disasm 0x507f0 to confirm it mutates managedServiceDevices. 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_DYLIB constructors before handing control to main(). CoreDevice/MobileDevice/plugin loader and the Swift main that calls install(browser:) all run AFTER our ctor finishes.
  • Confirmed for 4 distinct CDS launches (pids 1301, 1785, 2233, 2299). In every case INJECT loaded precedes 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 0x27d7f00x27db56 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)

  1. Primary (viable, recommended). Install the install(browser:) hook in the ctor body before dispatch_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.
  2. Fallback (if the primary hook ever gets displaced). Hook swift_allocObject transiently during ctor+install window and filter for the 0x28-sized allocations inside install(browser:); these are the closure boxes. Higher noise.
  3. 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 called install(browser:) in the first place).
  • SDM therefore lives for the entire CDS process lifetime. deinit runs 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:

void *sdm = find_instance_of_class(sdm_class);

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_retain on SDM is a one-instruction-cost pin; zero downside given SDM never deallocates in normal CDS operation anyway.

Timing constraints we must respect

  1. Inject ctor must run before CDS's ServiceDeviceManager.__allocating_init to observe install(browser:). DYLD_INSERT_LIBRARIES + constructor attribute already satisfies this (see Q4 section, ~69 ms margin).
  2. 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.
  3. 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 must swift_retain the context (2nd word of the {funcptr, ctx} pair), otherwise install(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.s on havoc, lines ~634014-634030)
  • install(browser:) entry: CoreDevice 0x27d7d0
  • SDM __allocating_init: CoreDevice 0x27cae0 (dispatch thunk 0x2899a0)
  • SDM deinit: CoreDevice 0x287e10
  • SDM ivar symbols (_browsers, _state, clientManager, devicesUpdateHandlerQ): nm on 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 _state dict 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_create in 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 — confirms managedServiceDevices is a computed property, not a stored ivar. The dictionary lives inside the _state ivar.
  • 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

  1. Role of 0x507f0: REFUTED as the mutation function. It is a Swift-async swift_task_create thunk that schedules the real work on the SDM actor. The insertion and log happen inside the task body (~0x27e9a0..0x27ee23).
  2. Demangled name: task body has no symbol (stripped). By the neighbouring log strings and the public __cstring entry, it is ServiceDeviceManager._offer(discoveredDeviceRepresentation:to:) async.
  3. Published event: deviceManagerDevicesUpdate — confirmed as cstring, broadcast via CoreDevice + 0x284030 (publisher) then 0x28d2a0/0x28da40 (handler walk) directly after the os_unfair_lock_unlock on the dict.
  4. SDM ivar layout: confirmed by Swift-emitted _OBJC_IVAR_$_ symbols: _state, devicesUpdateHandlerQ, clientManager, _browsers. managedServiceDevices has 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.
  5. Runtime cross-check: confirmed — "New device representation added" log fires in the same tick that our handleDiscoveredSDR hook at 0x27e850 is entered, before the fallback updateIdentifier call. The hook of 0x27e850 is therefore a sufficient and correct trigger.
  6. 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 + 0x27e850 with {sdm_self, sdr} is confirmed as the correct single-call trigger. Do not try to reach 0x2829e0 / 0x284030 directly — 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 updateIdentifier call in the inject is not what populates managedServiceDevices; it remains necessary for the identity-managed-device path, but S1.C should not conflate the two.
  • The deviceManagerDevicesUpdate.added broadcast is emitted "for free" by the async body. The inject never needs to synthesise a publish call.
  • To poll managedServiceDevices from the inject later, call the public getter at CoreDevice + 0x27e5c0 with SDM self rather than reading _state directly — the getter acquires the right locks.

Source references

  • 0x507f0 disasm (full, through 0x50a4d return): /tmp/cd.asm on havoc, lines scoped to 00000000000507f0..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:

movq <sdr>,  %rdi
movq <ctx>,  %r13
callq *<funcptr>

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_allocObject at 0x27dae2 → refcount 1.
  • Between construction and witness call: swift_retain on 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.3Tm at 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:

    movq    g_closure_ctx(%rip), %r13
    movq    our_sdr_ptr(%rip),   %rdi
    callq   *g_closure_fp(%rip)
    ; void return

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_retain it before the release burst at 0x27db5d–0x27db79.
  • Slide: 0x288e70 and 0x286b70 are RVAs. Resolve via the RIP-relative leaq 0xb33b(%rip), %rsi at 0x27db2e or compute CoreDevice_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:

  1. Race fix — inject earlier. The SDR must be in managedServiceDevices before CDS processes the first DeviceManagerCheckInRequest. Options, roughly in order of preference:
  2. (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.
  3. (b) Hook the earlier SDM init path (Marked service manager as fully initialized log site) to register our SDR at initialization, before any client can connect. Fragile: requires tunneld + iPhone ready at CDS startup.
  4. © 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.
  5. (a) is the correct fix. It removes the race entirely and doesn't depend on any framework timing.

  6. Identity-management registration. We need to find and call the "track for external identity management" entrypoint that updateIdentifier checks against — i.e. the function that would legitimately add an entry to the external-identity set. Candidates to disasm:

  7. The log string "Received identity update request for device representation that we're not tracking for external identity management" — find its os_log call 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.
  8. Likely lives inside the RemoteServiceDiscovery-driven registration path: real SDRs are born through a different constructor that simultaneously inserts into both managedServiceDevices and the external-identity set.
  9. Once found, call it instead of (or in addition to) handleDiscoveredSDR, so our SDR passes the updateIdentifier precondition 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/CoreDevice
  • 0x286470ServiceDeviceManager.handle(clientCheckInRequest:from:)
  • 0x286589 — call to snapshot builder
  • 0x287e80 — 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 for DeviceManagerCheckInRequest
  • 0x29efd0 — type metadata accessor for DeviceManagerCheckInCompleteEvent

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) → r15
  • rcx = 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_t handle via helpers 0x34983e / 0x349e68
  • _os_log_type_enabled(log, type)testb %al,%alje 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:

  1. Prologue allocates a generic-buffer VLA (same idiom, for _DispatchData-ish type from mangled name at 0x27e869).
  2. Checks a byte at rbx + *(0x1a31e1 rel) — this is a static-ivar offset lookup into SDR. If non-zero, calls 0x27e9a0 (a detailed dispatcher we did not fully read) and tail-jumps to 0x27ee40. This is a fast-path early branch based on an SDR flag. Our SDRs would take the je 0x27e8c9 path (fall-through), assuming the flag is zero.
  3. Fall-through (0x27e8c9): mov 0x10(%r13), %r12 — loads the browser's DeviceManager-like field (offset 0x10) into r12.
  4. Acquires os_unfair_lock at r12 + 0x50.
  5. Reads r12 + 0x40 (byte flag). If it equals 1, retain-captures the bridged string at r12 + 0x38 (a stored browser name / filter string). Otherwise captures nil.
  6. Retains r12 + 0x48 (a sub-manager ref). Unlocks.
  7. swift_allocObject — allocates a 0x40 (64 byte) heap-capture with flags (rdx=7):
  8. +0x00 – Swift object header (isa + refcount)
  9. +0x10 – zeroed (xmm0)
  10. +0x20 – the optional bridged string captured under the lock (or nil)
  11. +0x28 – SDR pointer (rbx)
  12. +0x30 – the r12+0x48 sub-manager ref
  13. +0x38 – self (r13)
  14. Retains SDR and self again (for the capture), and calls 0x507f0 with:
  15. rdi=0, rsi=0 (dispatch_queue? nil → default queue / attached semantics)
  16. rdx=r14 (the generic-buffer for the DispatchData/Task metadata)
  17. rcx = a small function-pointer-table at 0xe3477(%rip) — this is the async task / dispatch_after entry with the closure descriptor
  18. r8 = the capture object
  19. 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 0x287e80 event publisher or 0x289060 snapshot 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) at 0x27cb40 — init takes a fixed DispatchTimeInterval ("full initialization time"). There is a default value for this argument.
  • ServiceDeviceManager.handle(clientCheckInRequest:from:) at 0x286470 — handler that logs "Handling DeviceManagerCheckInRequest" and "Published DeviceManagerCheckInCompleteEvent".
  • DeviceManagerCheckInCompleteEvent.init(checkInRequestIdentifier:initialDeviceSnapshots:serviceFullyInitialized:) at 0x29db40 — 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 through 0x10(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:

  1. Snapshot list is built from managedServiceDevices directly (Q-D).
  2. 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).
  3. serviceFullyInitialized does not gate the list.
  4. Builder iterates the dict and calls per-entry makeSnapshot at 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-optional transport, 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 DeviceIdentifier whose Hashable/equality differs from what devicectl's client identity filter at 0x286567 (the clientIdentity projection 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
Tag==1 (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)
No hash, no transform: the caller-supplied enum value is copied straight into the 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.m lines 857-861 (dev_id_buf construction)
  • DeviceIdentifier.uuidRepresentation.getter: CoreDevice + 0x25b650
  • Fallback keypath path: CoreDevice + 0x25b720 (loads "hasAMRestorableDeviceRef" at +0x25b7f8)
  • SDR.__allocating_init: CoreDevice + 0x28c7b0
  • SDR.init: CoreDevice + 0x28cb50
  • SDR.selfReportedDeviceIdentifier.getter: CoreDevice + 0x28c3c0 (inline ivar read)
  • SDR.description.getter: CoreDevice + 0x28e550
  • SDR.description helper (formats id = ..., uuid: ...): CoreDevice + 0x28b7a0
  • Ivar symbol: _OBJC_IVAR_$_CoreDevice.ServiceDeviceRepresentation.selfReportedDeviceIdentifier at CoreDevice + 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}s
  • ServiceDeviceManager - 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 inside CoreDevice.ServiceDeviceManager.updateIdentifier(_:forIdentityManagedDevice:) at CoreDevice + 0x27c8ba. The closure is the "key-not-found" branch of a Dictionary.subscript { ... } inout operation on self.managedServiceDevices ([DeviceIdentifier : [ServiceDeviceRepresentation]], getter at 0x27e5c0, storage is the ivar read as 0x10(%rbx) after taking _os_unfair_lock_lock on self+0x50).

updateIdentifier control flow (CoreDevice + 0x27c7e0)

  1. _os_unfair_lock_lock(self+0x50).
  2. Call Dictionary.subscript on self.managedServiceDevices[deviceRepresentation] passing closure 0x2811a0 as the "miss" handler. When the SDR/ECID key is absent, 0x2811a0 formats and emits the "not tracking" log via os_log_impl, does NOT mutate anything, and returns an empty Optional.
  3. _os_unfair_lock_unlock.
  4. 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.
  5. 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]] (getter CoreDevice + 0x27e5c0, property descriptor +0x361d50). This is also the dict updateIdentifier queries.
  • An SDR is added to this dict only when a DeviceRepresentationProvider accepts an DeviceRepresentationIdentityManagementOffer:
  • CoreDevice.DeviceRepresentationIdentityManagementOffer.accept() -> DeviceRepresentationIdentityControl at CoreDevice + 0x25ae90.
  • decline() at +0x25ae70 (pure release).
  • The registration helper right after accept() at +0x25aee0 is the fn that ends up calling ServiceDeviceManager.updateIdentifier directly 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 via ServiceDeviceManager.install(browser:) +0x27d7d0) surfaces a newly discovered SDR, CDS wraps it in a DeviceRepresentationIdentityManagementOffer and calls the provider's consider(offer:) (thunk +0x2e57a0, extension default impl +0x2e5780). The "Offering identity management..." log is emitted inside this path.
  • There is no public addToIdentityTracking / registerExternalIdentity / manageExternalIdentity symbol on ServiceDeviceManager. Outside the Offer→accept() pipeline there is no way to insert an SDR into managedServiceDevices.

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 constructs ServiceDeviceRepresentations and publishes them through the snapshot channel, but it does NOT touch managedServiceDevices. 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 from managedServiceDevices.
  • 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 managedServiceDevices without implementing a full DeviceRepresentationProvider, receiving the Offer from CDS's own browser, and calling accept(). 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 real coredeviced pairing 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