Skip to content

Stage S2.C self-experiment

Status: verified — runbook executed end-to-end

This runbook was actually run on 2026-04-13 against the patched pymobiledevice3 v9.9.1 + the iosmux_wire_logger.m inject. Raw artifacts in results/. Findings distilled in FINDINGS.md.

Artifacts for the one-shot Phase C self-experiment that resolves three of the Stage S2 blockers in a single run on the havoc VM:

  • X (client-side) — what bytes does CoreDeviceService actually emit on its pair-flow RSD tunnel connection?
  • Y — which PID inside the CDS process family calls nw_connection_create_with_connected_socket (so we know whether our LC_LOAD_DYLIB injection reaches the right process)?
  • Z — does CDS's sandbox permit AF_UNIX sockets, bind() to /tmp/*.sock, and sendmsg() with SCM_RIGHTS ancillary data?

Full context is in ../../plan-stage2-pair-flow.md and the prior-art capture in ../s2b-pair-attempt-log.md. The research / devil's-advocate pass that produced these three blockers is ../../plan-stage2-pair-flow.md in its Phase C section.

Contents

One-shot runbook

All commands assume the iosmux checkout on the Linux host at /home/op/dev/myrepos/iosmux and ssh access to the havoc VM as havoc (user) and havoc-root (root).

  1. Deploy patched pymobiledevice3 (idempotent):
./scripts/iosmux-install-pymobiledevice3.sh
  1. Deploy the instrumented inject:
