Skip to content

Q3 iter-1 findings — naive upfront replay rejected with PROTOCOL_ERROR

Status: verified — 2026-04-18

Live run on havoc. Real CoreDeviceService (PID 2044 at capture time) connected to our spike listener running in IOSMUX_SPIKE_REPLAY=1 mode. The session completed in a single round-trip that ended with a well-formed HTTP/2 GOAWAY frame carrying a human-readable diagnostic. Every byte below was produced by that run and decoded through standard HTTP/2 framing.

TL;DR

Our iter-1 listener replays the 10 iPhone server-side frames from Q2's pcap verbatim, upfront, before reading any client frames. CDS opens the connection, consumes our replay, and immediately responds with:

GOAWAY  last_stream_id=0  error_code=0x00000001 (PROTOCOL_ERROR)
        debug="request HEADERS: invalid stream_id"

Root cause: HTTP/2 forbids the server from opening a stream with a client-owned stream ID. Client-initiated stream IDs are odd (1, 3, 5, …); server-initiated are even (2, 4, 6, …). Our replay includes HEADERS stream=1 (replay frame #3) and HEADERS stream=3 (replay frame #6). Emitting them before CDS (the client) opens those streams looks to CDS like "the server is trying to initiate a stream with an odd ID" — illegal per RFC 7540 §5.1.1. CDS rejects on that grounds and aborts the whole connection.

The real iPhone's capture (Q2) is NOT violating the rule. The iPhone-side HEADERS frames were responses emitted after the client had already opened the corresponding streams. Our upfront replay stripped that timing out and broke the semantics.

This is a successful iter-1. We learned exactly how the naive replay strategy fails, got a human-readable diagnostic from CDS, and have a clean path to iter-2 (event-driven dispatch).

The session, byte by byte

Setup

  • Listener bound [::1]:34719, IOSMUX_SPIKE_REPLAY=1.
  • Tunneld (SPIKE mode) advertises tunnel-address=::1, tunnel-port=34719.
  • CDS restart via killall CoreDeviceService. devicectl list devices invoked to trigger the tunnel query.

What the listener sent (14326 bytes, 10 frames)

Exactly the replay bytes from iter-0's pcap extraction, via iphone_replay_bytes.py. In send order (with HTTP/2 frame-header overhead making each on-wire frame 9 bytes longer than its payload):

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

Total 14236 payload bytes + 90 header bytes = 14326 bytes on wire. All 10 frames committed to the socket before the listener entered its read loop.

What CDS sent back (180 bytes, 6 frames)

Reduced from the Q1 capture's 204 bytes / 8 frames. CDS aborted early:

# off type stream flags len note
preface 0x00 HTTP/2 preface 24 PRI * HTTP/2.0...
0 0x18 SETTINGS 0 0x00 12 client's SETTINGS (MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=0x100000)
1 0x2d WINDOW_UPDATE 0 0x00 4 window_increment=0x000f0001
2 0x3a HEADERS 1 0x04 0 END_HEADERS — opens stream 1 from CDS side
3 0x43 SETTINGS 0 0x01 0 ACK of our server-SETTINGS
4 0x4c DATA 1 0x00 44 XpcWrapper empty-dict, same as Q1 frame #4
5 0x81 GOAWAY 0 0x00 42 the abort frame (see below)

Missing, compared to Q1: - DATA(s1, 24) sync-flag wrapper (Q1 frame #6) - HEADERS(s3) opening REPLY_CHANNEL (Q1 frame #5) - DATA(s3, 24) INIT_HANDSHAKE wrapper (Q1 frame #7)

CDS stopped after its first DATA frame on stream 1. It never reached the REPLY_CHANNEL open sequence because our replay had already violated the stream-ownership rule by the time CDS got there.

The GOAWAY payload decoded

off=0x81 type=GOAWAY(0x07) stream=0 flags=0x00 len=42

payload (42 bytes):
  00 00 00 00                        last_stream_id = 0 (R|31-bit)
  00 00 00 01                        error_code    = 0x00000001 (PROTOCOL_ERROR)
  72 65 71 75 65 73 74 20            debug: "request "
  48 45 41 44 45 52 53 3a 20         debug: "HEADERS: "
  69 6e 76 61 6c 69 64 20            debug: "invalid "
  73 74 72 65 61 6d 5f 69 64         debug: "stream_id"

last_stream_id=0 is the key signal: CDS did NOT successfully process any client stream before rejecting. The PROTOCOL_ERROR code + the debug message pin the violation to our server-side HEADERS frame on a client-owned odd stream ID.

Why the upfront replay is wrong

HTTP/2 (RFC 7540 §5.1.1) encodes stream ownership in the parity of the stream ID:

  • Odd IDs (1, 3, 5, ...): client-initiated.
  • Even IDs (2, 4, 6, ...): server-initiated.

A server can send frames on a client-initiated stream, but only AFTER the client has opened that stream (by sending its first HEADERS frame on it). The server's own HEADERS on the same stream then act as "response headers" — start of the response portion of the request/response exchange.

In the real iPhone pcap (Q2), every iPhone-side HEADERS(s1) and HEADERS(s3) frame is interleaved with the client's frames such that the client's HEADERS(sX) always arrives before the iPhone's HEADERS(sX) on the same stream. Our iter-1 replay ordered all iPhone frames first, so our HEADERS(s1) arrived on the wire before CDS had sent its own HEADERS(s1). CDS's h2c parser correctly classifies that as "server attempting to initiate a stream with an odd ID" — illegal — and tears down the connection with a textual diagnostic that points the finger exactly where it belongs.

Implications for iter-2

The fix is event-driven replay. Instead of blasting all frames upfront, the listener dispatches server frames in reaction to client frames:

client SETTINGS(s0)         → send our frames #0, #1, #2
client HEADERS(s1)          → send our frames #3, #4, #5
client DATA(s1) with data   → send our frames #8 (big Handshake), #9 (RST_STREAM)
client HEADERS(s3)          → send our frame #6
client DATA(s3) INIT_HANDSHAKE → send our frame #7

Exact trigger-to-send mapping needs one more pass against Q2's pcap to confirm the timing the real iPhone used. The dispatch is small enough to implement inline in the listener's handle_frame method without a full state machine — a few if branches keyed on (frame_type, stream_id, seen-before-flag).

Open question for iter-2 readers: is it enough to send our frame #8 (big Handshake) in response to the client's first DATA on stream 1, or does CDS expect it sooner? The real iPhone sent frame #8 much later than the stream-1 open. iter-2 will check by observing whether CDS proceeds past its own DATA(s1) frame after our corrected replay finally lets it.

Artifacts (committed, redacted)

All artifacts live under results/ alongside this file. VM hostname, tunnel ULA, and MAC-derived link-local IPv6 have been redacted in the text logs. Binary files (*.bin) contain no personal data — the recv binary captures CDS's init + GOAWAY (no UDID there), and the send binary is exactly the already-redacted iphone_replay_bytes.IPHONE_REPLAY_FRAMES rendered onto the wire.

  • results/session-01-recv.bin — 180 bytes, CDS's full transmission (preface + 5 HTTP/2 frames ending in GOAWAY).
  • results/session-01-send.bin — 14326 bytes, what the listener sent (= iphone_replay_bytes.IPHONE_REPLAY_FRAMES rendered as on-wire HTTP/2 frames, redacted at source).
  • results/session-01.log — 4611 bytes, human-readable per-frame log. Starts with "REPLAY mode on — sending 10 iPhone frames", ends with session aborted: [Errno 54] Connection reset by peer.
  • results/listener.log — 156 bytes, listener lifecycle (start + one accept).
  • results/tunneld-spike.log — 454 bytes, SPIKE-mode tunneld output (tunnel ULA + link-local interface redacted).

How to reproduce

On havoc (assumes the iter-1 deployment from step B is still up):

ssh havoc-root 'killall CoreDeviceService 2>/dev/null || true'
ssh havoc 'devicectl list devices 2>&1 | tail -15'

Then pull capture files with scp and decode session-01-recv.bin with any HTTP/2 framer (9-byte header walk) plus the GOAWAY payload layout (RFC 7540 §6.8).

Status of the iter-1 chain

  • Step A (listener REPLAY mode): delivered in commit 3d63756.
  • Step B (deployment to havoc): delivered, chain armed.
  • Step C (trigger CDS + capture): delivered, one clean session on record.
  • Step D (decode + findings): this document.

Iter-1 closes. Iter-2 (event-driven dispatch) is the next implementation pass.