chahinebrini 01374c426e feat(supervise-magic): TechLockdown-clone v1 — supervise iPhones without erase
Implementiert eigenen MobileBackup2-Restore-Trick zur Supervision-Übernahme
von aktivierten iOS-Geräten ohne Factory-Reset. Foundation für DiGA-Phase-G
Lock-Layer-Stack (non-removable Apps, non-removable Profiles, OnDemand-VPN-
Toggle-Lock) auf Consumer-iPhones.

Verifiziert end-to-end auf:
- iPhone Air (iPhone18,4, iOS 26.5): TL→ReBreak re-supervise 
- Olfa iPhone 14 Pro (iPhone15,3, iOS 26.4.2): TL→ReBreak re-supervise 

Key empirische Findings:
1. Find-My-iPhone MUSS off sein (ErrorCode 211 sonst)
   → Stolen Device Protection (SDP) zwingt FMI an seit iOS 17.3+
2. SupervisorHostCertificates DARF NICHT in CloudConfigurationDetails sein
   für fresh-supervise auf activated unsupervised devices (sonst partial-apply)
3. MCInstall.SetCloudConfiguration firet 14002 auf allen activated devices
   → MobileBackup2-Restore-Trick ist der einzige Weg
4. TL's extracted-embed-bytes != TL's wire-output (Runtime-Mutation)
   → Verbatim-Kopieren reicht nicht

Reverse-Engineering basiert komplett auf:
- Apple's public protocol docs (devicemanagement, mobilebackup2 schemas)
- libimobiledevice (open-source reference impl)
- TL public-distributed binary (interop-RE, legal per US-DMCA-1201 + EU-2009/24)

