Phase D.6.6-research-b iter-14 findings — UDID patch alone does not unblock CDS back-connect to :50367 (H-HS narrowed form, slot #1, FALSIFIED)¶
Status: verified — 2026-04-24
Added env-gated UDID patching to the Go backend Handshake
emitter (IOSMUX_HANDSHAKE_UDID=<25-char-UDID> at fixture
offset 0x34BC, mirroring D.6.1's UUID patch mechanism at
0x371C). Deployed to havoc, ran the iter-12 wide-pcap trigger
(devicectl device info details) with the UDID matching
tunneld's advertisement. Result across four independent
runs: Handshake carries the patched UDID verbatim (backend
verbose log confirms), devicectl reports pairingState:
unpaired + tunnelState: unavailable + Mercury-class
warning (identical to iter-12), and zero SYN packets to
any advertised service port appear in the 30-second
post-trigger observation window. The only TCP SYNs in the
wide pcap are the client's own probes and real connections
to our [::1]:34719 listener (2–3 per run). The H-HS
narrowed form slot #1 (UDID) is falsified at the
UDID-alone-sufficient level.
TL;DR¶
iter-13 ranked UniqueDeviceID as the #1 candidate trust-gate
field by a wide margin. iter-14 tests that prediction directly
by patching the fixture at emit time with the real tunneld-
advertised UDID. The patch lands (verified in backend verbose
log every run) but CDS still does not back-connect to any
advertised service port. H-HS narrowed form with UDID-alone is
refuted.
A secondary empirical signal is worth flagging: CDS closes the session 3 seconds after receiving the patched Handshake, versus ~30 seconds with iter-12's zero-UDID Handshake. The faster disposition is evidence the UDID is being consumed and evaluated — just not in a way that progresses us past the trust gate. Interpretation: there is at least one additional check (likely host-side pair-record lookup in CoreDevice's paired-devices DB) that our wire-state patching cannot reach.
Method¶
Four runs against the same iPhone, same tunneld session (PID 2337 throughout), with the backend re-launched per run via a scripted nohup pattern.
Code change¶
Extended internal/backend/handshake.go with the same shape
as the D.6.1 UUID patch:
handshakeUDIDEnv = "IOSMUX_HANDSHAKE_UDID"handshakeUDIDOffset = 0x34BC(value start of the fixture'sUniqueDeviceIDentry; derived by byte-scanning the fixture for the adjacent '0'-run next to the "UniqueDeviceID" ASCII key — see/tmp/iosmux-d14-inspect-strings.py)handshakeUDIDLen = 25handshakeUDIDFill = 0x30('0' ASCII; surprising finding — iter-13's decoder reported "all-zero ASCII", which we initially read as NUL-filled, but the fixture actually holds 25 × 0x30 printable-'0' bytes. Scan confirmed only three NUL-byte zero-runs of length ≥ 8 in frame #8: the ECID slot @ 0x34EA, the BootSessionUUID slot @ 0x3594, and the top-level UUID slot @ 0x371C. UDID/Serial/MAC all use '0' padding, keeping the string values valid UTF-8.)- Validation: exactly 25 bytes, every byte in the printable
range
[0x20, 0x7E]; on violation, log a session-tagged warning and leave the region untouched (same failure discipline as the UUID patch). - Independence: the UUID and UDID patches are applied in a
single pass over a freshly copied buffer; a failing patch
on one slot does not block the other. A unit test pins this
(
TestHandshakePatchIndependence).
Full tests: TestHandshakePatchUDIDFixed,
TestHandshakePatchUDIDInvalidLength,
TestHandshakePatchUDIDInvalidBytes,
TestHandshakePatchUUIDAndUDIDBothApply,
TestHandshakePatchIndependence — all green with -race.
Apparatus¶
- iPhone SE (3rd gen), iOS 26.4.x, USB-tethered to havoc
- Tunneld (PID 2337) serving HTTP API on
127.0.0.1:49151, advertisingtunnel-port: 34719at::1 - Hardware UDID (what tunneld returns + what we patch into
Handshake):
00008110-XXXXXXXXXXXXXXXX(25-char iOS 17+ dashed form; real value present in held-local runtime logs, never committed) - CoreDevice identifier for the device (what
devicectlwants as--devicearg, distinct from the hardware UDID — see Discovery below):<CoreDevice-UUID>(36-char canonical UUID) - SPIKE backend built from the new code:
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build→ binary athavoc:/tmp/iosmux-backend-d14, 5,817,904 B. Deployed via scp.
Trigger + observation¶
- Backend env:
IOSMUX_BACKEND_VERBOSE=1,IOSMUX_HANDSHAKE_UUID=random(kept on from D.6.1),IOSMUX_HANDSHAKE_UDID=<tunneld-UDID>(the iter-14 new patch). - Pcap:
tcpdump -i lo0 -U -n -w … 'tcp and not port 49151'(same wide filter as iter-09 / iter-12). - Trigger:
xcrun devicectl device info details --device <CoreDevice-UUID> --timeout 10, thensleep 30to give CDS a wide window for async back-connects. 30 s was chosen to exceed iter-12's observed ~30 s local-close timeout. - Teardown: SIGTERM backend, SIGINT tcpdump, scp artifacts back to Linux host for analysis.
Scripts (held local, not committed — they contain live UDID values in shell history):
/tmp/iosmux-d14-find-offsets.py (fixture zero-run scan)
/tmp/iosmux-d14-inspect-strings.py (fixture bytes per key)
/tmp/iosmux-d14-start-pcap.sh (havoc-root)
/tmp/iosmux-d14-start-backend.sh (havoc)
/tmp/iosmux-d14-trigger.sh (havoc)
/tmp/iosmux-d14-teardown.sh (havoc)
/tmp/iosmux-d14-teardown-root.sh (havoc-root)
Discovery: tunneld UDID ≠ devicectl CoreDevice UUID¶
The first run accidentally passed the tunneld-advertised UDID
as devicectl --device arg, expecting them to be the same
identifier. devicectl rejected it with
CoreDeviceError Code=1000 "The specified device was not
found". Running xcrun devicectl list devices clarified:
Name Hostname Identifier State Model
--------------- -------- ------------------------------------ ------------------ ----
iPhone (iosmux) <CoreDevice-UUID> connected (no DDI) iPhone SE (3rd gen)
The Identifier column is a 36-char canonical UUID
generated per-host by CoreDevice when it first registers the
device; it is NOT the hardware UDID. The hardware UDID (what
tunneld keys by and what lives in the Handshake Properties
UniqueDeviceID field) is the 25-char iOS 17+ form
00008110-XXXXXXXXXXXXXXXX.
iter-12's findings used the placeholder <devicectl-uuid>
without spelling out which of these two identifiers it
referred to; in practice it meant the CoreDevice UUID.
For future iterations this distinction matters: the hardware
UDID goes into the wire Handshake; the CoreDevice UUID goes
into devicectl --device. They are cross-referenced by
CoreDevice internally.
Results¶
Run-by-run summary¶
| Run | devicectl arg | devicectl rc | session=1 | session=2 | SYN to :50367 / any advertised port | Close timing |
|---|---|---|---|---|---|---|
| 1 | tunneld UDID (wrong) | 1 (device not found) | preface-EOF (probe) | full HS | 0 | local close at T+12 s |
| 2 | CoreDevice UUID | 0 (Mercury warning) | preface-EOF (probe) | — | 0 | no session=2 in the window |
| 3 | CoreDevice UUID | 0 (Mercury warning) | preface-EOF (probe) | full HS | 0 | peer closed (EOF) at T+3 s |
| 4 (wide window) | CoreDevice UUID | 0 (Mercury warning) | preface-EOF (probe) | full HS | 0 | peer closed (EOF) at T+3 s, no further traffic in the following 27 s |
Run #1 is a methodology artefact (wrong identifier); the handshake completed but was driven by a different caller (some CoreDevice background daemon, not devicectl). The primary result table is runs #2–#4 with the correct trigger argument.
devicectl output (runs #2–#4, abridged)¶
Gathering device information...
WARNING: Unable to retrieve complete information for this device.
The best available information will be returned.
Error: An error occurred while communicating with a
remote process.
Current device information:
? identifier: <CoreDevice-UUID>
? hardwareProperties:
? hardwareModel: D49AP
? marketingName: iPhone SE (3rd generation)
? productType: iPhone14,6
? deviceProperties:
? ddiServicesAvailable: false
? name: iPhone (iosmux)
? connectionProperties:
? pairingState: unpaired
? tunnelState: unavailable
Identical in kind to iter-12's output — pairingState:
unpaired + tunnelState: unavailable + Mercury-class
warning. The patched UDID does not move devicectl's reported
pair-state off unpaired.
Backend verbose log (run #4, the full-handshake run)¶
Each Handshake emission in runs #1/#3/#4 logs the applied patches before the 14124-byte frame goes on the wire:
session=N verbose send UUID (patched) => <fresh v4 UUID>
session=N verbose send UDID (patched) => 00008110-XXXXXXXXXXXXXXXX
session=N verbose send DATA(s1, 14124 B): flags=0x101 msgid=2
session=N verbose send MessageType => string "Handshake"
session=N verbose send MessagingProtocolVersion => uint64 7
session=N verbose send Services => <dict 62 entries>
session=N verbose send Properties => <dict 46 entries>
session=N verbose send UUID => <uuid …>
Both patches land every run. The fixture's adjacent bytes
outside the patch regions are preserved (unit-tested; also
checked by byte-diff against iter01BigHandshake14124 in a
spot-check script during development).
Pcap: SYN enumeration (run #4, 30 s window)¶
T+0.000 ::1.52497 > ::1.34719 Flags [S] (probe)
T+0.564 ::1.52500 > ::1.34719 Flags [S] (session=1 = preface-EOF)
T+0.564 ::1.34719 > ::1.52500 Flags [S.]
T+1.531 ::1.52502 > ::1.34719 Flags [S] (session=2 = full HS)
T+1.531 ::1.34719 > ::1.52502 Flags [S.]
Five TCP SYN / SYN-ACK records total. Two are client-initiated
connections to our :34719 listener; the SYN-ACK pair are
our replies. Client destination ports observed in SYNs:
34719 only.
Zero SYN to any of the 62 advertised services, including
:50367 (.remote.trusted) and :50333
(.remote.untrusted). The 30 s post-trigger window is
quiescent after T+1.531 except for the orderly close of
session=2 at T+~3 s.
What changed vs iter-12 (same control, one var: UDID)¶
| Aspect | iter-12 (UDID = 25 × '0') | iter-14 (UDID = real hardware UDID) |
|---|---|---|
| Code | D.6.1 backend, UUID patching only | + UDID patching |
| Handshake bytes reaching CDS | 14124 B with '0'-padded UDID |
14124 B with real UDID |
| devicectl reported state | unpaired / unavailable / Mercury |
unpaired / unavailable / Mercury |
| SYN to :50367 | 0 | 0 |
| Session close timing after Handshake | ~30 s (CDS-side timeout) | ~3 s (CDS-side disposition, much faster) |
| Close direction | backend's local close |
peer closed (EOF) |
The close-timing delta is the one real wire-level difference between the two treatments: patched Handshake produces a disposition roughly an order of magnitude faster. This is evidence our patched UDID is being read by CDS but interpreted as actionable for faster rejection, not as a gate passed.
What this means for H-HS¶
H-HS narrowed form, slot #1 (UDID), FALSIFIED at the UDID-alone-sufficient level. The patch lands, the bytes reach CDS, CDS appears to consume them (close speed-up), but the back-connect does not follow.
Three ways this failure can be accommodated without abandoning H-HS entirely:
-
H-HS requires a multi-field match: CDS may cross-check UDID against SerialNumber and/or UniqueChipID (ECID), and any one of them being wrong (still
'0'-padded in our fixture) causes the gate to fail. Testable by patching Serial / ECID alongside UDID — slots #2 and #3 of iter-13's shortlist. -
H-HS requires coherent values against tunneld advertisement metadata, not just a non-zero value: CDS may pull UDID from tunneld, pull UDID from Handshake, and check that the bytes agree exactly. We did match tunneld's advertised value — so this interpretation is partially falsified by iter-14. But other metadata (advertised interface
fe80::d446:…%en0, tunnel-address::1) also reach CDS via tunneld; a mismatch there could still be the proximate cause, unrelated to Handshake content. -
The gate is not in the Handshake at all — host-side state (CoreDevice's paired-devices DB, the lockdown pair-record escrow) is consulted and returns "unpaired", which short-circuits the back-connect regardless of Handshake content. This explanation is consistent with devicectl's very explicit
pairingState: unpairedline.
Option 3 is the most austere explanation — it predicts that no amount of Handshake patching will unlock the back-connect, and that the real missing piece is either (a) actually pairing the device to the host, or (b) planting a fake pair-record in CoreDevice's DB to satisfy the local check.
Proposed next step¶
The cheapest follow-up that disambiguates between options 1 and 3 is a combined patch (UDID + Serial + ECID all patched to real tunneld-derived values) in a single backend session. If the combined patch still produces 0 SYNs to any advertised port, option 3 is strongly supported and research-b for H-HS should be declared dead. If the combined patch produces a SYN to :50367, H-HS multi-field becomes the working model and we iterate to the minimal satisfying set.
For the combined patch we need the real iPhone's SerialNumber and ECID. Both are available from:
xcrun devicectl list devices --json-output -(reports SerialNumber in the JSON tree)/var/db/lockdown/plists, if accessible (probably root required; may not be necessary)ioreg -p IOUSB -l(ECID is exposed asUSB Serial Numberon the USB device node; SerialNumber likewise — this is what ioreg-based tools use)
The patch plumbing is straightforward — add
IOSMUX_HANDSHAKE_SERIAL and IOSMUX_HANDSHAKE_ECID env
gates at offsets 0x3364 (10 B ASCII) and 0x34EA (8 B LE
uint64), with the same validation discipline as UDID and UUID.
If the combined patch fails: pivot to the pair-state angle. The question "can we plant a CoreDevice pair-record to satisfy the local check" is a separate non-trivial line of work; it may need its own iter-15+.
Anomalies worth flagging¶
- session=2 completion is inconsistent across runs: runs 1, 3, and 4 saw session=2 complete a full handshake; run 2 saw only session=1 preface-EOF within the observation window. No clear deterministic cause. May be related to devicectl's internal retry / probe scheduler. Not the primary signal — the primary signal (zero back-connect SYNs) is invariant across all runs.
- Faster close (3 s) with UDID patch: as noted above, this is a positive signal that UDID is consumed. Worth remembering for research-c and onward — any future patch that removes this speed-up and restores the 30-second timeout would be a regression and should be investigated.
Status¶
- Step A (implement UDID patching): delivered (code +
tests, all green with
-race) - Step B (run H-HS slot-1 experiment): delivered (4 runs, consistent zero back-connect)
- Step C (analyse + propose next): delivered (option 1 combined patch, option 3 pair-state angle)
iter-14 falsifies the UDID-alone form of H-HS. The next move is either the combined UDID + Serial + ECID patch (iter-15) or a pivot to the host-side pair-state angle.
Reproduce¶
# Build
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build \
-o /tmp/iosmux-backend-darwin ./cmd/iosmux-backend/
# Deploy
scp /tmp/iosmux-backend-darwin havoc:/tmp/iosmux-backend-d14
# Apparatus check
ssh havoc "curl -sm 2 http://127.0.0.1:49151/"
# expect: {"<hardware-UDID>":[{"tunnel-address":"::1","tunnel-port":34719,...}]}
ssh havoc "xcrun devicectl list devices"
# expect: one row with Identifier=<CoreDevice-UUID>
# Run (copy/paste the five scripts under /tmp/iosmux-d14-*.sh in
# this iter's script list; run them in the order: start-pcap,
# start-backend <hardware-UDID>, trigger <CoreDevice-UUID>,
# teardown, teardown-root)
# Analyse
tcpdump -r /tmp/iosmux-d14.pcap -nn | grep -E 'Flags \[S'
# expect: only SYNs to :34719, zero to any other dst port