Skip to content

Stage S2 Phase C — self-research queue (Q1..Q5)

Progress as of 2026-04-18

  • Q1 (verified) — see Q1-decode.md. Classification: generic RemoteXPC handshake, not pair-specific.
  • Q2 (verified) — see Q2-utun4-capture.md. Classification: plaintext HTTP/2 (h2c), no TLS. Q5 is not needed; Q3 replies can be synthesized directly from the held- local pcap of a live rsd-info round-trip.
  • Q4 (verified) — see Q4-server-side.md. No ready-made server in pymobiledevice3, but the XpcWrapper.build + _build_xpc_* helpers are reusable for Q3's dispatch table.
  • Q3 iter-0 (verified) — see Q3-iter-00-pcap-dispatch.md. Reference dispatch table built from the Q2 pcap — iPhone sends a single large MessageType="Handshake" DATA frame on stream 1 (62 services + 46 properties) after the h2c init, then RSTs the stream with STREAM_CLOSED. Stream 3 (REPLY_CHANNEL) stays open.
  • Q3 iter-1 (verified) — see iter-01/findings.md. Naive upfront replay of all 10 iPhone frames fails: CDS returns GOAWAY error_code=PROTOCOL_ERROR debug="request HEADERS: invalid stream_id" because the server cannot initiate streams on client-owned odd IDs (1, 3). iter-2 will implement event-driven dispatch where server frames are emitted in reaction to matching client frames on the same stream.
  • Q3 iter-2 (verified) — see iter-02/findings.md. Event-driven dispatch cleared the HTTP/2 layer entirely: 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) on a dispatch-ordering bug: the 14 KB Handshake DATA (replay #8) fires on client DATA(s1) instead of on client DATA(s3). Recv grew from 180→217 bytes (+21%). iter-3 relocates #8/#9 onto the DATA(s3) trigger and splits client DATA(s1) into a counter (1st#4, 2nd#5).
  • Q3 iter-3 (verified) — see iter-03/findings.md. Dispatch table v2 reproduced the pcap emit order exactly; all 10 replay frames dispatched (send = 14326 B). Recv unchanged at 217 bytes; CDS still ended with RST_STREAM(stream=3, STREAM_CLOSED). The ordering hypothesis from iter-02's post-mortem is empirically disproven — the rejection is at a deeper layer. Three remaining hypotheses for iter-4+: (1) drop our #9 RST_STREAM(s1), (2) unzero the device identifiers in #8 Handshake dict, (3) introduce per-frame spacing. iter-4 tests (1) first (one-line change).
  • Q3 iter-4 (verified, closes Q3 under replay) — see iter-04/findings.md. Dropping #9 removed CDS's reciprocal RST_STREAM(s3). No GOAWAY, no teardown. CDS reached a quiescent state after our 9-frame reply and waits silently. 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-⅔'s application-layer abort. The remaining hypothesis (unzeroed identifiers) is no longer cheaply testable under replay. Pure replay has reached its ceiling; any further s2c progress goes through Phase D backend work that can respond dynamically to CDS's service calls.
  • Q5 closed by Q2's plaintext verdict.
  • Phase D.5 smoke (verified, 2026-04-19) — see iter-05-go-backend/findings.md. First real-CDS test of the Go backend (cmd/iosmux-backend/, commits bc0b248 through 114548a). Cross-compiled for havoc's darwin/amd64, deployed in place of the iter-4 Python listener. Server-side wire output byte-exact vs iter-4 (14313 B, cmp clean), same 9 frames, same quiescent state. The Python spike listener is now functionally replaceable by the Go dispatcher in internal/backend/dispatcher.go. Next: Phase D.6 (per-service handlers, starting with whatever CDS requests after a devicectl pair trigger).
  • Phase D.6.0-B (verified, 2026-04-19) — see iter-06-pair-trigger/findings.md. Deployed D.6.0-A verbose-logging Go backend (commit 5d47ae5) with three interactive devicectl triggers. None produced a post-handshake request. Instead, CDS disconnects with peer EOF 23-36 s after our #8 big Handshake in all three sessions. iter-1 through D.5 pkill'd within ~5-13 s and never observed this — the "quiescent state" was observation-window bias. Four candidate causes ranked (placeholder UUID, stale Services dict, missing keepalive, UDID mismatch). iter-4 and D.5 findings amended with a clarification callout; byte-level conclusions unchanged. D.6.1 will test H1 first (patch random UUID into #8 per session).
  • Phase D.6.1 (verified, 2026-04-19, H1 CONFIRMED STRONGLY) — D.6.1-A landed IOSMUX_HANDSHAKE_UUID=random patching in the dispatcher (commit ffe7c29). D.6.1-B redeployed the patched backend on havoc. Result: zero CDS-initiated EOFs in 130 s of observation, TCP ESTABLISHED throughout, only 15-second TCP keepalives. The 16-byte zero UUID at offset 0x371C was indeed the session-progression gate. H2/H3/H4 do not need testing. iter-4 and D.5 clarifications updated from "CDS rejects after 30 s" to "CDS accepts, the rejection was solely UUID-driven". Next D.6.2: identify what CDS expects from us now that handshake is accepted (CDS silent at the HTTP/2 layer post-handshake — not a timeout, actual idle). See iter-07-uuid-patched/findings.md.
  • Phase D.6.2 H1c (verified, 2026-04-23, falsified) — Ran devicectl manage pair and unpair+pair fallback against the UUID-patched backend. Four sessions captured, every one 40-line handshake baseline only. devicectl surface identical Mercury 1000 error as iter-6. Trigger- type is not the missing variable. New top-ranked hypotheses for D.6.3: H1b (passive port instrumentation via widened pcap filter) and H2-reply (missing server follow-up frame beyond iter-01 replay corpus). See iter-08-pair-trigger/findings.md.
  • Phase D.6.3 (verified, 2026-04-24, H1b FALSIFIED) — Single wide-filter pcap capture (tcpdump -i lo0 "tcp and not port 49151") during the same manage pair trigger. Result: exactly one TCP connection observed the entire window, [::1]:49492 → [::1]:34719. Zero SYNs to any of our 62 advertised Services ports. CDS is not trying to back-connect — it expects a server-initiated follow-up frame on the existing HTTP/2 stream. H2-reply is the surviving hypothesis. D.6.4a will temporarily swap tunneld out of SPIKE mode and run Q2-methodology capture on utun4 to observe authoritative iPhone post-handshake bytes. See iter-09-wide-pcap/findings.md.
  • Phase D.6.4a (verified, 2026-04-24, H2-reply FALSIFIED + parallel service discovered) — Temporary SPIKE → stock tunneld swap + utun4 tcpdump during non-destructive triggers (rsd-info baseline + lockdown info + devicectl device info). Apparatus restored to SPIKE cleanly. Real iPhone greeting-stream output is byte-identical to our SPIKE backend — iter-01 10-frame corpus, then silent. H2-reply falsified: nothing missing on the greeting channel. Real finding: a parallel non-HTTP/2 TCP flow on iPhone:50367 (~9.5 KB iPhone→client, likely lockdown-over-TCP) that devicectl opens alongside the greeting. SPIKE backend has no equivalent — tunneld advertises only tunnel-port:34719. CDS hangs waiting for that parallel service. D.6.5 decodes the 50367 bytes and identifies what to emulate. Raw pcap held at /home/op/backups/iosmux/pcaps/iosmux-d10-real.pcap (contains real UDID, gitignored). See iter-10-real-iphone-ref/findings.md.
  • Phase D.6.5 (verified, 2026-04-24, protocol + advertisement identified) — Pure local decode. 50367 = classic lockdown-over-TCP (plaintext XML plist, 4-byte BE length prefix, 3 verbs: RSDCheckin, QueryType, GetValue). Advertised in our own Handshake Services dict under com.apple.mobile.lockdown.remote.trusted. Services census: 62 total, 40 classic-lockdown (UsesRemoteXPC=False), 22 RemoteXPC — one Go-backend handler unlocks 40 endpoints. D.6.6 implements the handler. See iter-11-lockdown-decode/findings.md.
  • Phase D.6.5 control (verified, 2026-04-24, back-connect hypothesis FALSIFIED) — Re-ran iter-09 wide-pcap on SPIKE backend with devicectl device info details trigger. Exactly one SYN (client→our-backend :34719), zero SYNs to any advertised service port, zero RSTs. CDS bails upstream of consulting Services dict — pair/trust signal reads "unpaired" in SPIKE. Iter-10's :50367 flow was opened by pymobiledevice3 (no trust gate), not devicectl (has trust gate). D.6.6 plan revised to research-a + research-b preceding implementation. See iter-12-spike-control/findings.md.

Status: reviewed — all five research questions are resolved empirically from artifacts on havoc (four verified with evidence, one closed without work because its premise was invalidated). Q3 has iter-0 landed as a reference dispatch table; iter-1 through iter-4 closed the Python-replay investigation; Phase D.5 has landed the Go production equivalent with byte-exact parity.

Hard rule kept intact: no guessing. Every answer below is tied to a raw artifact (capture, pcap, decoded XpcWrapper object) or to a direct upstream source quote from pymobiledevice3. Any residual gap is named explicitly.

This file is the authoritative tracker for the five questions. It is referenced from plan-stage2-pair-flow.md Phase C section.

Context (what we already know — one paragraph)

The Stage S2.C self-experiment on havoc confirmed empirically that CoreDeviceService speaks plain prior-knowledge HTTP/2 cleartext to the address tunneld advertises, with no TLS and no ALPN, and that the RemoteXPC framing above HTTP/2 uses the same XpcWrapper (magic 0x290bb092) + XpcPayload (magic 0x42133742) byte layout that pymobiledevice3's remote/xpc_message.py already implements. The sandbox probe confirmed Shape B's fd-passing primitives are legal inside CoreDeviceService. The inject LC_LOAD_DYLIB reaches the same PID that opens the pair nw_connection. Full hex-level detail in research/s2c-self-experiment/FINDINGS.md.

What the experiment did not tell us: what bytes the other end (the real iPhone's RSD endpoint, or the plaintext side of pymobiledevice3's tunnel) actually returns in response to CDS's first XPC payload. Our spike listener replies with just the HTTP/2 handshake and nothing at the application layer, so CDS stalls after its first real DATA frame. The five questions below are the ordered plan for closing that gap on havoc.

Q1 — Decode the captured 44-byte XPC payload

What we want to know: the semantic meaning of the 44 bytes CDS sent on stream 1 (ROOT_CHANNEL) right after the HTTP/2 handshake. Specifically: which RemoteXPC message type, which XpcFlags bits are set, and what the 20-byte body decodes to when parsed as a bplist16 xpc_object.

Why it matters: we need to know if CDS's first move is a generic RemoteXPC handshake (in which case every future session opens the same way), a pair-specific request (in which case we are already inside the pair handshake), or something else. This classifies the entire subsequent dialog.

How to answer (empirical, no guessing):

  1. Take the raw bytes in research/protocol/s2c-self-experiment/results/iosmux-capture/session-01-recv.bin.
  2. Drop the HTTP/2 frame headers to isolate the DATA frame payloads per stream. The 44-byte payload is the first DATA stream=1 len=44 frame.
  3. Run the isolated payload through pymobiledevice3's own parser:
from pymobiledevice3.remote.xpc_message import XpcWrapper
# assuming `payload` is the 44-byte bytes object
wrapper = XpcWrapper.parse(payload)
# inspect wrapper.flags, wrapper.size, wrapper.message, etc.

(Exact class / method names confirmed via pymobiledevice3/remote/xpc_message.py in the upstream checkout.) 4. Same for the 24-byte and 24-byte DATA frames that follow on streams 1 and 3.

Done criteria:

  • A short section in a per-question findings doc docs/research/s2c-self-experiment/Q1-decode.md with:
  • The exact pymobiledevice3 parser call used
  • The decoded Python representation of each of the three DATA frames (stream 1 len=44, stream 1 len=24, stream 3 len=24)
  • A classification: is this a pair request, a generic open, or something else?
  • If pymobiledevice3's parser refuses any payload, that is itself an answer — document the refusal and the reason.
  • The finding gets linked back from this file with status verified and a link to the Q1 doc.

If Q1 reveals the classification: proceed to Q3 with a known starting point. If Q1 decodes cleanly but the classification is ambiguous: go to Q2 in parallel — getting the server side may disambiguate. If Q1 refuses to parse: something is wrong with either our byte isolation or our understanding of the framing. Halt and investigate; do not guess.

Q2 — Capture pymobiledevice3 client traffic on utun4

What we want to know: whether the bytes on the VM's utun4 interface, as produced by a working pymobiledevice3 remote rsd-info / mounter list --tunnel / developer dvt ls / command, are plaintext HTTP/2 (transparent tunnel) or TLS-encrypted (termination is deeper than utun4).

Why it matters: if utun4 carries plaintext, we can capture the real server responses the iPhone sends over a live session and feed those straight into Q3's listener replies. That is the gold standard, fully self-contained on havoc. If utun4 is TLS-encrypted, Q5 (post-TLS hook) becomes the fallback — still self-contained, but more invasive.

How to answer (empirical, no guessing):

  1. Restart havoc tunneld in stock (non-spike) mode so it advertises the real iPhone RSD endpoint. iosmux-restore.sh already does this; confirm via curl -sm 2 http://127.0.0.1:49151/ showing a non-::1 tunnel address.
  2. On havoc-root, start tcpdump on utun4 capturing to a pcap file, filtered to the specific tunnel IPv6 address / port pair (from the tunneld JSON response):
sudo tcpdump -i utun4 -s 0 -U -w /tmp/utun4-rsdinfo.pcap \
    'host <tunnel-ipv6> and port <tunnel-port>'
  1. In another havoc shell run:
/Users/[vm-user]/pymobiledevice3-venv/bin/pymobiledevice3 remote rsd-info

Let it complete, then stop tcpdump. 4. Copy the pcap to docs/research/s2c-self-experiment/results/q2-utun4-rsdinfo.pcap and inspect locally with tshark -r ... -V or Wireshark.

Done criteria:

  • A per-question findings doc docs/research/s2c-self-experiment/Q2-utun4-capture.md with:
  • First 64 bytes hex of the first packet client→iPhone
  • First 64 bytes hex of the first packet iPhone→client
  • Explicit classification: plaintext HTTP/2 (preface visible, or HTTP/2 SETTINGS frame visible) or TLS-encrypted (TLS record header 0x16 0x03 visible) or something else (note and halt).
  • pcap committed to results/ as evidence (confirmed clean of personal data — typically a pcap of RSD traffic is just IPv6 plus TCP plus HTTP/2 frames, no UDIDs in cleartext, but verify before committing).

If plaintext HTTP/2: rejoice — Q3's listener replies can be synthesized directly from this capture, and Q5 is not needed. If TLS: document the TLS version/cipher/PSK hint visible in the ClientHello and proceed to Q5 as the next step. If neither: halt and investigate; do not guess the protocol.

Q3 — Incremental dialog bisection via the spike listener

What we want to know: the full request/response pair sequence that CDS goes through for a working pair flow, one step at a time. Each step is empirically grounded — we never reply to CDS with made-up bytes.

Why it matters: this is the actual "write down the Apple protocol" work that unblocks Phase D implementation. By advancing one step at a time and only replying with bytes we can point to a source for (from Q1, Q2, Q4, or direct upstream pymobiledevice3 reference), we walk the whole pair handshake without guessing.

How to answer:

  1. Extend docs/research/s2c-self-experiment/iosmux-spike-listener.py (or replace it — user decision at that time) to keep a per-session dispatch table mapping (stream_id, message_type) to a hand-crafted response payload. For the first iteration, respond to CDS's initial stream-1 44-byte payload with the single reply that Q1+Q2 justify as "what the real server would send here".
  2. Restart the spike environment (IOSMUX_SPIKE=1 tunneld, listener, CDS), click Pair (or run devicectl list devices), capture the new state.
  3. Observe the new request CDS sends once our first reply lands. Decode it via Q1's methodology. Find or derive the correct reply. Add to the dispatch table. Repeat.
  4. Each iteration gets its own short finding doc: docs/research/s2c-self-experiment/Q3-iter-NN.md with:
  5. Incoming request hex + decoded structure
  6. Source for the outgoing reply (Q1 bytes, Q2 bytes, or a pymobiledevice3 source file path + line numbers)
  7. CDS's observed next behavior

Done criteria:

  • Either: the full pair handshake is walked end-to-end without a gap, producing a sequence of request/response pairs sufficient for Phase D implementation to be fully specified.
  • Or: an iteration reveals a request whose correct reply we cannot source from Q1, Q2, Q4, or pymobiledevice3. That is a named gap — document the exact request and halt. Do not guess.

If the dialog walks to completion: Phase D can start. The Go backend's initial handler set is a direct translation of the dispatch table Q3 produced. If the dialog hits a named gap: the gap is named explicitly in iter-N doc, and iter-N+1 re-runs the matching command on havoc via pymobiledevice3 remote ... under tcpdump to capture authoritative iPhone bytes for that specific request.

Q4 — Does pymobiledevice3 ship server-side utilities?

What we want to know: whether pymobiledevice3's source tree contains any code that implements the server side of the RemoteXPC protocol — test fixtures, mocks, unit test servers, or any "pretend to be an iPhone" utilities — that we could reuse directly to drive Q3's replies instead of hand-crafting them from protocol reference reads.

Why it matters: if it exists, every hour of Q3 iteration saved by running a ready-made server is an hour Phase D comes sooner. If it does not, Q3's hand-crafted approach remains the plan, no worse off.

How to answer (straight source read, no guessing):

  1. Grep the pymobiledevice3 upstream checkout for candidates:
cd /home/op/dev/myrepos/pymobiledevice3-scratch  # or the havoc checkout
grep -rn -iE "server|listen|mock|fixture|fake" \
    pymobiledevice3/remote/ tests/
grep -rn "start_server\|listen(\|accept(" pymobiledevice3/
grep -rn "XpcWrapper.build" pymobiledevice3/
  1. Specifically check tests/ for any fixture that opens a listening socket and speaks RemoteXPC back to a client.
  2. Also check if there is a pymobiledevice3 remote serve CLI subcommand or anything similar in pymobiledevice3/cli/.

Done criteria:

  • A short finding doc docs/research/s2c-self-experiment/Q4-server-side.md listing whatever was found (paths, what they do, usability rating) or explicitly concluding "no server-side code in pymobiledevice3; Q3 remains hand-crafted".
  • If any fixture is found usable, the exact call sequence to drive it is documented.

This question is independent of Q1/Q2/Q3 — it can be done in parallel or first. It has no empirical dependencies beyond reading source.

Q5 — Post-TLS byte capture of a live pymobiledevice3 session

What we want to know: if Q2 reveals utun4 carries TLS-encrypted bytes, the plaintext equivalent of those bytes as pymobiledevice3 sees them internally after TLS termination.

Why it matters: this is the fallback path for Q3's server replies if Q2 does not give us plaintext directly. The pymobiledevice3 process already has the PSK and the decrypted stream — we just need to observe what it reads and writes.

How to answer (last-resort self-research, no guessing):

Two alternatives, tried in order of invasiveness:

  1. SSLKEYLOGFILE + Wireshark. Some Python TLS stacks honor SSLKEYLOGFILE to dump session keys for offline decryption. Check if pymobiledevice3's _create_client_socket path uses a TLS implementation (ssl stdlib or a third-party PSK library) that supports this. If yes, run the capture with the env var set and decrypt the Q2 pcap in Wireshark.
  2. recv() hook via site-packages edit. If SSLKEYLOGFILE is not honored, add a small instrumentation patch to the pymobiledevice3 upstream clone (under docs/patches/pymobiledevice3/ following the same workflow as patch 0001) that wraps the TLS socket's recv() / sendall() methods with a logger. Restart tunneld and CLI commands; capture the decrypted bytes to /tmp/iosmux-pymob-plaintext.log.

Done criteria:

  • A per-question finding doc docs/research/s2c-self-experiment/Q5-post-tls.md with:
  • Which alternative was used
  • The captured plaintext bytes (hex) for a complete remote rsd-info session in both directions
  • Cross-reference: these bytes should match (modulo header framing) what Q3 needs for its dispatch table replies.
  • If neither alternative works, that would have been a hard gap. Not applicable in this session: Q2 confirmed plaintext h2c on utun4, so no TLS-termination step exists to work around. Q5 is closed without execution; see Q5-post-tls.md.

Mechanics

  • Each question has its own short finding doc under docs/research/s2c-self-experiment/ once answered. This tracker is updated with a link and the final status.
  • Artifacts (hex dumps, pcaps, decoded JSON, screenshots) live alongside the finding doc in results/ or q{N}-... files.
  • Before any per-question doc is committed, a personal-data sweep (UDID, hostname, MAC-derived IPv6, serial) is mandatory. Redact before commit; do not rely on repo privacy.
  • Each answered question closes with a one-line update here: Q{N}: verified — see Q{N}-doc.md with a link. Until then the question stays draft.