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) — all resolved

The experiment answered "what does CDS send". The Q1-Q5 follow-up answered "what does CDS expect back" — entirely on havoc, no external capture needed. Status as of 2026-04-18:

  • Q1 (verified) — decoded the captured CDS bytes via pymobiledevice3's own XpcWrapper.parse. Classification: generic RemoteXPC connection init, byte-exact with pymobiledevice3's own _do_handshake. See Q1-decode.md.
  • Q2 (verified)tcpdump on havoc's utun4 during a live pymobiledevice3 remote rsd-info shows plaintext HTTP/2 prior-knowledge (h2c). No TLS. The pcap is the authoritative source for iPhone-side reply bytes. See Q2-utun4-capture.md.
  • Q3 iter-0 (verified) — reference dispatch table extracted from the Q2 pcap. After h2c init the iPhone unilaterally sends a single 14 KB MessageType="Handshake" DATA frame on stream 1 (62 services + 46 properties), then RSTs the stream with STREAM_CLOSED (error_code 5). Stream 3 stays open as the persistent channel. See Q3-iter-00-pcap-dispatch.md.
  • Q3 iter-1 (verified) — live replay of the iter-0 bytes at CDS rejected with a clean GOAWAY error_code=PROTOCOL_ERROR debug="request HEADERS: invalid stream_id". Root cause: HTTP/2 forbids the server from initiating streams on client-owned odd IDs. Fix for iter-2: event-driven dispatch. See iter-01/findings.md.
  • Q3 iter-2 (verified) — event-driven dispatch cleared the HTTP/2 layer entirely (no more PROTOCOL_ERROR). Full XPC handshake now exchanged on both ROOT_CHANNEL (s1) and REPLY_CHANNEL (s3). CDS closed REPLY_CHANNEL with RST_STREAM(stream=3, error_code=5 STREAM_CLOSED) due to a dispatch-ordering bug: our table emits the 14 KB Handshake on client DATA(s1) instead of on client DATA(s3). Recv grew 180→217 bytes, +21%. Fix for iter-3: relocate #8/#9 to the DATA(s3) trigger and split DATA(s1) into a counter. See iter-02/findings.md.
  • Q3 iter-3 (verified) — dispatch table v2 reproduced the pcap emit order exactly; all 10 replay frames dispatched cleanly (send = 14326 B). Recv unchanged at 217 bytes; CDS still ended with RST_STREAM(stream=3, STREAM_CLOSED). Ordering hypothesis empirically disproven — the rejection is at a deeper layer than frame scheduling. Three hypotheses remain for iter-4+: (1) drop our #9 RST_STREAM(s1), (2) unzero identifiers in #8 Handshake dict, (3) introduce per-frame spacing. iter-4 tests (1) first. See iter-03/findings.md.
  • Q3 iter-4 (verified, closes Q3 under replay) — dropping replay #9 from the DATA(s3) trigger removed CDS's reciprocal RST_STREAM(s3): no GOAWAY, no teardown of any kind. CDS reached a quiescent state after receiving our 9-frame reply (#0–#8) and simply waits. Recv shrank 217→204 bytes — the missing 13 bytes are exactly the reciprocal RST_STREAM CDS no longer needs to send. Hypothesis 1 confirmed: our own server-initiated reset was the direct cause of iter-2/iter-3's application-layer abort. The remaining hypothesis (unzeroed identifiers) is no longer cheaply testable under replay — CDS's silence hides the signal. Pure replay has reached its ceiling. Next productive work on the s2c side is Phase D backend, which can respond dynamically to CDS's post-handshake service calls instead of pre-recording them. See iter-04/findings.md.
  • Phase D.5 smoke (verified, replaces Python listener with Go backend) — Cross-compiled cmd/iosmux-backend/ for havoc's darwin/amd64, deployed in place of the iter-4 Python listener, CDS triggered. Server-side wire output is byte-exact vs iter-4 (14,313 B, cmp clean). Same 9 frames, same quiescent state. The Python spike listener is now functionally replaceable by the Go implementation built on x/net/http2.Framer + internal/xpc/ codec. Next: Phase D.6 (per-service handlers, starting with whatever CDS requests after devicectl pair). See iter-05-go-backend/findings.md.
  • Phase D.6.0-B (verified, reinterprets "quiescent state") — Deployed D.6.0-A verbose-logging Go backend on havoc, ran three interactive devicectl triggers (device info, manage pair, device info processes). None produced a post-handshake request: instead, CDS tore down the TCP connection with peer EOF 23-36 s after our #8 big Handshake in all three sessions. This is the natural-timeout that iter-1 through D.5 could not surface because each prior run pkill'd within ~5-13 s. Iter-4's "quiescent state" language was observation-window bias — CDS silently evaluates the handshake and disconnects, it does not "wait indefinitely". Four candidate causes ranked (H1 placeholder UUID, H2 stale Services, H3 missing keepalive, H4 UDID mismatch). Next D.6.1 tests the cheapest first. See iter-06-pair-trigger/findings.md.
  • Phase D.6.1-B (verified, H1 CONFIRMED STRONGLY) — Landed UUID patching in the Go backend (IOSMUX_HANDSHAKE_UUID=random, commit ffe7c29), redeployed to havoc, let one session run 130 s past handshake completion. Result: zero CDS-initiated EOFs, TCP ESTABLISHED for the full window, only 15-second TCP keepalives. The 16-byte zero UUID at offset 0x371C in the redacted iter-01 fixture was the sole session-progression gate. H2/H3/H4 no longer need testing. New third state observed: CDS accepts the handshake and waits silently — no disconnect (good) but also no new application traffic (still to investigate in D.6.2). The iter-4 and D.5 byte-level findings were always correct; only the UUID field blocked progression. See iter-07-uuid-patched/findings.md.
  • Phase D.6.2 H1c (verified, falsified) — Swapped devicectl device info trigger for devicectl manage pair (and fallback unpair+pair) against the same UUID-patched backend. Four sessions captured; every one hit the same 40-line handshake baseline with zero post-handshake frames. devicectl surface identical Mercury 1000 error as iter-6. Trigger-type is not the missing variable. D.6.3 widens pcap filter to separate H1b (CDS back-connects to advertised service ports) from H2-reply (we're missing a server follow-up frame beyond the iter-01 replay corpus). See iter-08-pair-trigger/findings.md.
  • Phase D.6.3 (verified, H1b FALSIFIED decisively) — Single capture with tcpdump -i lo0 "tcp and not port 49151" (wider than iter-8's port-filtered capture). Exactly one TCP connection observed: CDS [::1]:49492 → [::1]:34719 (our backend). Zero SYNs to any of the 62 advertised service ports. CDS is NOT trying to back-connect anywhere — it's waiting for a server-initiated follow-up frame inside the same HTTP/2 stream. H2-reply is the surviving hypothesis. Next D.6.4a: temporary SPIKE→stock tunneld swap + utun4 pcap of real iPhone post-handshake flow (Q2 methodology, extended window) for authoritative reference bytes. See iter-09-wide-pcap/findings.md.
  • Phase D.6.4a (verified, H2-reply FALSIFIED + parallel service discovered) — Swapped SPIKE → stock tunneld, captured utun4 during rsd-info + lockdown info + devicectl info triggers, swapped back to SPIKE cleanly. Real iPhone greeting-stream output is byte-identical to our SPIKE backend (iter-01 10-frame corpus, falls silent after #9 RST_STREAM(s1)). H2-reply (missing follow-up frames on greeting stream) is falsified. Real discovery: a parallel non-HTTP/2 TCP service on iPhone:50367 (~9.5 KB iPhone→client, likely lockdown-over-TCP) opened by devicectl in parallel with the greeting session. SPIKE mode exposes no analogous listener — that's where CDS hangs. D.6.5 decodes the parallel service bytes and identifies what to emulate. Raw pcap held local (/home/op/backups/iosmux/pcaps/, gitignored) — contains real UDID. See iter-10-real-iphone-ref/findings.md.
  • Phase D.6.5 (verified, protocol identified + service advertised) — Pure local decode of the iter-10 held-local pcap. Protocol on iPhone:50367: classic lockdown-over-TCP (plaintext XML plist, 4-byte BE length prefix, 3-verb API: RSDCheckin / QueryType / GetValue). Port 50367 is advertised in our Handshake Services dict under com.apple.mobile.lockdown.remote.trusted (UsesRemoteXPC=False, ServiceVersion=1). Full Services census: 62 services total, 40 classic-lockdown (UsesRemoteXPC=False), 22 RemoteXPC — a single lockdown handler in our Go backend unlocks 40 endpoints. D.6.6 implements the handler (bind [::1]:50367, read length-prefixed XML plists, dispatch 3 verbs, serve cached 88-key device dict for GetValue). See iter-11-lockdown-decode/findings.md.
  • Phase D.6.5 control (verified, back-connect hypothesis FALSIFIED) — Re-ran iter-09 wide-pcap methodology on SPIKE backend but with devicectl device info details as trigger (the one that iter-10 proved opens lockdown against real iPhone). Expected SYN to [::1]:50367 + ECONNREFUSED. Actual: exactly one SYN, client→our-backend :34719, zero SYNs to any advertised service port, zero RSTs. CDS does NOT back-connect in SPIKE mode regardless of trigger. Causal model revised: CDS evaluates a pair/trust signal after Handshake and bails upstream of Services consultation when it reads "unpaired / untrusted" (which our SPIKE Handshake apparently signals). Important re-read of iter-10: 50367 flow there was opened by pymobiledevice3 (no trust gate), not by devicectl (has trust gate). D.6.6 plan revised: research-a (Properties dict diff) + research-b (per-field bisection patching) precede the implementation step. See iter-12-spike-control/findings.md.
  • Phase D.6.6-research-a (verified, H-HS narrowed) — Byte-diffed the 46-key Handshake Properties sub-dict between the SPIKE fixture and the real iPhone reference pcap. 36 keys bit-exact same, 5 zeroed-by-us (declared redactions: UDID / Serial / MAC / ChipID / BootSessionUUID), 5 differ (all iOS 26.4.1→26.4.2 point-version drift, not gate candidates), 0 only-ours / only-real. Dict shape, key set, and insertion order are all identical. Consequence: iter-12's speculative H-HS candidates (HostAttached, PairRecords, TrustedHostAttached, ActivationState, PasswordProtected) are not in Properties at all — they live in the GetValue response on the classic-lockdown port :50367 that CDS never reaches in SPIKE. H-HS narrows to a 5-slot field-bisection over the identity redactions, UDID most likely gate by a wide margin. D.6.6-research-b is now a concrete patch order: UDID → Serial → ECID → BootSessionUUID → MAC, stopping on first SYN to [::1]:50367. See iter-13-hs-props-diff/findings.md.
  • Phase D.6.6-research-b iter-14 (verified, UDID-alone FALSIFIED) — Added env-gated UDID patching (IOSMUX_HANDSHAKE_UDID at fixture offset 0x34BC, same mechanism as D.6.1's UUID patch at 0x371C). Ran the iter-12 trigger across 4 runs. Handshake carries the real tunneld-advertised UDID (confirmed in verbose log every run), devicectl reports pairingState: unpaired + Mercury warning (same as iter-12), 0 SYN to any advertised service port in a 30-second window. H-HS narrowed slot #1 refuted. Interesting positive signal: CDS closes the session ~3 s after Handshake (vs iter-12's ~30 s with zero UDID) — UDID IS being consumed, just triggering faster rejection rather than progression. Methodology note surfaced: tunneld hardware UDID (25-char, goes into Handshake) ≠ CoreDevice UUID (36-char, goes into devicectl --device); iter-12's placeholder meant CoreDevice UUID. Next options: combined UDID + Serial + ECID patch (iter-15), or pivot to host-side pair-state investigation. See iter-14-udid-patch/findings.md.
  • Phase D.6.6-research-b iter-15 (verified, H-HS multi-field FALSIFIED) — Scaffolded env-gated patching for SerialNumber (offset 0x3364, 10 B ASCII) and UniqueChipID / ECID (offset 0x34EC, 8 B LE uint64; initial guess of 0x34EA was off by 2 bytes — our own verbose decoder caught it with "unknown type code 0x401e4000" after the patch corrupted the low bytes of the XPC uint64 type tag — fix in 2bcea98). Fetched real identity values via pymobiledevice3 remote rsd-info --tunnel <UDID> in a brief stock-tunneld window. Ran iter-12 trigger across two clean post-fix runs with all 3 Properties identity fields (UDID + Serial + ECID) matching tunneld's advertisement. Handshake parses cleanly on CDS side (no RST_STREAM), devicectl still reports pairingState: unpaired + Mercury warning, 0 SYN to any advertised service port in a 30-s window. Most important signal: close-timing reverts from iter-14's ~3 s to iter-12's ~30 s. If the gate were wire-level identity matching, iter-15 should have either unblocked (SYN to :50367) or closed fast like iter-14. Neither happened. H-HS dead at both single-field (iter-14) and multi-field (iter-15) levels. H-PairState promoted to primary working hypothesis: the gate reads host-side state (CoreDevice paired-devices DB / lockdown escrow) that wire-state patches cannot influence. Next step: iter-16 read-only pass on /var/db/lockdown/<UDID>.plist, CoreDevice DB, keychain entries — then iter-17 decide whether pair-record plant is feasible without corrupting live iPhone usage. Apparatus lesson surfaced across iter-15's 5 runs: CoreDevice registry is fragile and decays within ~5 min after the first SPIKE-mode experiment per VM reboot — reproducibility runs must be collected back-to-back in the same warm window. See iter-15-multifield-patch/findings.md.
  • Phase D.6.6-research-c iter-16 (verified, H-PairState localized) — Read-only filesystem, SQLite, keychain, log show, and fs_usage (SIP-disabled) survey of havoc's pair-state surface. The authoritative pair store is /var/db/lockdown/RemotePairing/user_{UID}/peers/<CoreDevice-UUID>.plist — an NSKeyedArchiver CUPairedPeer record keyed by the 36-char CoreDevice UUID, holding a 32-B Ed25519 public key (pk), a 16-B BT IRK (altIRK), an ident NSUUID, and device metadata. The host's own identity lives in the sibling identity.plist with public + PRIVATE Ed25519 keys stored inline in the plist (significant iOS 17+ design shift from the classic keychain-held identity model). CoreDevice's on-disk SQLite (~/Library/Developer/CoreDevice/Devices/db.sqlite) is a cache with 0 data rows. System keychain has 0 iPhone/RemotePair entries. BT-LE paired-device DB has 0 rows. The gate: remotepairingd loads peer records into memory at startup and matches live Bonjour _remotepairing._tcp.local adverts against them. Neither pymobiledevice3 tunneld nor SPIKE backend publishes that mDNS service, so no advert ever resolves → every advert tagged unauth device → CoreDevice reports pairingState: unpaired. The trust gate is below RSD and requires: (a) a matching mDNS advert, AND (b) a successful Ed25519 challenge-response the device signs with its own secure-enclave-held private key. iter-17 minimal test: plant a fake peer + fake mDNS advert, kickstart remotepairingd, check whether metadata-query pairingState flips independently of the cryptographic handshake. Backup-first discipline mandatory. See iter-16-pairstate-survey/findings.md.
  • Phase D.6.6 iter-17/18 (verified, xcrun devicectl manage pair FALSIFIED + bridge-not-proxy architecture confirmed) — iter-17 attempted the iter-16 plant-and-advert minimal test; abandoned mid-flight when remotepairingd rejected the planted advert (Ignoring bonjour advert without expected TXT record keys: Apple expects identifier=/authTag=/ver=/minVer=/flags=, the planted one used ident=/id=/uuid=/udid=) and authTag was discovered to rotate every ~8 s alongside the rotating identifier (iOS 17+ privacy), making static planted adverts non-viable without first solving the authTag derivation. iter-18 then took the obvious next step: drive the documented Apple pair flow xcrun devicectl manage pair --device <CoreDevice-UUID>. Two runs, both fail in ~2 s before any Trust-this-computer dialog can appear on the iPhone. Run A (active 150 KB inject, sha 26ca1658): kAMDNotConnectedError / Mercury 1000 — CDS finds the synthetic device the inject registers but cannot reach the iPhone-side notification_proxy service through the synthetic device's tunnel. Run B (96 KB .disabled stub, sha 97a8cfae, swapped into the active load-path): Connection refused 127.0.0.1:62078 — synthetic device disappears from devicectl list devices entirely (No devices found), CDS falls back to classic usbmuxd forward path, no listener. The 96 KB variant is also not a no-op stub — its own log line [iosmux] FATAL: XPC proxy init failed confirms it tries to bring up its own plumbing and crashes; symbol-table diff (267 vs 411 symbols, __text 0x7a10 vs 0xf13b) shows it lacks the SPIKE / RSD hook surface but still installs the Objective-C IosmuxMDProxy class. Combined outcome re-confirms the bridge-not-proxy architecture already documented in connection.md "Phase S2.B note" and stage2.md "Phase B realization": iosmux synthesises an iOS device inside the Mac VM's CoreDevice subsystem; it is not a transparent proxy of the physical iPhone. Pair-state, trust, and the classic-lockdown surface are owned by the iosmux Option δ shim, not derived from a successful iOS Pair-Setup against the real device. Phase D.6.6-impl remains the next concrete step (Go backend binds [::1]:50367, serves the three-verb classic-lockdown protocol, declares the synthetic device's pair-state from a captured empirical source — exact mechanics still being researched). Future iters do not retry xcrun devicectl manage pair, mDNS-advert planting, or authTag derivation. See iter-18-devicectl-pair-falsified/findings.md.
  • Q4 (verified) — pymobiledevice3 has no "pretend to be iPhone" server fixture, but its XpcWrapper.build + _build_xpc_* helpers are direction-agnostic and reusable for server-side replies. See Q4-server-side.md.
  • Q5 (closed without work) — the post-TLS hook was conditional on Q2 showing TLS. Q2 showed plaintext. No TLS means no key-material extraction, no recv() hook. See Q5-post-tls.md.

Every answer is tied to a raw artifact or a direct pymobiledevice3 source line. Named residual gaps (flag bit 0x200 without an enum name, and the message_id=1 skip) are small and non-blocking for Phase D.

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.