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
<?xmlstart +</plist>end. Every plist valid perplistlib.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:
- Wire format is trivial:
[BE-u32 length][XML plist]loop. Go stdlib has XML parsing,howett.net/plistalready ingo.mod, both handle this without new dependencies. - 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).
- Stays within SPIKE isolation (no tunneld routing change).
- 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 Handshakefixture'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]:50367at 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
Requeststring: RSDCheckin→ write{Request: RSDCheckin}+ unsolicited{Request: StartService}QueryType→ write{Request: QueryType, Type: "com.apple.mobile.lockdown"}GetValue(noKey) → 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)¶
- Control experiment: re-run iter-09 on SPIKE backend but
with
devicectl device info detailstrigger. Expected result: CDS SYN to our advertised lockdown port, fails withECONNREFUSED. Confirms the causal mechanism. Priority: low (the implementation proceeds without it). - Does devicectl use the
.trustedor.untrustedvariant? Our iter-10 capture only exercised pymobiledevice3'slockdown info, which went to.trusted. devicectl via CDS may prefer.untrustedfor unpaired state. A second reference capture specifically for a devicectl flow would settle this. - Do the other 39 classic-lockdown services share the
exact 3-verb protocol? Some may have service-specific
verbs (e.g.
diagnostics_relayservice at its own port might acceptDiagnosticsRequestinstead of / alongsideGetValue). D.6.6 should start with just.remote.trustedand add service-specific handlers on demand as they surface in the trigger log.
Artifacts¶
Under
iter-11-lockdown-decode/:
findings.md— this documentindex.md— one-page summary
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:
/home/op/venvs/iosmux-research/bin/python3 /tmp/iosmux-d11-extract-50367.py/home/op/venvs/iosmux-research/bin/python3 /tmp/iosmux-d11-decode-50367.py/home/op/venvs/iosmux-research/bin/python3 /tmp/iosmux-d11-plist-walk.py/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.