Skip to content

Stage S2.C self-experiment — findings

Status: verified — hex-level raw-byte evidence

Every blocker conclusion in this document is tied to a specific line in the raw wire log or the per-session hex capture. Raw artifacts are committed under results/ and referenced by filename in each section.

Date: session 10, after commits afd8dd6 and the 1a84960 inject. Setup is the one documented in the runbook. Raw artifacts are in results/ alongside this file.

Summary — three blockers closed

# Blocker Verdict Evidence
X What bytes does CDS emit on the RSD-tunnel connection? Plain prior-knowledge h2c + RemoteXPC DATA frames, matching pymobiledevice3's own xpc_message.py magic bytes results/iosmux-capture/session-01.log + session-01-recv.bin
Y Which PID inside CDS actually makes the network call? The same PID our LC_LOAD_DYLIB inject lives in (PID 17227 in this run). No remotepairingd delegation, no helper respawn. results/iosmux_wire.log — every nw_connection_* line tagged pid=17227, same PID as the sandbox probe
Z Does CDS's sandbox permit Shape-B primitives? All four syscall tests passed with errno=0. AF_UNIX sockets, socketpair(), bind() to /tmp/*.sock, and sendmsg() with SCM_RIGHTS are all allowed inside CoreDeviceService. results/iosmux_wire.log sandbox probe block

These resolutions, combined, mean Shape B is architecturally viable: we have the hook site, we have the sandbox permissions, and we know the client-side protocol is standard enough that a native Go HTTP/2 server can serve it. The remaining unknown is the server-side half of the conversation — what responses CDS expects from the RSD endpoint — and that is the subject of the Q1-Q5 self-research queue that starts after this checkpoint.

Z in detail — sandbox probe output

Blocker Z closed — sandbox primitives all legal

All four Shape B syscall primitives returned errno=0 from inside CoreDeviceService's sandbox on macOS. The sendmsg(SCM_RIGHTS) case is the one the devil's advocate ranked as the likely-killer; it is empirically disproven here. Evidence: results/iosmux_wire.log, sandbox probe block (PID 17227), lines reproduced below verbatim.

From results/iosmux_wire.log, verbatim:

[1776081707.347052 pid=17227 tid=0x7ff84eb56c00] === sandbox probe start ===
[1776081707.347486 pid=17227 tid=0x7ff84eb56c00] probe1 socket(AF_UNIX,STREAM) OK fd=3
[1776081707.347905 pid=17227 tid=0x7ff84eb56c00] probe2 socketpair OK fds=[3,4]
[1776081707.348372 pid=17227 tid=0x7ff84eb56c00] probe3 bind(/tmp/iosmux_probe_17227.sock) OK
[1776081707.349225 pid=17227 tid=0x7ff84eb56c00] probe4 sendmsg(SCM_RIGHTS) OK sent=1
[1776081707.349589 pid=17227 tid=0x7ff84eb56c00] === sandbox probe end ===
  • probe1 — plain socket(AF_UNIX, SOCK_STREAM, 0), allowed.
  • probe2socketpair(AF_UNIX, SOCK_STREAM, 0, fds), allowed.
  • probe3bind() to /tmp/iosmux_probe_17227.sock, allowed. Combined with the fact that the inject already happily fopens /tmp/iosmux_inject.log and friends every boot, this confirms /tmp/ is a writable, addressable sandbox region from inside CoreDeviceService.
  • probe4sendmsg() over a socketpair with an SCM_RIGHTS ancillary block carrying a single int fd, allowed. This is the one attack the devil's advocate ranked as a likely-killer for Shape B; it is empirically disproven on this build of macOS.

The probe file /tmp/iosmux_probe_17227.sock is unlinked by the probe itself after the bind test, so it leaves no residue.

Y in detail — PID topology

Every Network.framework interposer hit logs pid=17227:

[1776081707.481791 pid=17227 tid=0x700007bdb000] nw_connection_create endpoint={type=1 host=127.0.0.1 port=49151} params=0x6000038ed500
[1776081707.483217 pid=17227 tid=0x700007bdb000] nw_connection_start conn=0x7f828d80fd60
[1776081707.497409 pid=17227 tid=0x700007bdb000] nw_connection_send conn=0x7f828d80fd60 size=229 is_complete=1