Structure:
  cmd/supervise/         — main CLI (check, cloud-config, supervise, cert-info, unsupervise)
  cmd/dump-artifacts/    — diagnostic helper (no device needed)
  cmd/usbmux-proxy/      — MITM-proxy for TL-traffic-capture (debug)
  cmd/tl-patcher/        — patches TL's hard-coded usbmuxd path (debug)
  internal/dlmessage/    — DLMessage wire-protocol (4-byte BE length + plist)
  internal/mobilebackup2/— mobilebackup2-service impl (BaseVersionExchange,
                          Hello, Restore, ServeFiles + TL-extracted templates)
  internal/cloudconfig/  — CloudConfigurationDetails.plist builder (cert-less,
                          25 keys matching TL's runtime-output)
  internal/cert/         — auto-gen + persist supervisor-cert in ~/.rebreak-supervise/
  internal/mcinstall/    — MCInstall.GetCloudConfiguration für state-checks
  internal/device/       — go-ios DeviceEntry wrapper
  internal/afclock/      — AFC sync-lock auf /com.apple.itunes.lock_sync
  internal/notification_proxy/ — PostNotification (syncWillStart/etc)
  internal/preflight/    — FMI/Activation/OS-version pre-checks
  internal/supervise/    — End-to-end Flow-Orchestrierung (MobileBackup2 default,
                          MCInstall via REBREAK_FORCE_MCINSTALL=1)

Pending für volle Productization (Phase G):
- Fresh-supervise direkt-empirisch auf truly-unsupervised iPhone testen
  (heute Nacht nur durch Inferenz aus TL-Verhalten gestützt)
- Auto-MDM-enroll-Step nach Supervise (ConfigurationURL oder cfgutil-style)
- DiGA-Onboarding-Flow + Lyra-Coach für FMI/SDP-Disable
- Multi-Device-Validation (Modelle, iOS-Versionen)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:55:10 +02:00

262 lines
6.8 KiB
Go

// Command usbmux-proxy ist ein Man-in-the-Middle Unix-socket proxy zwischen
// einem Client (z.B. TechLockdown) und Apple's `/var/run/usbmuxd`-Daemon.
//
// Architektur:
//
// Client --[unix socket /tmp/usbmux-proxy]--> proxy --[unix socket /var/run/usbmuxd]--> usbmuxd
// |
// v
// /tmp/usbmux-capture-*.log
//
// Usage:
//
// # Terminal 1 — start proxy
// ./bin/rebreak-usbmux-proxy
//
// # Terminal 2 — run TL through proxy (kein sudo nötig!)
// USBMUXD_SOCKET_ADDRESS=unix:///tmp/usbmux-proxy \
// open ~/Downloads/TechLockdown-supervise-mac-arm64.app
//
// Log-Output: hex+ascii dump für jeden gesendeten/empfangenen byte.
// Plus: usbmux-frame-parsing (16-byte header + payload), plist-detection.
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"io"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"howett.net/plist"
)
const (
defaultProxyPath = "/tmp/usbmux-proxy"
defaultRealPath = "/var/run/usbmuxd"
)
var (
flagProxyPath = flag.String("proxy", defaultProxyPath, "unix-socket-pfad für proxy")
flagRealPath = flag.String("real", defaultRealPath, "unix-socket-pfad zum echten usbmuxd")
flagLogPath = flag.String("log", "", "log-output (default: /tmp/usbmux-capture-TIMESTAMP.log)")
flagQuiet = flag.Bool("q", false, "kein stdout output, nur file-log")
sessionCounter atomic.Uint64
)
func main() {
flag.Parse()
logPath := *flagLogPath
if logPath == "" {
logPath = fmt.Sprintf("/tmp/usbmux-capture-%s.log", time.Now().Format("20060102-150405"))
}
logFile, err := os.Create(logPath)
if err != nil {
fmt.Fprintln(os.Stderr, "log create:", err)
os.Exit(1)
}
defer logFile.Close()
logger := &mitm{
out: logFile,
quiet: *flagQuiet,
}
logger.printf("== usbmux-proxy starting — proxy=%s → real=%s, log=%s\n", *flagProxyPath, *flagRealPath, logPath)
// Cleanup any stale socket
os.Remove(*flagProxyPath)
listener, err := net.Listen("unix", *flagProxyPath)
if err != nil {
fmt.Fprintln(os.Stderr, "listen:", err)
os.Exit(1)
}
defer listener.Close()
defer os.Remove(*flagProxyPath)
logger.printf("== listening — point USBMUXD_SOCKET_ADDRESS=unix://%s\n", *flagProxyPath)
for {
clientConn, err := listener.Accept()
if err != nil {
logger.printf("accept error: %v\n", err)
continue
}
sessionID := sessionCounter.Add(1)
go handleSession(sessionID, clientConn, logger)
}
}
type mitm struct {
mu sync.Mutex
out *os.File
quiet bool
}
func (m *mitm) printf(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
s := fmt.Sprintf(format, args...)
m.out.WriteString(s)
if !m.quiet {
os.Stderr.WriteString(s)
}
}
func handleSession(sessionID uint64, client net.Conn, logger *mitm) {
defer client.Close()
logger.printf("\n=== SESSION %d START (client connected) ===\n", sessionID)
daemon, err := net.Dial("unix", *flagRealPath)
if err != nil {
logger.printf("[session %d] dial daemon: %v\n", sessionID, err)
return
}
defer daemon.Close()
var wg sync.WaitGroup
wg.Add(2)
// Client → Daemon
go func() {
defer wg.Done()
pipeWithLog(sessionID, "C→D", client, daemon, logger)
daemon.Close() // signal other side
}()
// Daemon → Client
go func() {
defer wg.Done()
pipeWithLog(sessionID, "D→C", daemon, client, logger)
client.Close()
}()
wg.Wait()
logger.printf("=== SESSION %d END ===\n", sessionID)
}
// pipeWithLog liest from src + writes to dst, logging each chunk.
func pipeWithLog(sessionID uint64, dir string, src, dst net.Conn, logger *mitm) {
buf := make([]byte, 64*1024)
for {
n, err := src.Read(buf)
if n > 0 {
chunk := buf[:n]
logger.printf("\n--- [session %d] %s | %d bytes | %s ---\n", sessionID, dir, n, time.Now().Format("15:04:05.000"))
dumpChunk(chunk, logger)
if _, werr := dst.Write(chunk); werr != nil {
logger.printf("write err: %v\n", werr)
return
}
}
if err != nil {
if err != io.EOF {
logger.printf("read err: %v\n", err)
}
return
}
}
}
// dumpChunk format-aware: tries to detect usbmux-frame, plist, etc.
// Falls back to hex+ascii.
func dumpChunk(data []byte, logger *mitm) {
// 1) Try usbmux-frame parsing (16-byte header + payload)
if len(data) >= 16 {
length := binary.LittleEndian.Uint32(data[0:4])
version := binary.LittleEndian.Uint32(data[4:8])
request := binary.LittleEndian.Uint32(data[8:12])
tag := binary.LittleEndian.Uint32(data[12:16])
if length > 16 && int(length) <= len(data) && version <= 2 && request <= 100 {
payload := data[16:length]
logger.printf(" [usbmux-frame] len=%d ver=%d req=%d tag=%d payload=%d bytes\n",
length, version, request, tag, len(payload))
tryParsePlist(payload, logger)
// remaining bytes
if int(length) < len(data) {
logger.printf(" [+%d bytes after frame]\n", len(data)-int(length))
dumpHex(data[length:], logger, 4)
}
return
}
}
// 2) Try mobilebackup2 / lockdown 4-byte length-prefix + plist
if len(data) >= 4 {
length := binary.BigEndian.Uint32(data[0:4])
if int(length)+4 <= len(data) && length > 0 && length < 1024*1024 {
payload := data[4 : 4+length]
logger.printf(" [length-prefixed] len=%d payload=%d bytes\n", length, len(payload))
tryParsePlist(payload, logger)
if int(length)+4 < len(data) {
logger.printf(" [+%d bytes after frame]\n", len(data)-int(length)-4)
dumpHex(data[int(length)+4:], logger, 4)
}
return
}
}
// 3) Raw hex dump
dumpHex(data, logger, 4)
}
// tryParsePlist — try XML or binary plist parse, log readable form.
func tryParsePlist(data []byte, logger *mitm) {
if bytes.HasPrefix(data, []byte("<?xml")) || bytes.HasPrefix(data, []byte("bplist00")) {
var parsed interface{}
if _, err := plist.Unmarshal(data, &parsed); err == nil {
// Re-encode as XML for readability
var buf bytes.Buffer
enc := plist.NewEncoderForFormat(&buf, plist.XMLFormat)
enc.Indent(" ")
if enc.Encode(parsed) == nil {
logger.printf(" [plist parsed]:\n%s\n", indent(buf.String(), " "))
return
}
}
}
dumpHex(data, logger, 4)
}
// dumpHex — print hex+ascii dump.
func dumpHex(data []byte, logger *mitm, indentSpaces int) {
const lineLen = 16
prefix := strings.Repeat(" ", indentSpaces)
for i := 0; i < len(data); i += lineLen {
end := i + lineLen
if end > len(data) {
end = len(data)
}
hex := ""
ascii := ""
for j := i; j < end; j++ {
hex += fmt.Sprintf("%02x ", data[j])
if data[j] >= 32 && data[j] < 127 {
ascii += string(data[j])
} else {
ascii += "."
}
}
hex += strings.Repeat(" ", lineLen-(end-i))
logger.printf("%s%04x: %s | %s\n", prefix, i, hex, ascii)
}
}
func indent(s, pad string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
if line != "" {
lines[i] = pad + line
}
}
return strings.Join(lines, "\n")
}