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 ourLC_LOAD_DYLIBinjection reaches the right process)? - Z — does CDS's sandbox permit
AF_UNIXsockets,bind()to/tmp/*.sock, andsendmsg()withSCM_RIGHTSancillary 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¶
iosmux-spike-listener.py— zero-deps Python 3 HTTP/2 capture listener. See its module docstring for the protocol it speaks. Intended to run on havoc and receive the redirected CDS connection enabled by../../patches/pymobiledevice3/0001-iosmux-spike-tunneld-endpoint-override.patch.- Future: a
README-findings.mdsummarising whatever we observe when the experiment runs. Added alongside the commit that lands the result.
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).
- Deploy patched pymobiledevice3 (idempotent):
- 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'
- Restart tunneld with
IOSMUX_SPIKE=1so it advertises[::1]:34719as 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,...}]}
- 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.
- 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'
- 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 interposednw_connection_*call with (pid, tid, args) — answers Y and Ziosmux-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.loghas 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.loghas at least onenw_connection_create_with_connected_socket fd=X params=Yline whosepid=Nfield matches a CDS instance we control. Blocker Y closed.iosmux-capture/session-01.loghas*** HTTP/2 preface matched ***followed by at least oneFRAME type=SETTINGSfrom the client. Blocker X (client half) closed. If instead it shows*** preface MISMATCH *** first byte=0x16then 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_DYLIBcoverage is sufficient. - Blocker Z (sandbox) — all four syscall probes passed.
AF_UNIX + socketpair +
/tmpbind + 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/bplist16parsers. Turns the hex inresults/iosmux-capture/session-01-recv.bininto a typed Python object graph so we know exactly which RemoteXPC message type and whichXpcFlagsbits CDS sent. - Q2 — tcpdump on
utun4during a workingpymobiledevice3 remote rsd-inforound-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
utun4is TLS-encrypted, hook pymobiledevice3'srecv()buffers (or useSSLKEYLOGFILE+ 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/http2for prior-knowledge h2c (via theFramerinterface directly, because RemoteXPC does not fitnet/http.Handlersemantics)- a hand-written XpcWrapper / XpcPayload codec ported from
pymobiledevice3's
remote/xpc_message.pyas reference howett.net/plistfor 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.