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— plainsocket(AF_UNIX, SOCK_STREAM, 0), allowed.probe2—socketpair(AF_UNIX, SOCK_STREAM, 0, fds), allowed.probe3—bind()to/tmp/iosmux_probe_17227.sock, allowed. Combined with the fact that the inject already happilyfopens/tmp/iosmux_inject.logand friends every boot, this confirms/tmp/is a writable, addressable sandbox region from inside CoreDeviceService.probe4—sendmsg()over a socketpair with anSCM_RIGHTSancillary block carrying a singleintfd, 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 preface —
PRI * 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 64 — SETTINGS_MAX_CONCURRENT_STREAMS=100
- 00 04 00 10 00 00 — SETTINGS_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:
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:
- An HTTP/2 frame reader that surfaces DATA frame payloads on
specific streams. Use
golang.org/x/net/http2(theFramertype is sufficient; we do not need the fullhttp2.Serverstate machine). - 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.Readcalls and magic-byte validation. - A bplist16 decoder.
howett.net/plistalready 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'sxpc_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:
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.