This first connection is to 127.0.0.1:49151 — that's tunneld's HTTP API. CDS is fetching / to discover the tunnel endpoint. 229 bytes outbound is a typical GET / plus headers.

[1776081707.543977 pid=17227 tid=0x700007a52000] nw_connection_start conn=0x7f828de05a00
[1776081707.550323 pid=17227 tid=0x7000079cf000] nw_connection_send conn=0x7f828de05a00 size=44 is_complete=1
[1776081707.550800 pid=17227 tid=0x7000079cf000] nw_connection_start conn=0x7f828df06a20
[1776081707.551107 pid=17227 tid=0x7000079cf000] nw_connection_send conn=0x7f828de05a00 size=24 is_complete=1
[1776081707.554258 pid=17227 tid=0x7000079cf000] nw_connection_send conn=0x7f828df06a20 size=24 is_complete=1

Same PID. Two new nw_connection_*0x7f828de05a00 and 0x7f828df06a20. These are the ones that target the spike-overridden [::1]:34719. One of them is the connection our listener accepted (timestamped 19:01:47.537 — accept session 1 from ('::1', 54568, 0, 0)).

Conclusion: CDS does not spawn a helper process to make the pair-flow network call. The same CoreDeviceService instance that received the XPC message from devicectl / Xcode also opens the nw_connection inline. Our LC_LOAD_DYLIB injection reaches the right process without any additional hooking work.

Phase B's earlier observation of three CoreDeviceService PIDs was correct as a fact but the interpretation was wrong: PIDs 7268 / 7273 / 7506 are not a split of "action dispatcher" vs "network worker". They are concurrent launchd-managed instances (user session vs system session vs log-stream-ephemeral), and the one that handles a given devicectl request is whichever one the client's XPC connection resolves to. In this run that was PID 17227, and the inject ran there end-to-end.

X in detail — the wire trace

Listener session header

From results/iosmux-capture/session-01.log:

[19:01:47.539] === new session 1 from ('::1', 54568, 0, 0) ===
[19:01:47.549] preface: 24 bytes
[19:01:47.549]   hex:   50 52 49 20 2a 20 48 54 54 50 2f 32 2e 30 0d 0a 0d 0a 53 4d 0d 0a 0d 0a
[19:01:47.549]   ascii: PRI * HTTP/2.0....SM....
[19:01:47.549] *** HTTP/2 preface matched ***

Those 24 bytes are the RFC 7540 HTTP/2 connection prefacePRI * HTTP/2.0\r\n\r\nSM\r\n\r\n. This is prior-knowledge h2c, no TLS record wrapping. The first byte is 0x50 (P), not 0x16 (TLS ClientHello ContentType). The R9 disasm claim that TunnelProtocolSecurityOptions.tcp(Data) carries no sec_identity_t is now empirically backed: the wire is plaintext.

Server handshake (sent by our listener)

[19:01:47.550] sent server SETTINGS: 21 bytes
[19:01:47.550]   hex:   00 00 0c 04 00 00 00 00 00 00 03 00 00 00 64 00 04 00 10 00 00

Decoded: - 00 00 0c — length = 12 (two setting entries) - 04 — frame type = SETTINGS - 00 — flags - 00 00 00 00 — stream 0 - payload: - 00 03 00 00 00 64SETTINGS_MAX_CONCURRENT_STREAMS=100 - 00 04 00 10 00 00SETTINGS_INITIAL_WINDOW_SIZE=0x100000=1 MiB

These values are the ones pymobiledevice3's own observations (as documented in its RemoteXPC.md) captured from Apple/remoted. We mirrored them server-side on a hunch that "Apple's stack likes seeing what it sends"; the hunch is working.

[19:01:47.550] sent server WINDOW_UPDATE(983041): 13 bytes
[19:01:47.550]   hex:   00 00 04 08 00 00 00 00 00 00 0f 00 01

Decoded: WINDOW_UPDATE on stream 0 with increment 0x000f0001 = 983041. Same magic number pymobiledevice3 sends. CDS accepts it without complaint.

Client handshake (received from CDS)

After our handshake frames, CDS replies with a normal HTTP/2 opening sequence:

[19:01:47.551] FRAME type=SETTINGS flags=0x00 stream=0 len=12
[19:01:47.551]   hex:   00 00 0c 04 00 00 00 00 00 00 03 00 00 00 64 00 04 00 10 00 00

