Skip to content

Q3 iter-4 findings — dropping replay #9 removes CDS's RST_STREAM, reveals CDS is silently waiting for more

Status: verified — 2026-04-19

Live run on havoc. iter-4 listener (commit 1cd564e) differs from iter-3 by exactly one thing: the DATA(s3) trigger emits [7, 8] instead of [7, 8, 9]. Send buffer shrank from 14326 B to 14313 B (= −13 B, the one dropped RST_STREAM frame). Recv shrank from 217 B to 204 B (= −13 B, CDS's RST_STREAM(s3) that was present in iter-3 is gone). No GOAWAY, no RST_STREAM, no client-side close signal of any kind. Every byte below decoded through standard HTTP/2 framing.

Update 2026-04-19: root cause identified and fixed

D.6.0-B surfaced that the "quiescent state" below actually ended with CDS closing TCP after ~30 s of silence. D.6.1-B then confirmed hypothesis H1 (placeholder UUID as session-progression gate): swapping the 16-byte zero placeholder in #8 for a fresh RFC 4122 v4 UUID per session made CDS hold the connection ESTABLISHED for the full 130 s observation window. The iter-4 byte-level findings below were always correct — the dispatch table, frame ordering, and wire output are all valid. The handshake was blocked solely by the 16-byte UUID field redacted at source in iphone_replay_bytes.py. With that one field patched, iter-4 behaviour becomes "CDS accepts handshake and waits indefinitely" (as originally described), not "CDS times out after 30 s". See iter-06-pair-trigger/findings.md for the timeout observation and iter-07-uuid-patched/findings.md for the fix and confirmation.

TL;DR

Hypothesis 1 confirmed: our replay #9 RST_STREAM(s1, STREAM_CLOSED) was the direct cause of iter-2/iter-3's RST_STREAM(s3, STREAM_CLOSED) from CDS. CDS was reciprocating: it saw our immediate server-side reset on s1 as "server gave up mid-handshake" and matched the error on the remaining stream (s3).

New state: with #9 dropped, CDS no longer emits any teardown signal. It sends its full 8-frame handshake up to DATA(s3) INIT_HANDSHAKE (all consistent with iter-2/iter-3 client behaviour for those frames), receives our full 9-frame reply (#0#8), then goes silent. No further client-to-server bytes. No GOAWAY. No RST. The TCP connection stays open indefinitely from CDS's side; iter-4 was ended by pkill on the listener.

Forward progress: qualitative. Recv byte count went down, but the signal it went down means CDS is no longer rejecting the exchange — it has simply reached the end of its canned handshake output and is waiting for server-initiated next-step work that our listener does not provide.

This is the expected boundary of a pure replay strategy. Further progress requires either: (a) simulating whatever server-side RemoteXPC service call CDS expects next, or (b) accepting that research phase C has answered what it can answer and moving to Phase D backend implementation.

The session, byte by byte

Setup

  • Listener bound [::1]:34719, IOSMUX_SPIKE_REPLAY=1, iter-4 dispatcher (commit 1cd564e).
  • SPIKE tunneld (PID 1952) unchanged from iter-1 chain.
  • CDS restart via killall CoreDeviceService, devicectl list devices to drive one tunnel query.

Client → listener: 8 HTTP/2 frames, 204 bytes total

# type stream flags payload len wire len
(preface) 0 24
0 SETTINGS 0 0x00 12 21
1 WINDOW_UPDATE 0 0x00 4 13
2 HEADERS 1 0x04 0 9
3 DATA 1 0x00 44 53
4 SETTINGS 0 0x01 0 9
5 HEADERS 3 0x04 0 9
6 DATA 1 0x00 24 33
7 DATA 3 0x00 24 33

No RST_STREAM(s3), no GOAWAY, no further frames. Byte-exact with iter-3's recv for frames 0 through 7. Frame 8 (RST_STREAM) that iter-3 had is simply absent — CDS had no reason to send it because it was never provoked by our #9.

Listener → CDS: 9 HTTP/2 frames, 14313 bytes total

Dispatches:

client trigger server frames emitted notes
SETTINGS(s0, non-ACK) #0, #1, #2 unchanged from iter-3
HEADERS(s1) #3 unchanged
DATA(s1) #1 #4 unchanged
HEADERS(s3) #6 unchanged
DATA(s1) #2 #5 unchanged
DATA(s3) INIT_HANDSHAKE #7, #8 #9 dropped — this is the one change

All 9 emitted dispatches landed on wire. Post-#8 the listener stayed in its recv() loop; CDS sent nothing; the 30-second read timeout would have fired eventually, but the agent-driven trigger script killed the listener ~1 second after devicectl list devices returned.

Why recv shrank instead of growing

iter-3's recv = 217 B included CDS's 13-byte RST_STREAM(s3, STREAM_CLOSED) reciprocating our #9. iter-4 removed the provocation, so CDS never emitted the reciprocation. The 13-byte delta is exactly that one frame. Recv 217 − 13 = 204.

"Smaller recv" sounds like regression; it is not. It is the absence of an error signal. The frame CDS emitted in iter-3 was a diagnostic-of-our-mistake, not progress. Removing our mistake removed CDS's diagnostic.

What iter-4 confirmed

  1. #9 is load-bearing for iter-3's symptom. Drop it, the symptom vanishes.
  2. CDS's RST_STREAM(s3) in iter-3 was reciprocal, not independent. That kills the initially tempting alternate hypothesis that CDS had some other problem with s3 (e.g., the INIT_HANDSHAKE flag semantics or the 0x00400001 flag word).
  3. CDS accepts our #8 14 KB Handshake dict with zeroed identifiers without complaint — no reject, no GOAWAY, no error code. The identity-validation hypothesis (iter-3 #2) is still open but less load-bearing than it looked.
  4. Replay strategy has a clear ceiling: it can reach a quiet end state, but cannot itself cause CDS to progress further.

What iter-4 did NOT resolve

  • What CDS expects next. It is silent but attentive. Likely it is waiting for the real-device-side to initiate a specific lockdown-over-XPC service call (e.g. com.apple.mobile.lockdown via one of the 62 service ports advertised in our Handshake dict). Until we synthesize that, CDS will not send a pair request.
  • Whether zeroed identifiers matter at all. With CDS gone silent, we cannot distinguish "accepted identity but waiting" from "rejected identity silently and waiting for deadline". iter-5 could test this by restoring plausible identifiers in #8 and seeing if the silence becomes faster or the same.

Implications for Stage S2 and Phase D

The iter-1 through iter-4 arc closes Q3 in the strongest empirical form available from a pure-replay approach:

  • iter-1: basic HTTP/2 stream-ownership rules matter (upfront replay rejected with PROTOCOL_ERROR).
  • iter-2: event-driven dispatch is necessary, but naïve trigger mapping causes semantic-layer aborts.
  • iter-3: frame ordering is not the root cause of the application layer abort.
  • iter-4: our own server-initiated reset was the direct cause of that abort, and removing it lets CDS reach a quiescent state.

Phase D implication: a Go-based backend that serves the spike-tunneld endpoint can clear the HTTP/2 + RemoteXPC handshake using the exact state-machine captured here. The next unknown — "what RemoteXPC service call comes after the handshake" — is a Phase D research target, not a Phase C blocker. Phase C's original question was "what protocol does CDS speak to tunneld?" Answered: plain h2c, RemoteXPC envelope, reachable quiescent state after handshake. Details of post-handshake service choreography are Phase D scope because they are tied to whatever flow we expose first (pair is a user-initiated action, so its own request-reply is the natural thing to observe on the backend).

Recommended close-out: stop at iter-4. Do not continue iter-5+ under replay. Move the investigation into Phase D backend, where we can respond to CDS's actual service requests instead of pre-recording them.

Artifacts (committed, redacted)

Under results/ alongside this file. Same redaction policy as iter-02/iter-03: text logs scrubbed for VM hostname, tunnel ULA, USB link-local IPv6. Binary files untouched (iphone_replay_bytes.py corpus already redacted at source).

  • results/session-01-recv.bin — 204 bytes.
  • results/session-01-send.bin — 14313 bytes (= 14326 − 13).
  • results/session-01.log — 4990 bytes. Ends mid-read (no === session end === line) because the listener was pkill'd before the 30-second read timeout.
  • results/iosmux-listener.log — 138 bytes.
  • results/tunneld-spike.log — redacted SPIKE tunneld output.

How to reproduce

On havoc (assumes iter-4 deployment still in place):

ssh havoc-root 'killall CoreDeviceService 2>/dev/null || true'
ssh havoc 'devicectl list devices 2>&1 | tail -15'
# leave for 10-30 seconds to observe CDS's silence
ssh havoc 'pkill -f iosmux-spike-listener.py'

Status of the iter-4 chain

  • Step A (drop #9): delivered in commit 1cd564e.
  • Step B (deployment to havoc): delivered.
  • Step C (trigger CDS + capture): delivered.
  • Step D (decode + findings): this document.

Iter-4 closes, and closes Q3 under the replay approach. Any further progress on the s2c direction goes through Phase D backend work, not through more iphone_replay_bytes.py iterations.