scp inject/*.m inject/Makefile havoc:/Users/nullweft/iosmux/inject/
ssh havoc 'cd /Users/nullweft/iosmux/inject && make iosmux_inject.dylib'
ssh havoc-root 'cp /Users/nullweft/iosmux/inject/iosmux_inject.dylib \
    /Library/Developer/CoreDevice/iosmux_inject.dylib'
  1. Restart tunneld with IOSMUX_SPIKE=1 so it advertises [::1]:34719 as the tunnel endpoint instead of the real iPhone:
ssh havoc-root 'pkill -f "pymobiledevice3 remote tunneld" || true'
ssh havoc-root 'IOSMUX_SPIKE=1 IOSMUX_SPIKE_PORT=34719 \
    nohup /Users/nullweft/pymobiledevice3-venv/bin/pymobiledevice3 \
    remote tunneld --host 127.0.0.1 --port 49151 \
    > /tmp/iosmux-tunneld-spike.log 2>&1 &'

Verify:

ssh havoc 'curl -sm 2 http://127.0.0.1:49151/'
# expected: {"<udid>":[{"tunnel-address":"::1","tunnel-port":34719,...}]}
  1. Start the capture listener on havoc, bound to [::1]:34719:
scp docs/research/protocol/s2c-self-experiment/iosmux-spike-listener.py \
    havoc:/tmp/iosmux-spike-listener.py
ssh havoc 'python3 /tmp/iosmux-spike-listener.py --outdir /tmp/iosmux-capture'

Leave that running in a foreground terminal — it prints one line per accept() and writes per-session files.

  1. Trigger CDS to reach for the tunnel. Easiest path: restart CoreDeviceService and run devicectl list devices, or click Pair in Xcode's Devices window. Both make CDS consult tunneld, get back the spike-overridden endpoint, and try to connect to [::1]:34719.
ssh havoc-root 'killall CoreDeviceService 2>/dev/null || true'
ssh havoc 'devicectl list devices 2>&1 | tail -10'
  1. Collect results — stop the listener (Ctrl-C) and pull all three log sources:
mkdir -p docs/research/protocol/s2c-self-experiment/results
scp havoc:/tmp/iosmux_wire.log \
    docs/research/protocol/s2c-self-experiment/results/
scp -r havoc:/tmp/iosmux-capture \
    docs/research/protocol/s2c-self-experiment/results/
scp havoc:/tmp/iosmux-tunneld-spike.log \
    docs/research/protocol/s2c-self-experiment/results/
  • iosmux_wire.log — sandbox probe errno + every interposed nw_connection_* call with (pid, tid, args) — answers Y and Z
  • iosmux-capture/session-NN-* — bytes CDS sent to the spike endpoint and what we sent back — answers the client half of X
  • iosmux-tunneld-spike.log — tunneld's own stderr, useful if anything went sideways

  • Revert to stock tunneld when done (so the normal device flow works again for other sessions):

ssh havoc-root 'pkill -f "pymobiledevice3 remote tunneld" || true'
./scripts/iosmux-restore.sh   # restarts stock tunneld, no env var

What "success" looks like

  • /tmp/iosmux_wire.log has a === sandbox probe end === block with errno=0 (or at least a clear EPERM) for each of the four syscall tests. Blocker Z closed.
  • /tmp/iosmux_wire.log has at least one nw_connection_create_with_connected_socket fd=X params=Y line whose pid=N field matches a CDS instance we control. Blocker Y closed.
  • iosmux-capture/session-01.log has *** HTTP/2 preface matched *** followed by at least one FRAME type=SETTINGS from the client. Blocker X (client half) closed. If instead it shows *** preface MISMATCH *** first byte=0x16 then the earlier "no TLS" assumption is wrong and we pivot to investigating PSK.

What the experiment closed

Ran on 2026-04-13 against the patched pymobiledevice3 v9.9.1 + the iosmux inject with iosmux_wire_logger.m added. Results land in FINDINGS.md and the raw artifacts in results/. Short version:

  • Blocker X (client protocol) — plain prior-knowledge h2c, no TLS. RemoteXPC DATA frames on streams ⅓ with XpcWrapper magic bytes matching pymobiledevice3's xpc_message.py.
  • Blocker Y (which PID) — same PID our inject loads into. Current LC_LOAD_DYLIB coverage is sufficient.
  • Blocker Z (sandbox) — all four syscall probes passed. AF_UNIX + socketpair + /tmp bind + SCM_RIGHTS are all legal inside CoreDeviceService's sandbox.

Self-research queue (Q1-Q5) — still open

The experiment answered "what does CDS send". It did NOT answer "what does CDS expect back" — our listener has no application logic, so the session stalls after the first DATA frame. Five self-research tasks can close that gap on havoc without any friend-capture:

  • Q1 — Decode the captured 44-byte XPC payload via pymobiledevice3's own XpcWrapper / XpcPayload / bplist16 parsers. Turns the hex in results/iosmux-capture/session-01-recv.bin into a typed Python object graph so we know exactly which RemoteXPC message type and which XpcFlags bits CDS sent.
  • Q2 — tcpdump on utun4 during a working pymobiledevice3 remote rsd-info round-trip. Shows whether the tunnel interface carries plaintext HTTP/2 (transparent tunnel) or TLS-encrypted bytes (termination deeper down), and if plaintext, captures the REAL server responses the iPhone sends.
  • Q3 — Incremental dialog bisection. Extend the spike listener so that after the handshake it returns a minimal-but-plausible XPC response (synthesized from Q1 + Q2 + pymobiledevice3 message definitions). CDS proceeds to its next step; capture that. Repeat until either (a) the full pair handshake is mapped out or (b) we hit a request with no reference answer — in which case we name it as an empirical gap and halt. No guessing — each reply comes from a real source or the bisection stops there.
  • Q4 — pymobiledevice3 server-side grep. Search the pymobiledevice3 tree for any "pretend to be iPhone" test fixtures or server utilities we can reuse directly. If so, some Q3 replies can be driven from pymobiledevice3 code rather than hand-crafted.
  • Q5 — Post-TLS byte capture. If Q2 shows utun4 is TLS-encrypted, hook pymobiledevice3's recv() buffers (or use SSLKEYLOGFILE + Wireshark decryption) to see the post-TLS plaintext. Last resort before friend-capture.

Only after Q1-Q5 have been exhausted and there is still an empirical gap do we consider the friend-capture track (ship a capture script to someone with a real Mac + iPhone). No guessing in any branch — findings are either measured or labelled UNKNOWN.

Language discipline for Phase D

The listener in this directory is written in Python because it is one-shot research tooling, not because the backend will be Python. Phase D's production backend is Go. Any new production code for Stage S2's Option δ shim lands in cmd/iosmux-backend/ and uses:

  • golang.org/x/net/http2 for prior-knowledge h2c (via the Framer interface directly, because RemoteXPC does not fit net/http.Handler semantics)
  • a hand-written XpcWrapper / XpcPayload codec ported from pymobiledevice3's remote/xpc_message.py as reference
  • howett.net/plist for bplist decoding (extended if bplist16 is not already covered)

The only Python in steady state remains the existing pymobiledevice3 remote tunneld subprocess, which Go talks to over its HTTP API on loopback. No new long-lived Python processes get introduced by Stage S2.

When Stage S2 moves from research into implementation, this entire directory goes away alongside ../../../patches/pymobiledevice3/ and the inject additions in iosmux_wire_logger.m.