Byte-for-byte identical to what we sent. MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=1 MiB. This is not a coincidence — this confirms that CDS uses the exact same defaults as pymobiledevice3's client observations on the reverse path, which strongly implies both ends are using the same Apple http2_transport implementation with the same tuning.

Our listener then auto-ACKs:

[19:01:47.551] sent SETTINGS ACK: 9 bytes
[19:01:47.551]   hex:   00 00 00 04 01 00 00 00 00

CDS continues:

[19:01:47.551] FRAME type=WINDOW_UPDATE flags=0x00 stream=0 len=4
[19:01:47.551]   hex:   00 00 04 08 00 00 00 00 00 00 0f 00 01

Again byte-identical to the one we sent — stream-0 WINDOW_UPDATE with increment 983041.

Stream opens

[19:01:47.551] FRAME type=HEADERS flags=0x04 stream=1 len=0
[19:01:47.552]   hex:   00 00 00 01 04 00 00 00 01

HEADERS frame on stream 1 with flags 0x04 = END_HEADERS, and an empty payload (len=0). No HPACK block at all. This violates RFC 7540's strict reading of "HEADERS MUST contain at least one HEADERS frame with pseudo-headers", but it is consistent with how RemoteXPC uses HTTP/2: the HEADERS frame is a stream creation marker only; the actual data comes in DATA frames that carry XpcWrapper blobs. pymobiledevice3's client code opens streams the same way (see RemoteXPCConnection._open_channel).

A Go backend using http2.Server{}.ServeConn should handle this fine as long as we do NOT enable validate_inbound_headers (or its Go-equivalent strict validation). net/http Server layer would reject it; the lower-level frame server (http2.Framer in golang.org/x/net/http2/frame.go) accepts it. Implementation note: we probably want to drive the frame layer directly and synthesize a minimal http.Handler-less server, similar to what mitmproxy / h2spec do.

[19:01:47.552] FRAME type=SETTINGS flags=0x01 stream=0 len=0
[19:01:47.552]   hex:   00 00 00 04 01 00 00 00 00

Client-side SETTINGS ACK — acknowledging our server SETTINGS.

The first real XPC payload

[19:01:47.552] FRAME type=DATA flags=0x00 stream=1 len=44
[19:01:47.552]   hex:   00 00 2c 00 00 00 00 00 01 92 0b b0 29 01 00 00 00 14 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 42 37 13 42 05 00 00 00 00 f0 00 00 04 00 00 00 00 00 00 00

Breakdown:

HTTP/2 frame header (9 bytes)
  00 00 2c          length = 0x2c = 44
  00                type = 0x00 = DATA
  00                flags = 0x00 (no END_STREAM)
  00 00 00 01       stream id = 1 (ROOT_CHANNEL)

XpcWrapper header (from pymobiledevice3 xpc_message.py)
  92 0b b0 29       magic = 0x290bb092 little-endian
                    matches XpcWrapper.magic in pymobiledevice3
  01 00 00 00       flags = 0x00000001 little-endian
                    maps to XpcFlags.ALWAYS_SET (or INIT_HANDSHAKE
                    depending on how the enum is laid out upstream)
  14 00 00 00 00 00 00 00   body_size = 0x14 = 20 bytes (uint64 LE)
  00 00 00 00 00 00 00 00   msg_id = 0 (uint64 LE)

XpcPayload header (from pymobiledevice3 xpc_message.py)
  42 37 13 42       magic = 0x42133742 little-endian
                    matches XpcPayload.magic in pymobiledevice3
  05 00 00 00       protocol version = 5
  00 00 f0 00 00 04 00 00   (start of bplist16 body — 8 bytes)
  00 00 00 00       (more body bytes)

The 20-byte body after the XpcPayload magic is a bplist16 serialized xpc_object_t — the same format pymobiledevice3 parses on the reverse (iPhone → client) path. A Go port of this decoder needs three layers:

  1. An HTTP/2 frame reader that surfaces DATA frame payloads on specific streams. Use golang.org/x/net/http2 (the Framer type is sufficient; we do not need the full http2.Server state machine).
  2. An XpcWrapper/XpcPayload decoder that consumes DATA frame payloads and returns a dict-like object plus a stream id. ~300 LOC of Go, mostly little-endian binary.Read calls and magic-byte validation.
  3. A bplist16 decoder. howett.net/plist already handles standard bplist00 — bplist16 is a variant used by Apple's binary XPC that may or may not be covered. If not, port the relevant logic from pymobiledevice3's xpc_message.py (bplist16 is not standardized; it is an Apple-internal extension).

