Skip to content

Phase D.6.5 findings — parallel service is classic lockdown-over-TCP; 40/62 advertised services are the same family

Status: verified — 2026-04-24

Pure local analysis on the iter-10 reference pcap (held at /home/op/backups/iosmux/pcaps/iosmux-d10-real.pcap, not committed). Extracted + decoded both directions of the iPhone:50367 flow, then decoded the iPhone's Handshake Services dict from the greeting session to identify which advertised service lives at that port. Result: the parallel service on iPhone:50367 is classic lockdown-over-TCP (plaintext XML plist, 4-byte big-endian length prefix), and it is advertised in our own Handshake Services dict under the key com.apple.mobile.lockdown.remote.trusted (UsesRemoteXPC=False, ServiceVersion=1). 40 of 62 advertised services share this classic-lockdown wire format — one handler implementation unlocks 40 endpoints.

The 50367 in this document is NOT a stable port — discovered runtime

Every reference to 50367 below describes the port the iPhone happened to advertise for com.apple.mobile.lockdown.remote.trusted in the iter-10 pcap session only. iOS 17+ allocates Services-dict ports per tunneld session: this was empirically established in session 13 (2026-04-28) when the sub-task 1 deploy initially used 50367 as a hardcoded constant and got immediate connect: connection refused against a live iPhone advertising the same service at port 54311. A second restore-script cycle then rotated the port again, to 54421. See docs/plans/d66-research-questions.md Q-D66-13 §Resolution log for the cross-session evidence.

Production code (commit b2da3f6) discovers the port at backend startup via the new internal/lockdown/rsd_discovery.go:FetchServices client and FindServicePort helper. Do not promote any number from this doc's tables back into a constant. Read the live Services dict at runtime; if discovery fails, exit cleanly per ADR-0006.

TL;DR

The gap between SPIKE backend and real iPhone is NOT a missing follow-up frame on the greeting stream (D.6.4a falsified that). It's a missing listener at the advertised lockdown port in SPIKE mode. When tooling on the host (pymobiledevice3 or CDS) wants to query device state / pair state / lockdown info, it reads the port from the Handshake Services dict and opens a parallel TCP connection to that port. In stock mode the iPhone answers. In SPIKE mode our Go backend answers the greeting but has nothing behind the lockdown port.

D.6.6 implementation: add a lockdown-over-TCP handler in the Go backend that listens on the port advertised under com.apple.mobile.lockdown.remote.trusted, handles 3 verbs (RSDCheckin, QueryType, GetValue), returns cached device info plist for GetValue. Estimated effort: 1 day.

Capture forensics

Pcap at /home/op/backups/iosmux/pcaps/iosmux-d10-real.pcap. Link-layer DLT_NULL (BSD loopback), IPv6 inner. scapy needed explicit IPv6 reparsing of Raw layer (raw[0] >> 4 == 6) — recorded for any future iter working with this pcap format.

Three TCP flows, two distinct services (iPhone side = ::1, client side = ::2 on ULA fd45:3648:8491::):

Flow a→b b→a Identity
iPhone:52889 ↔ client:50346 14,326 B / 15 pkts 204 B / 15 pkts RSD greeting session #1
iPhone:52889 ↔ client:50356 14,326 B / 9 pkts 204 B / 9 pkts RSD greeting session #2
iPhone:50367 ↔ client:50347 9,532 B / 13 pkts 924 B / 13 pkts the parallel service

The two greeting sessions are byte-identical to each other AND to iter-01 corpus — confirms no iOS version drift, no per-session variation, no iPhone-side freshness in the greeting output.

Protocol identification on iPhone:50367

Multi-lens decoding of the extracted 924 B / 9,532 B streams:

Lens A — hex + ASCII preview

Both directions begin with a 4-byte big-endian length prefix followed by ASCII <?xml version="1.0" encoding="UTF-8"?>... <plist version="1.0">.... Plaintext XML. No binary plist, no TLS handshake, no XpcWrapper magic.

Lens B — classic lockdown framing ([BE-u32 length][XML plist]*) — MATCH

  • Client → iPhone (924 B) parses cleanly as 3 sequential plist frames: 337 B + 288 B + 287 B = 924 B consumed 100%.
  • iPhone → client (9,532 B) parses cleanly as 4 sequential plist frames: 237 B + 239 B + 297 B + 8,743 B = 9,532 B consumed 100%.
  • All 7 plists have <?xml start + </plist> end. Every plist valid per plistlib.loads.

Lens C — XpcWrapper

Magic 0x29B00B92 absent from the entire 50367 stream. XpcWrapper.parse fails with ConstError: expected 699403154 but parsed 1359020032. This service is not RemoteXPC.

Lens D — HTTP/2 preface

Not present. Confirmed.

Lens E — TLS handshake first byte

First byte of c2i is 0x00 (high byte of the length prefix), not 0x16 (TLS ClientHello). Plaintext.

Verdict

