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 devicesinvoked 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_FRAMESrendered 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 withsession 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.