Follow-up DATA frames

[19:01:47.554] FRAME type=HEADERS flags=0x04 stream=3 len=0
[19:01:47.554]   hex:   00 00 00 01 04 00 00 00 03

[19:01:47.554] FRAME type=DATA flags=0x00 stream=1 len=24
[19:01:47.554]   hex:   00 00 18 00 00 00 00 00 01 92 0b b0 29 01 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

[19:01:47.555] FRAME type=DATA flags=0x00 stream=3 len=24
[19:01:47.555]   hex:   00 00 18 00 00 00 00 00 03 92 0b b0 29 01 00 40 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

CDS opens stream 3 (REPLY_CHANNEL) with an END_HEADERS HEADERS frame just like stream 1, then writes a 24-byte XpcWrapper on both streams. Both have zero-length bodies (size=0), which means they are header-only messages — probably "init handshake" or "open channel" markers.

Interesting differences in the flags field: - stream 1 second DATA: flags = 01 02 00 00 (little-endian = 0x00000201) - stream 3 first DATA: flags = 01 00 40 00 (little-endian = 0x00400001)

Both have bit 0 (0x01) set. Bit 9 (0x200) is set for one and bit 22 (0x400000) for the other. Mapping these to pymobiledevice3's XpcFlags enum is a Q1 task — we don't guess here, we look it up.

Session idle and close

After those frames CDS stops talking and waits for server-side replies. Our listener has no application logic beyond the handshake, so it just sits there. 30 seconds later the read timeout fires:

[19:02:17.557] read timeout after 30.0s
[19:02:17.558] === session end ===

CDS's application-level devicectl list devices call correspondingly returns No devices found. because CDS never got its RSD handshake completed.

What this closes and what it does not

Closed: - Shape B is sandbox-compatible (Z). - Shape B's hook site is in the right process (Y). - Shape B's HTTP/2 transport assumptions are empirically confirmed (X, client side). - pymobiledevice3's reverse-engineered RemoteXPC framing matches the reality on the wire (same magic bytes, same XpcWrapper / XpcPayload layout). We can reuse its decoder as a reference when writing the Go port, without having to invent any of this.

Not closed yet: - What exact semantics each of the first XPC messages has — which pymobiledevice3 XpcFlags they map to, whether the 20-byte body of the first stream-1 DATA is a known RemoteXPC request type. (Q1 in the research queue.) - What responses CDS actually expects to receive on streams 1 and 3. We need a reference capture of a working CDS ↔ iPhone session — either via self-research on havoc's utun4 (Q2, Q5) or, as a last resort, a friend-captured session from a real Mac + iPhone setup. - Whether macOS http2_transport has any Apple-specific quirks beyond the empty-payload HEADERS frame (which is a minor deviation from strict RFC but manageable). - Whether pymobiledevice3 itself carries any server-side code we can reuse directly from Go via a small Python subprocess RPC, or whether we must port every frame handler to Go. (Q4.)

Implication — Phase D architecture is Go

As spelled out in the updated plan-stage2-pair-flow.md, the Option δ shim backend is now explicitly a Go component. No new production Python code is introduced. The Python dependency is still bounded to the existing pymobiledevice3 remote tunneld subprocess (TLS 1.2 PSK + pair identity handling), which Go speaks to as an HTTP API client on loopback — exactly as it does today.

Concretely the Go backend will use: - golang.org/x/net/http2/Framer for raw frame read/write - golang.org/x/net/http2/h2c for prior-knowledge HTTP/2 upgrade handling if we decide to stand up a http2.Server instead of driving the framer directly - A hand-written XpcWrapper/XpcPayload codec (~300-500 LOC Go) ported from pymobiledevice3 as reference - howett.net/plist for bplist decoding, extended if bplist16 needs it

The spike-only Python code in this directory (iosmux-spike-listener.py) remains Python because it is a one-shot research capture tool, not a production component. It will be deleted together with the rest of the spike infrastructure once Stage S2 moves from research into implementation.