Classic lockdown-over-TCP, plaintext, 4-byte BE length prefix + XML plist. Same wire format pymobiledevice3 has implemented against classic lockdown since pre-iOS 17 era — implemented in pymobiledevice3/lockdown.py.

The 50367 dialog (first three request/response pairs)

Client → iPhone (three requests, all shape {Label: "pymobiledevice3", Request: ...}):

# length Request Additional keys
0 337 B RSDCheckin ProtocolVersion: "2"
1 288 B QueryType
2 287 B GetValue no Key/Domain → full dump

iPhone → client (four responses, the second is an unsolicited server push):

# length Keys Content
0 237 B {Request: RSDCheckin} bare acknowledgement
1 239 B {Request: StartService} unsolicited push — server announcing "I started your service on this connection"
2 297 B {Request: QueryType, Type: "com.apple.mobile.lockdown"} service self-identifies as classic lockdown
3 8,743 B {Request: GetValue, Value: <dict of 88 keys>} full device-info dictionary

The 88-key GetValue dict is exactly what classic lockdown's GetValue(nil, nil) returns — ActivationState, BasebandVersion, BuildVersion, ChipID, CPUArchitecture, PasswordProtected, HostAttached, ProductType, ProductVersion, TrustedHostAttached, etc. Contains real device identifiers (UDID, SerialNumber, UniqueChipID, DieID, Bluetooth MAC) — not reproduced here per privacy policy; stays in the held-local pcap only.

Cross-reference: GetValue keys are NOT in greeting Handshake Properties

The trust-adjacent fields above (HostAttached, TrustedHostAttached, PairRecords, ActivationState, PasswordProtected) live only in this 50367 GetValue response. They are not keys of the 46-entry greeting Handshake Properties dict — see the verbatim 46-key list in s1a-properties-audit.md §"What MobileDevice expects". This is the empirical anchor for iter-12 §"H-HS: Handshake Properties dict" being a non-starter (no trust signal in the wire we control) and for iter-13 falsifying it via byte-diff. iter-16 then localised the actual gate to remotepairingd's in-memory peer registry, downstream of any wire we serve.

Important client identity: Label: "pymobiledevice3". The trigger that opened this 50367 flow was pymobiledevice3 lockdown info (part of iter-10's trigger sequence), not devicectl. Whether devicectl ALSO opens this port when it can reach the service is an unanswered question for D.6.5+ (see Open Questions).

Services dict decoding (the other prize)

Extracted the iPhone's Handshake Services dict from the greeting flow's 14,124-byte DATA frame (XpcWrapper msgid=2, payload dict, Services sub-dict). 62 services advertised, port range 50309-50370.

Breakdown by RemoteXPC flag:

UsesRemoteXPC Count Protocol Our handling
True 22 RemoteXPC (HTTP/2 + XpcWrapper) Requires H/2 listener + XPC decoder per service
False 40 classic lockdown-over-TCP (what 50367 uses) One handler unlocks all 40

Excerpt showing the family boundary (sorted by port):

Port UsesRemoteXPC Ver ServiceKey
50333 False 1 com.apple.mobile.lockdown.remote.untrusted
50357 True 1 com.apple.accessibility.axAuditDaemon.remoteAXService
50361 True 1 com.apple.dt.remoteFetchSymbols
50362 False 1 com.apple.internal.devicecompute.CoreDeviceProxy
50367 False 1 com.apple.mobile.lockdown.remote.trusted ← the iter-10 match
50368 True 1 com.apple.mobile.storage_mounter_proxy.bridge

Port 50367 is exactly the .remote.trusted lockdown variant, sibling of .remote.untrusted at 50333. Both UsesRemoteXPC=False, both use the classic-lockdown wire format we just decoded. The .trusted vs .untrusted distinction likely relates to host trust state — an already-paired host connects to .trusted, an unknown host connects to .untrusted first for pairing.

Why iter-09 (SPIKE wide-filter pcap) saw zero back-connects

Apparent contradiction: iter-09 with devicectl manage pair on SPIKE backend + wide filter saw exactly one SYN (to our [::1]:34719), zero attempts to any advertised port. Yet our handshake advertises 40 classic-lockdown services, one of which real clients DO connect to (as iter-10 proved).

Resolution: trigger-dependent service usage. Different devicectl sub-commands consult different subsets of the Services dict. devicectl manage pair on an already-paired device (our state) may short-circuit without opening lockdown. devicectl device info details (used in iter-10) invokes the lockdown service for hardware queries, which is why iter-10 captured both an RSD session AND a 50367 flow.

Open question for D.6.5 follow-up (non-blocking): re-run iter-09-style wide-pcap on SPIKE backend BUT with devicectl device info details as the trigger. Expected: CDS will open a SYN to our advertised lockdown port, which fails ECONNREFUSED because SPIKE has no listener there. That's the mechanism that produces "connection interrupted" Mercury errors we've been seeing since iter-6.

(This re-run is a control experiment, not a blocker for D.6.6. Even without it, the D.6.6 implementation plan is sound.)

D.6.6 implementation plan (Option A)

Recommendation: add lockdown-over-TCP emulation to the Go backend, not a pass-through proxy. Reasoning:

  1. Wire format is trivial: [BE-u32 length][XML plist] loop. Go stdlib has XML parsing, howett.net/plist already in go.mod, both handle this without new dependencies.
  2. Only 3 request verbs observed in reference. Responses are either bare acks or fixed strings or a cached device-info dict we can source from elsewhere (tunneld already queries lockdown during its own init — we can snapshot there).
  3. Stays within SPIKE isolation (no tunneld routing change).
  4. Reusable for 39 other classic-lockdown services — the backend's lockdown handler is factorable.

Implementation sketch (for a work-plan doc, not this findings file):

  • Choose a port number for the SPIKE backend to bind the lockdown service on. Options:
  • Use the same port the fixture advertises (50367). Simplest. Requires #8 big Handshake fixture's Services dict to match (it does — it's byte-exact iter-01 corpus).
  • Generate a fresh port per session, patch the Services dict at emit time (same mechanism as D.6.1 UUID patching). More flexible, more code.
  • Option 1 is sufficient for D.6.6 MVP.
  • Listener binds [::1]:50367 at backend startup (only if not already bound — the fixture's service ports come from the real iPhone's port allocation, which we can't guarantee is free).
  • On accept: read u32 BE length, read that many bytes, plistlib loads, dispatch on Request string:
  • RSDCheckin → write {Request: RSDCheckin} + unsolicited {Request: StartService}
  • QueryType → write {Request: QueryType, Type: "com.apple.mobile.lockdown"}
  • GetValue (no Key) → write {Request: GetValue, Value: <88-key cached dict>}
  • unknown verb → log + close connection
  • Response plists all emit 4-byte BE length prefix + XML body.

Where does the 88-key device-info dict come from? Two sources:

a) Snapshot from real iPhone via tunneld at startup — our backend can open its own lockdown client to the real iPhone once at boot (using the stock tunneld route or a temporary SPIKE bypass), cache the dict, then serve it. Ensures all 88 keys present and accurate. b) Synthesize from known constants + tunneld-advertised values — fill UDID from tunneld response, fill most other fields with sensible defaults. Faster to implement but risks CDS rejecting synthesized fields.

(a) is more correct. (b) is faster. Recommend (a) for D.6.6 first cut, with a fallback comment in code for (b) if tunneld client proves fiddly.

Open questions (D.6.5 follow-up / D.6.6 prereqs)

  1. Control experiment: re-run iter-09 on SPIKE backend but with devicectl device info details trigger. Expected result: CDS SYN to our advertised lockdown port, fails with ECONNREFUSED. Confirms the causal mechanism. Priority: low (the implementation proceeds without it).
  2. Does devicectl use the .trusted or .untrusted variant? Our iter-10 capture only exercised pymobiledevice3's lockdown info, which went to .trusted. devicectl via CDS may prefer .untrusted for unpaired state. A second reference capture specifically for a devicectl flow would settle this.
  3. Do the other 39 classic-lockdown services share the exact 3-verb protocol? Some may have service-specific verbs (e.g. diagnostics_relay service at its own port might accept DiagnosticsRequest instead of / alongside GetValue). D.6.6 should start with just .remote.trusted and add service-specific handlers on demand as they surface in the trigger log.

Artifacts

Under iter-11-lockdown-decode/:

Raw decode artifacts (local only, not committed — they contain real device fields from the GetValue dict):

  • /tmp/iosmux-d11-50367-c2i.bin — 924 B raw client→iPhone bytes
  • /tmp/iosmux-d11-50367-i2c.bin — 9,532 B raw iPhone→client bytes
  • Decoder scripts /tmp/iosmux-d11-*.py — reproducible from this document's "how to reproduce" section

How to reproduce (script-based, no pipes/heredocs)

All analysis scripts at /tmp/iosmux-d11-*.py. Execution pattern:

  1. /home/op/venvs/iosmux-research/bin/python3 /tmp/iosmux-d11-extract-50367.py
  2. /home/op/venvs/iosmux-research/bin/python3 /tmp/iosmux-d11-decode-50367.py
  3. /home/op/venvs/iosmux-research/bin/python3 /tmp/iosmux-d11-plist-walk.py
  4. /home/op/venvs/iosmux-research/bin/python3 /tmp/iosmux-d11-decode-handshake.py

Each script prints its output directly. All read from /home/op/backups/iosmux/pcaps/iosmux-d10-real.pcap.

Status of D.6.5

  • Step A (extract + multi-lens decode of 50367 flow): delivered
  • Step B (decode Services dict from Handshake): delivered
  • Step C (match 50367 port to an advertised service key): delivered — com.apple.mobile.lockdown.remote.trusted
  • Step D (document + recommend implementation path): this file

iter-11 closes the protocol-identification question. D.6.6 (implement lockdown-over-TCP handler in the Go backend) is the next implementation pass, with a well-defined wire spec and a 3-verb API.