From 9963a7c25f815b396e692f9faae018a7ce04583d Mon Sep 17 00:00:00 2001 From: SimeonOnSecurity <4913771+simeononsecurity@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:53:11 -0500 Subject: [PATCH 1/7] feat: add USB cable deployer (deploy_usb.py) + cert/firewall fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy_usb.py (new): - One-command installer via USB cable — no WiFi required - Mirrors Rayhunter orbic-usb method: USB vendor ctrl request to switch device from RNDIS (0xf626) to ADB mode (0xf601) - AT+SYSCMD path (root via atfwd_daemon) with ADB shell fallback - Pushes firmware, boot script, and TLS certs to device - Configures iptables: ACCEPT 8443 (DagShell) + 8080, MASQUERADE NAT - Boot persistence via /data/usb/boot_hsusb_composition hook - Persistent daemon launch: uses init-context nc shell (port 24) to escape Android ADB cgroup — survives ADB disconnect - Post-deploy verification: process/port/TLS checks, no WiFi needed - --verify-only flag for checking existing installs dagshell_boot.sh: - Add iptables ACCEPT rule for port 8080 alongside 8443 orbic_fw_c/gen_pki.py: - Fix leaf cert validity: 365 days → 3650 days (10 years) - Fix deprecated datetime.utcnow() → datetime.now(timezone.utc) docs/deploy_usb_feature.md (new): - Full feature description, rationale, and quick-start guide --- dagshell_boot.sh | 5 +- deploy_usb.py | 920 +++++++++++++++++++++++++++++++++++++ docs/deploy_usb_feature.md | 121 +++++ orbic_fw_c/gen_pki.py | 8 +- 4 files changed, 1048 insertions(+), 6 deletions(-) create mode 100644 deploy_usb.py create mode 100644 docs/deploy_usb_feature.md diff --git a/dagshell_boot.sh b/dagshell_boot.sh index 4eb14d97..060ae11d 100644 --- a/dagshell_boot.sh +++ b/dagshell_boot.sh @@ -10,9 +10,10 @@ echo "=== BOOT $(date) ===" > $BOOTLOG busybox nc -ll -p 24 -e /bin/sh & echo "[OK] Shell on port 24" >> $BOOTLOG -# 2. Open HTTPS port (Port 8443) +# 2. Open HTTP and HTTPS ports (8080 and 8443) iptables -I INPUT -p tcp --dport 8443 -j ACCEPT -echo "[OK] Port 8443 open" >> $BOOTLOG +iptables -I INPUT -p tcp --dport 8080 -j ACCEPT +echo "[OK] Ports 8080 and 8443 open" >> $BOOTLOG # 3. Configure DNS forwarding for dnsmasq # CRITICAL: dnsmasq uses --dhcp-option-force=6,192.168.1.1 which makes diff --git a/deploy_usb.py b/deploy_usb.py new file mode 100644 index 00000000..809136e0 --- /dev/null +++ b/deploy_usb.py @@ -0,0 +1,920 @@ +#!/usr/bin/env python3 +""" +DagShell USB Deployer +===================== +Deploys DagShell firmware to the Orbic RCL400 via USB cable. +Mirrors Rayhunter's 'orbic-usb' installation method. + +How it works (matching Rayhunter installer/src/orbic.rs): + 1. Detects Orbic device on USB in RNDIS mode (PID 0xf626) + 2. Sends a USB vendor control request to switch it into ADB/debug mode (PID 0xf601) + 3. Device reboots and re-enumerates with ADB interface exposed + 4. Pushes firmware files to /tmp/ via 'adb push' (ADB user can write /tmp) + 5. Uses AT+SYSCMD via USB serial (interface 1) to move files to /data/ and chmod as root + 6. Sets up boot persistence via USB composition hook (same hook as deploy_base64.py) + 7. Opens firewall and starts orbic_app + +USB IDs (from Rayhunter installer/src/orbic.rs): + Vendor: 0x05c6 (Qualcomm) + RNDIS: 0xf626 (normal hotspot mode) + RNDIS2: 0xf622 (RNDIS + serial exposed) + ADB: 0xf601 (debug/command mode - target) + +AT+SYSCMD Protocol (USB serial, interface 1): + OUT endpoint: 0x02 + IN endpoint: 0x82 + Frame: \\r\\nAT+SYSCMD=\\r\\n + Read: echo (discard), then response + OK if: response contains \\r\\nOK\\r\\n + +Requirements: + pip install pyusb + adb (Android Debug Bridge): + macOS: brew install android-platform-tools + Linux: sudo apt install adb + Windows: https://developer.android.com/tools/releases/platform-tools +""" + +import subprocess +import sys +import time +from pathlib import Path + +# Optional pyusb for USB mode switching and AT+SYSCMD +try: + import usb.core + import usb.util + HAS_PYUSB = True +except ImportError: + HAS_PYUSB = False + + +# ============================================================================= +# Orbic USB constants (from Rayhunter installer/src/orbic.rs) +# ============================================================================= + +VENDOR_ID = 0x05c6 # Qualcomm +PRODUCT_ID_RNDIS = 0xf626 # Normal RNDIS / hotspot mode +PRODUCT_ID_RNDIS2 = 0xf622 # RNDIS + serial exposed +PRODUCT_ID_ADB = 0xf601 # ADB / debug / command mode (target) + +# USB serial (CDC ACM) interface for AT+SYSCMD commands +# Interface 1, bulk endpoints 0x02 (OUT) and 0x82 (IN) +SERIAL_INTERFACE = 1 +SERIAL_OUT_EP = 0x02 +SERIAL_IN_EP = 0x82 + + +# ============================================================================= +# File paths +# ============================================================================= + +SCRIPT_DIR = Path(__file__).parent.absolute() +FIRMWARE_DIR = SCRIPT_DIR / "orbic_fw_c" +FIRMWARE_FILE = "orbic_app" +BOOT_SCRIPT_FILE = "dagshell_boot.sh" + +FIRMWARE_PATH = FIRMWARE_DIR / FIRMWARE_FILE +BOOT_SCRIPT_PATH = SCRIPT_DIR / BOOT_SCRIPT_FILE + +# Remote locations on device +REMOTE_TMP_APP = "/tmp/orbic_app" +REMOTE_FILE = "/data/orbic_app" +REMOTE_TMP_BOOT = "/tmp/dagshell_boot.sh" +REMOTE_BOOT = "/data/dagshell_boot.sh" + +# USB persistence hook (same method used by deploy_base64.py) +USB_WRAPPER_PATH = "/data/usb/boot_hsusb_composition" +USB_ORIGINAL = "/sbin/usb/compositions/PRJ_SLT779_9025" + + +# ============================================================================= +# Prerequisites +# ============================================================================= + +def check_adb() -> bool: + """Check if adb is installed and accessible""" + try: + result = subprocess.run( + ["adb", "version"], + capture_output=True, text=True, timeout=5 + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +# ============================================================================= +# ADB helpers +# ============================================================================= + +def adb(args: list, check: bool = True, timeout: int = 60) -> str: + """Run an adb command, return stdout. Raises on non-zero if check=True.""" + cmd = ["adb"] + args + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if check and result.returncode != 0: + raise RuntimeError( + f"adb {' '.join(str(a) for a in args[:3])} failed:\n{result.stderr.strip()}" + ) + return result.stdout.strip() + + +def adb_devices() -> list: + """Return list of serial numbers for connected, authorised ADB devices.""" + try: + output = adb(["devices"], check=False, timeout=10) + except Exception: + return [] + devices = [] + for line in output.splitlines()[1:]: + line = line.strip() + if "\t" in line: + serial, state = line.split("\t", 1) + if state.strip() == "device": + devices.append(serial.strip()) + return devices + + +def wait_for_adb(timeout_sec: int = 60) -> bool: + """Poll until at least one authorised ADB device appears.""" + print(f" Waiting for ADB device (up to {timeout_sec}s)", end="", flush=True) + start = time.time() + while time.time() - start < timeout_sec: + if adb_devices(): + print(" — found!") + return True + time.sleep(2) + print(".", end="", flush=True) + print(" — timed out!") + return False + + +def adb_push(local: str, remote: str) -> None: + """Push a single file to the device.""" + name = Path(local).name + size = Path(local).stat().st_size + print(f" push {name} ({size:,} bytes) → {remote}") + adb(["push", local, remote], timeout=120) + + +def adb_shell(cmd: str, check: bool = True) -> str: + """Run a shell command on the device via ADB and return output.""" + return adb(["shell", cmd], check=check, timeout=30) + + +# ============================================================================= +# USB mode switch (mirrors Rayhunter enable_command_mode()) +# ============================================================================= + +def find_orbic() -> tuple: + """ + Search for the Orbic device in any of its known USB modes. + Returns (device_object, product_id) or (None, None). + """ + if not HAS_PYUSB: + return None, None + for pid in (PRODUCT_ID_ADB, PRODUCT_ID_RNDIS2, PRODUCT_ID_RNDIS): + dev = usb.core.find(idVendor=VENDOR_ID, idProduct=pid) + if dev is not None: + return dev, pid + return None, None + + +def switch_to_adb_mode() -> bool: + """ + Send the USB vendor control request that reboots the Orbic into ADB mode. + + Mirrors Rayhunter's enable_command_mode() (installer/src/orbic.rs): + Control { + control_type: ControlType::Vendor, + recipient: Recipient::Device, + request: 0xa0, + value: 0, + index: 0, + } + bmRequestType = 0x40 (Host→Device | Vendor | Device) + + After the request the device reboots; a STALL/pipe/timeout error is normal + because the firmware restarts mid-transfer. + """ + if not HAS_PYUSB: + print(" [!] pyusb not installed — cannot auto-switch USB mode.") + print(" Install with: pip install pyusb") + print(" You will need to manually enable ADB on the device.") + return False + + dev, pid = find_orbic() + if dev is None: + print(" [!] No Orbic device found via USB. Is it plugged in?") + return False + + mode_label = { + PRODUCT_ID_RNDIS: "RNDIS (normal hotspot)", + PRODUCT_ID_RNDIS2: "RNDIS + serial", + PRODUCT_ID_ADB: "ADB/debug (already ready)", + }.get(pid, f"unknown 0x{pid:04x}") + + print(f" Orbic found: PID=0x{pid:04x} ({mode_label})") + + if pid == PRODUCT_ID_ADB: + print(" Device is already in ADB mode — no switch needed.") + return True + + print(" Sending mode-switch vendor control request…") + try: + # Set configuration so we can issue control requests + try: + dev.set_configuration() + except Exception: + pass + + # Send the vendor control request (same semantics as Rayhunter nusb call) + dev.ctrl_transfer( + bmRequestType=0x40, # Host→Device | Vendor | Device + bRequest=0xa0, + wValue=0, + wIndex=0, + data_or_wLength=None, + timeout=2000, + ) + print(" Mode-switch sent. Device is rebooting…") + return True + + except usb.core.USBError as exc: + # A pipe/stall/timeout error is expected: the device reboots during the + # transfer, which Rayhunter also silently ignores. + err_lower = str(exc).lower() + if any(kw in err_lower for kw in ("pipe", "stall", "timeout", "no data")): + print(" Mode-switch sent (device is rebooting)…") + return True + print(f" [!] Unexpected USB error: {exc}") + return False + except Exception as exc: + print(f" [!] Error during mode switch: {exc}") + return False + + +# ============================================================================= +# AT+SYSCMD via USB serial (mirrors Rayhunter adb_serial_cmd / send_serial_cmd) +# ============================================================================= + +def open_at_interface(): + """ + Claim the CDC ACM serial interface (interface 1) on the Orbic ADB device + so we can send AT+SYSCMD commands as root. + + The Orbic's atfwd_daemon runs as root and handles AT+SYSCMD — this is how + Rayhunter performs privileged operations (chmod, mv, etc.) without needing + a rooted shell. + + IMPORTANT: The system ADB daemon holds the USB device open, which blocks + pyusb from claiming interface 1. We kill it first, claim the serial + interface, and let the caller restart adb when done. + """ + if not HAS_PYUSB: + return None + + # Kill the ADB daemon so it releases its hold on the USB device. + # This is necessary because adbd claims the ADB interface (0) and keeps + # the device open, preventing pyusb from claiming the serial interface (1). + print(" Stopping ADB daemon to release USB device…") + try: + subprocess.run(["adb", "kill-server"], capture_output=True, timeout=8) + time.sleep(2) + except Exception: + pass + + dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID_ADB) + if dev is None: + print(" [!] Orbic ADB device not found after killing adb server.") + return None + + # Set USB configuration (required before claiming interfaces) + try: + dev.set_configuration() + except Exception: + pass + + # Detach the kernel CDC ACM / serial driver from interface 1. + # On Linux this usually works. On macOS it requires root or an entitlement; + # if it fails the claim attempt below will surface a clearer error. + try: + if dev.is_kernel_driver_active(SERIAL_INTERFACE): + dev.detach_kernel_driver(SERIAL_INTERFACE) + except Exception: + pass + + try: + usb.util.claim_interface(dev, SERIAL_INTERFACE) + return dev + except usb.core.USBError as exc: + err = str(exc).lower() + print(f" [!] Could not claim AT serial interface: {exc}") + if "access" in err or "permission" in err or "not permitted" in err: + print(" On macOS/Linux try: sudo python3 deploy_usb.py") + elif "busy" in err or "resource" in err: + print(" Serial interface is busy. Try unplugging and replugging the device.") + # Restart ADB so fallback can still use adb shell + try: + subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) + except Exception: + pass + return None + + +def at_syscmd_raw(dev, command: str, timeout_ms: int = 3000) -> bool: + """ + Low-level AT+SYSCMD send over USB bulk endpoints. + Protocol (from Rayhunter installer/src/orbic.rs send_serial_cmd): + 1. CLASS control: SET_CONTROL_LINE_STATE (0x22), value=3, index=interface + 2. Bulk write to 0x02: "\\r\\nAT+SYSCMD=\\r\\n" + 3. Bulk read from 0x82: echoed command (discard) + 4. Bulk read from 0x82: actual response + 5. Success if response contains "\\r\\nOK\\r\\n" + """ + if dev is None: + return False + + payload = f"\r\nAT+SYSCMD={command}\r\n".encode() + + try: + # Step 1 — enable serial port (SET_CONTROL_LINE_STATE, RTS+DTR = 3) + # bmRequestType = 0x21 = Host→Device | Class | Interface + dev.ctrl_transfer( + bmRequestType=0x21, + bRequest=0x22, + wValue=3, + wIndex=SERIAL_INTERFACE, + data_or_wLength=None, + timeout=timeout_ms, + ) + + # Step 2 — send the AT command + dev.write(SERIAL_OUT_EP, payload, timeout=timeout_ms) + + # Step 3 — consume the echoed command + try: + dev.read(SERIAL_IN_EP, 256, timeout=timeout_ms) + except usb.core.USBTimeoutError: + pass + + # Step 4 — read the actual response + try: + raw = bytes(dev.read(SERIAL_IN_EP, 256, timeout=timeout_ms)) + resp = raw.decode("utf-8", errors="replace") + return "\r\nOK\r\n" in resp + except usb.core.USBTimeoutError: + # Some commands (background processes) don't return before timeout — treat as OK + return True + + except usb.core.USBError as exc: + print(f" [!] USB error in AT+SYSCMD ({command[:40]}…): {exc}") + return False + + +def at_cmd(dev, command: str, retries: int = 3) -> bool: + """Print and execute a single AT+SYSCMD command with automatic retry.""" + print(f" AT+SYSCMD: {command}") + for attempt in range(retries): + if at_syscmd_raw(dev, command): + return True + if attempt < retries - 1: + time.sleep(1) + print(f" [!] Command may have failed after {retries} attempts: {command}") + return False + + +def release_at_interface(dev) -> None: + """Release the claimed serial interface.""" + if dev is None: + return + try: + usb.util.release_interface(dev, SERIAL_INTERFACE) + except Exception: + pass + + +# ============================================================================= +# Boot persistence (mirrors deploy_base64.py setup_autostart) +# ============================================================================= + +def setup_autostart(at_dev) -> None: + """ + Hook DagShell into the USB composition init script so it starts on every boot. + The script /data/usb/boot_hsusb_composition is executed by the USB init daemon + on MDM9207. We replace it with a wrapper that runs dagshell_boot.sh first, + then chains to the original composition script. + """ + print(" Setting up boot persistence (USB composition hook)…") + + # Remove any stale dnsmasq references + at_cmd(at_dev, "sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf") + + # Build the wrapper script in /data/usb/ + at_cmd(at_dev, f"rm -f {USB_WRAPPER_PATH}") + at_cmd(at_dev, f"echo '#!/bin/sh' > {USB_WRAPPER_PATH}") + at_cmd(at_dev, f"echo '# DagShell USB boot wrapper' >> {USB_WRAPPER_PATH}") + at_cmd(at_dev, f"echo 'sh {REMOTE_BOOT} &' >> {USB_WRAPPER_PATH}") + at_cmd(at_dev, f"echo '{USB_ORIGINAL} \"$@\"' >> {USB_WRAPPER_PATH}") + at_cmd(at_dev, f"chmod +x {USB_WRAPPER_PATH}") + + print(" Boot hook installed.") + + +# ============================================================================= +# Post-deploy verification (mirrors Rayhunter test_rayhunter() in orbic.rs) +# ============================================================================= + +def verify_deployment() -> bool: + """ + Prove DagShell is running via ADB — no WiFi required. + + Rayhunter's equivalent (installer/src/orbic.rs test_rayhunter()): + adb_command(device, &["wget", "-O", "-", "http://localhost:8080/index.html"]) + + We do the same for DagShell on port 8443 (HTTPS) plus extra port / log checks. + All commands run on the device itself through the existing ADB session. + """ + print("\n" + "=" * 58) + print(" Verifying deployment (via ADB — no WiFi needed)") + print(" (Mirrors Rayhunter test_rayhunter() concept)") + print("=" * 58) + + all_ok = True + + # ── Check 1: process alive ──────────────────────────────────────────────── + pid = adb_shell("pgrep -f orbic_app 2>/dev/null", check=False).strip() + if pid: + print(f" [✓] Process : orbic_app is running (PID {pid})") + else: + print(" [✗] Process : orbic_app NOT found in process list") + all_ok = False + + # ── Check 2: port 8443 listening ────────────────────────────────────────── + # dagshell_boot.sh has a `sleep 5` before launching orbic_app; give it time. + print(" Waiting 10s for orbic_app to bind port 8443…", end="", flush=True) + time.sleep(10) + print(" done.") + + # Try netstat first, then nc -z, then /proc/net/tcp{,6} + # 8443 decimal = 0x20FB hex + port_open = False + + ns = adb_shell("netstat -tlnp 2>/dev/null | grep ':8443'", check=False).strip() + if ns: + print(f" [✓] Port 8443 : LISTENING ({ns.split()[0]})") + port_open = True + + if not port_open: + # nc -z does a TCP connect-only probe (no TLS) — fastest/most reliable + nc_out = adb_shell("nc -z 127.0.0.1 8443 2>&1; echo rc=$?", check=False).strip() + if "rc=0" in nc_out: + print(" [✓] Port 8443 : REACHABLE (nc -z probe)") + port_open = True + + if not port_open: + # /proc/net/tcp stores local ports in big-endian hex: 8443 = 20FB + for tcp_file in ("/proc/net/tcp6", "/proc/net/tcp"): + entry = adb_shell( + f"grep -i ' 20FB' {tcp_file} 2>/dev/null | head -1", check=False + ).strip() + if entry: + print(f" [✓] Port 8443 : LISTENING ({tcp_file})") + port_open = True + break + + if not port_open: + print(" [!] Port 8443 : not yet visible via netstat/nc/proc — " + "will confirm via TLS probe below") + + # ── Check 3: HTTP(S) response from localhost via adb shell ──────────────── + # This is the key check: hit the server directly on the device through ADB, + # exactly like Rayhunter's wget-to-localhost test — no WiFi connection needed. + # + # "Connection reset by peer" from busybox wget = SUCCESS: + # • The TCP connection WAS established (port is open) + # • The TLS handshake started but busybox's SSL implementation is + # incompatible with BearSSL cipher suites → reset after ClientHello + # • This is DIFFERENT from "Connection refused" (server not running) + MAX_ATTEMPTS = 5 + web_ok = False + print(f" Probing https://127.0.0.1:8443/ via adb shell ({MAX_ATTEMPTS} attempts)…") + + for attempt in range(1, MAX_ATTEMPTS + 1): + # busybox wget: -q quiet, -O - stdout, --no-check-certificate skip TLS verify + raw = adb_shell( + "wget -q -O - --no-check-certificate https://127.0.0.1:8443/ 2>&1 | head -5", + check=False, + ).strip() + + raw_lower = raw.lower() + + if raw and any(kw in raw_lower for kw in ("html", "dagshell", "/dev/null", check=False).strip() + if boot_log: + print() + print(" Boot log (/data/boot_diag.log):") + for line in boot_log.splitlines(): + print(f" {line}") + + # ── Summary ─────────────────────────────────────────────────────────────── + print() + if all_ok: + print(" [✓] All checks passed — DagShell is live!") + print(" Connect to the device WiFi and open: https://192.168.1.1:8443/") + else: + print(" [!] One or more checks failed.") + print(" The app may need a few more seconds. Tips:") + print(" • Wait 10s and re-run: python3 deploy_usb.py --verify-only") + print(" • Check the boot log above for errors") + print("=" * 58) + return all_ok + + +# ============================================================================= +# Main deployment flow +# ============================================================================= + +def deploy() -> None: + banner = "=" * 58 + print(banner) + print(" DagShell USB Deployer") + print(" (Rayhunter orbic-usb method — USB cable, no WiFi needed)") + print(banner) + + # ── Step 1: prerequisites ───────────────────────────────────────────────── + print("\n[1/7] Checking prerequisites…") + + if not check_adb(): + print(" [✗] 'adb' not found in PATH. Install Android Debug Bridge:") + print(" macOS: brew install android-platform-tools") + print(" Linux: sudo apt install adb") + print(" Windows: https://developer.android.com/tools/releases/platform-tools") + sys.exit(1) + print(" [✓] adb found") + + if HAS_PYUSB: + print(" [✓] pyusb found — automatic USB mode switching enabled") + else: + print(" [!] pyusb not installed — manual ADB mode required") + print(" Install with: pip install pyusb") + + if not FIRMWARE_PATH.exists(): + print(f" [✗] Firmware binary not found: {FIRMWARE_PATH}") + print(" Build it first: ./build.sh (macOS/Linux) or .\\build.ps1 (Windows)") + sys.exit(1) + size_kb = FIRMWARE_PATH.stat().st_size // 1024 + print(f" [✓] Firmware: {FIRMWARE_PATH.name} ({size_kb} KB)") + + if not BOOT_SCRIPT_PATH.exists(): + print(f" [✗] Boot script not found: {BOOT_SCRIPT_PATH}") + sys.exit(1) + print(f" [✓] Boot script: {BOOT_SCRIPT_PATH.name}") + + # ── Step 2: detect device ───────────────────────────────────────────────── + print("\n[2/7] Detecting Orbic device…") + + if HAS_PYUSB: + dev_obj, cur_pid = find_orbic() + if dev_obj is None: + print(" [✗] No Orbic device found on USB.") + print(" Make sure the USB cable is connected and the device is on.") + sys.exit(1) + pid_names = { + PRODUCT_ID_RNDIS: "RNDIS hotspot", + PRODUCT_ID_RNDIS2: "RNDIS+serial", + PRODUCT_ID_ADB: "ADB/debug", + } + print(f" [✓] Found Orbic: PID=0x{cur_pid:04x} ({pid_names.get(cur_pid, 'unknown')})") + else: + cur_pid = None + print(" (pyusb unavailable — skipping USB detection)") + + # ── Step 3: mode switch ─────────────────────────────────────────────────── + print("\n[3/7] Switching to ADB mode…") + + need_reboot = False + if HAS_PYUSB and cur_pid != PRODUCT_ID_ADB: + if not switch_to_adb_mode(): + print(" [✗] Failed to send mode-switch command.") + print(" Try unplugging, replugging, and re-running this script.") + sys.exit(1) + need_reboot = True + elif not HAS_PYUSB: + print(" Skipped (pyusb unavailable). Assuming ADB is already enabled.") + else: + print(" Device is already in ADB mode — no reboot needed.") + + if need_reboot: + wait_secs = 20 + print(f" Waiting {wait_secs}s for device to reboot and re-enumerate…") + time.sleep(wait_secs) + + # ── Step 4: wait for ADB ────────────────────────────────────────────────── + print("\n[4/7] Waiting for ADB device…") + if not wait_for_adb(timeout_sec=90): + print(" [✗] ADB device not detected after 90 seconds.") + print(" Troubleshooting:\n" + " • Run 'adb devices' and check for 'unauthorized' status\n" + " • Try: adb kill-server && adb start-server\n" + " • Replug the USB cable") + sys.exit(1) + + # ── Step 5: prepare device ──────────────────────────────────────────────── + print("\n[5/7] Preparing device…") + print(" Stopping any running orbic_app…") + adb_shell("pkill -f orbic_app 2>/dev/null; true", check=False) + print(" Ensuring /tmp is writable…") + adb_shell("mkdir -p /tmp", check=False) + time.sleep(1) + + # ── Step 6: push files via adb ──────────────────────────────────────────── + print("\n[6/7] Pushing files to device via ADB…") + + # Push firmware to /tmp/ — ADB shell user can always write here + adb_push(str(FIRMWARE_PATH), REMOTE_TMP_APP) + + # Push boot script + adb_push(str(BOOT_SCRIPT_PATH), REMOTE_TMP_BOOT) + + # Push SSL certs if they exist + ssl_cert = FIRMWARE_DIR / "server.der" + ssl_key = FIRMWARE_DIR / "server.key.der" + ssl_root = FIRMWARE_DIR / "root.der" + has_ssl = ssl_cert.exists() and ssl_key.exists() + + if has_ssl: + print(" Pushing SSL certificates…") + adb_push(str(ssl_cert), "/tmp/server.der") + adb_push(str(ssl_key), "/tmp/server.key.der") + if ssl_root.exists(): + adb_push(str(ssl_root), "/tmp/root.der") + else: + print(" [!] SSL certs not found — HTTPS will not be available.") + print(" Run python3 orbic_fw_c/gen_pki.py first.") + + print(" All files pushed to /tmp/") + + # ── Step 7: install via AT+SYSCMD ───────────────────────────────────────── + print("\n[7/7] Installing — AT+SYSCMD root operations…") + + # Open the USB serial interface to talk to atfwd_daemon (runs as root) + print(" Opening USB AT command interface (interface 1)…") + at_dev = open_at_interface() + + if at_dev is None: + # ─── Fallback: adb shell ────────────────────────────────────────────── + # open_at_interface() killed the ADB server — wait for it to reconnect. + print(" [!] Could not open AT serial interface.") + print(" Waiting for ADB to reconnect after server restart…") + if not wait_for_adb(timeout_sec=30): + print(" [✗] ADB did not reconnect. Run: adb start-server and retry.") + sys.exit(1) + + # Check whether the ADB shell is root — determines if /data/ is writable. + uid_line = adb_shell("id", check=False).split("\n")[0].strip() + is_root = "uid=0" in uid_line + print(f" ADB shell identity: {uid_line or '(unknown)'}") + if is_root: + print(" [✓] ADB shell has root — proceeding with adb shell install.") + else: + print(" [!] ADB shell is NOT root. Operations on /data/ may fail silently.") + print(" If files don't copy, retry with: sudo python3 deploy_usb.py") + + print() + print(" Moving files to /data/ via adb shell…") + for src, dst in [ + (REMOTE_TMP_APP, REMOTE_FILE), + (REMOTE_TMP_BOOT, REMOTE_BOOT), + ]: + print(f" mv {src} → {dst}") + adb_shell(f"mv {src} {dst}", check=False) + + print(" Setting permissions…") + adb_shell(f"chmod +x {REMOTE_FILE}", check=False) + adb_shell(f"chmod +x {REMOTE_BOOT}", check=False) + + if has_ssl: + print(" Installing SSL certificates…") + adb_shell("mv /tmp/server.der /data/server.der", check=False) + adb_shell("mv /tmp/server.key.der /data/server.key.der", check=False) + adb_shell("chmod 600 /data/server.key.der", check=False) + if ssl_root.exists(): + adb_shell("mv /tmp/root.der /data/root.der", check=False) + + # Verify the firmware actually landed + check_out = adb_shell(f"ls -la {REMOTE_FILE} 2>&1", check=False) + if "No such file" in check_out or not check_out.strip(): + print(f" [✗] {REMOTE_FILE} not found after copy — ADB shell likely lacks root.") + print(" Retry with: sudo python3 deploy_usb.py") + sys.exit(1) + print(f" [✓] Verified: {check_out.strip()}") + + # ── Configure network (ADB shell has root, so iptables works) ──────── + if is_root: + print() + print(" Configuring network (iptables / NAT)…") + adb_shell("iptables -I INPUT -p tcp --dport 8443 -j ACCEPT", check=False) + adb_shell("iptables -I INPUT -p tcp --dport 8080 -j ACCEPT", check=False) + adb_shell("iptables -t nat -F PREROUTING", check=False) + adb_shell("echo 1 > /proc/sys/net/ipv4/ip_forward", check=False) + adb_shell("iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE", check=False) + adb_shell("iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT", check=False) + adb_shell("iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False) + + # ── Boot persistence via adb shell (works because ADB is root) ─────── + if is_root: + print() + print(" Setting up boot persistence (USB composition hook)…") + adb_shell(f"sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf", check=False) + adb_shell(f"rm -f {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '#!/bin/sh' > {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '# DagShell USB boot wrapper' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo 'sh {REMOTE_BOOT} &' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '{USB_ORIGINAL} \"$@\"' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"chmod +x {USB_WRAPPER_PATH}", check=False) + print(" Boot hook installed.") + + # ── Start the app — survive ADB session disconnect ───────────────── + # + # Android cgroups kill the entire ADB shell process group when the + # connection drops — setsid/nohup alone are not enough. + # + # Strategy: + # 1. Run dagshell_boot.sh via setsid to set up iptables/NAT and start + # the netcat shell listener on port 24 (those may survive briefly). + # 2. Forward port 24 locally and send the orbic_app start command + # through that shell, which lives in init's cgroup rather than the + # ADB cgroup → survives after our ADB session ends. + # 3. Fall back to a direct setsid launch if nc isn't up yet. + print() + print(" Running dagshell_boot.sh (sets up iptables / NAT / nc listener)…") + adb_shell(f"setsid sh {REMOTE_BOOT} /dev/null 2>&1 &", check=False) + # Give the boot script time to start the nc listener (it has sleep 5 + # before launching orbic_app, but nc starts earlier). + time.sleep(8) + + # Try to launch orbic_app through the nc shell on port 24. + # The nc listener (`busybox nc -ll -p 24 -e /bin/sh`) is started near + # the top of dagshell_boot.sh in init context — its children inherit + # that context and survive ADB disconnects. + launched_via_nc = False + try: + print(" Forwarding port 24 (nc shell) to launch orbic_app from init context…") + subprocess.run( + ["adb", "forward", "tcp:12024", "tcp:24"], + capture_output=True, timeout=10, + ) + time.sleep(1) + import socket + with socket.create_connection(("127.0.0.1", 12024), timeout=5) as s: + cmd = ( + f"pkill -f orbic_app 2>/dev/null; sleep 1; " + f"{REMOTE_FILE} >/data/orbic_app.log 2>&1 &\n" + ) + s.sendall(cmd.encode()) + time.sleep(3) + launched_via_nc = True + print(" orbic_app start command sent via init-context nc shell.") + except Exception as exc: + print(f" [!] nc shell launch failed ({exc}) — falling back to setsid direct launch…") + adb_shell( + f"pkill -f orbic_app 2>/dev/null; sleep 1; " + f"setsid {REMOTE_FILE} /data/orbic_app.log 2>&1 &", + check=False, + ) + time.sleep(3) + + # Verify the process is alive + ps_out = adb_shell("pgrep -f orbic_app 2>/dev/null", check=False).strip() + if ps_out: + print(f" [✓] orbic_app running (PID {ps_out})") + else: + print(" [!] orbic_app did not start — check /data/boot_diag.log or /data/orbic_app.log") + + else: + # ─── Full install via AT+SYSCMD ─────────────────────────────────────── + print(" [✓] AT interface open — running privileged commands via AT+SYSCMD") + print() + print(" Moving files to /data/ …") + at_cmd(at_dev, f"mv {REMOTE_TMP_APP} {REMOTE_FILE}") + at_cmd(at_dev, f"mv {REMOTE_TMP_BOOT} {REMOTE_BOOT}") + at_cmd(at_dev, f"chmod +x {REMOTE_FILE}") + at_cmd(at_dev, f"chmod +x {REMOTE_BOOT}") + + if has_ssl: + print(" Installing SSL certificates…") + at_cmd(at_dev, "mv /tmp/server.der /data/server.der") + at_cmd(at_dev, "mv /tmp/server.key.der /data/server.key.der") + at_cmd(at_dev, "chmod 600 /data/server.key.der") + if ssl_root.exists(): + at_cmd(at_dev, "mv /tmp/root.der /data/root.der") + + print() + print(" Configuring network…") + at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8443 -j ACCEPT") + at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8080 -j ACCEPT") + at_cmd(at_dev, "iptables -t nat -F PREROUTING") + at_cmd(at_dev, "echo 1 > /proc/sys/net/ipv4/ip_forward") + at_cmd(at_dev, "iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE") + at_cmd(at_dev, "iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT") + at_cmd(at_dev, "iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT") + + print() + setup_autostart(at_dev) + + print() + print(" Starting orbic_app…") + at_cmd(at_dev, f"{REMOTE_FILE} &") + + release_at_interface(at_dev) + + # ── Done ────────────────────────────────────────────────────────────────── + print() + print(banner) + print(" Deployment complete!") + print(banner) + print(f" Firmware : {REMOTE_FILE}") + print(f" Boot hook : {REMOTE_BOOT} → {USB_WRAPPER_PATH}") + if has_ssl: + print(" Web UI : https://192.168.1.1:8443/") + print(" (Connect to device WiFi, then open the URL in your browser.)") + print(" (Accept the self-signed certificate warning.)") + else: + print(" Note: rebuild with SSL certs for HTTPS access.") + print(banner) + + # ── Verify ──────────────────────────────────────────────────────────────── + verify_deployment() + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser( + description="Deploy DagShell to Orbic RCL400 via USB (Rayhunter method)" + ) + parser.add_argument( + "--verify-only", + action="store_true", + help="Skip deployment — just verify an existing DagShell install via ADB", + ) + args = parser.parse_args() + + if args.verify_only: + print("=" * 58) + print(" DagShell USB Verifier (--verify-only)") + print("=" * 58) + if not check_adb(): + print(" [✗] adb not found. Install Android Debug Bridge first.") + sys.exit(1) + if not adb_devices(): + print(" [✗] No ADB device connected.") + sys.exit(1) + ok = verify_deployment() + sys.exit(0 if ok else 1) + else: + deploy() diff --git a/docs/deploy_usb_feature.md b/docs/deploy_usb_feature.md new file mode 100644 index 00000000..7bbe78ce --- /dev/null +++ b/docs/deploy_usb_feature.md @@ -0,0 +1,121 @@ +# USB Cable Deployment (`deploy_usb.py`) + +## Overview + +`deploy_usb.py` is a new one-command installer that deploys DagShell firmware to the Orbic RCL400 entirely over a USB cable — no existing WiFi connection or network access to the device required. It mirrors the approach used by the [Rayhunter](https://github.com/EFForg/rayhunter) project's `orbic-usb` installer and extends it with DagShell-specific setup, TLS certificate management, firewall configuration, and post-deploy verification. + +--- + +## Why This Matters + +Previous DagShell installers (`deploy_base64.py`, `deploy_net.py`) required the user to already be connected to the Orbic's WiFi hotspot or have the device in a known network state. This created a chicken-and-egg problem for fresh devices, brick-recovery scenarios, or cases where the WiFi interface was misconfigured. + +`deploy_usb.py` eliminates that dependency: if you can plug in a USB cable and run `python3 deploy_usb.py`, you can get DagShell running. + +--- + +## What It Does + +### 1. USB Mode Switch (mirrors Rayhunter `enable_command_mode()`) +Detects the Orbic on USB via PyUSB. If the device is in normal RNDIS hotspot mode (`PID 0xf626`) it sends the vendor control request (`bmRequestType=0x40`, `bRequest=0xa0`) that reboots the device into ADB/debug mode (`PID 0xf601`). This is the identical mechanism Rayhunter uses. + +### 2. File Deployment via ADB +Once in ADB mode, all firmware files are pushed to `/tmp/` (always writable by the ADB shell user), then moved to `/data/` with correct permissions via either: +- **AT+SYSCMD** (preferred) — runs as root via the device's `atfwd_daemon` system process, matching Rayhunter's `adb_serial_cmd` approach. +- **ADB shell fallback** — used automatically if the USB serial interface can't be claimed (e.g., macOS permissions). Requires ADB shell to have root (which it does on stock Orbic RCL400). + +Files deployed: +| Local | Device | +|---|---| +| `orbic_fw_c/orbic_app` | `/data/orbic_app` | +| `dagshell_boot.sh` | `/data/dagshell_boot.sh` | +| `orbic_fw_c/server.der` | `/data/server.der` | +| `orbic_fw_c/server.key.der` | `/data/server.key.der` | +| `orbic_fw_c/root.der` | `/data/root.der` | + +### 3. Firewall & NAT Configuration +Applies iptables rules immediately (not just on next boot): +```sh +iptables -I INPUT -p tcp --dport 8443 -j ACCEPT # DagShell HTTPS UI +iptables -I INPUT -p tcp --dport 8080 -j ACCEPT # Rayhunter / HTTP probe +iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE +iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT +iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT +echo 1 > /proc/sys/net/ipv4/ip_forward +``` + +### 4. Boot Persistence (USB Composition Hook) +Installs a wrapper at `/data/usb/boot_hsusb_composition` that runs `dagshell_boot.sh` on every USB init cycle, then chains to the original composition script. This is the same persistence mechanism used by the existing `deploy_base64.py`. + +### 5. Persistent Process Launch +Starting a daemon from an ADB shell is non-trivial on Android: the OS uses cgroups and kills all child processes of an ADB session when the connection drops — `nohup` and `setsid` alone are insufficient. + +`deploy_usb.py` solves this using a two-phase approach: +1. Run `dagshell_boot.sh` via `setsid` to bring up iptables and — crucially — the `busybox nc -ll -p 24 -e /bin/sh` listener. +2. Connect back to that netcat shell (forwarded via `adb forward tcp:12024 tcp:24`) and issue the `orbic_app` start command *from within that shell*. Because the nc listener was started from the USB init cgroup (not the ADB cgroup), its children survive the ADB disconnect. + +If the nc shell is not yet available, the script falls back to a direct `setsid` launch with output logged to `/data/orbic_app.log`. + +### 6. Post-Deploy Verification (mirrors Rayhunter `test_rayhunter()`) +After deployment, the script verifies DagShell is live entirely through ADB — no WiFi needed: +- Process check via `pgrep -f orbic_app` +- Port 8443 check via `netstat`, `nc -z`, and `/proc/net/tcp` (hex `20FB`) +- TLS probe via `busybox wget --no-check-certificate https://127.0.0.1:8443/` +- "Connection reset by peer" is treated as a success (BearSSL rejects BusyBox's cipher suites but the TCP+TLS exchange confirms the server is live) +- Boot log tail from `/data/boot_diag.log` + +--- + +## SSL Certificate Management (`gen_pki.py`) + +### Problem Solved +The original `gen_pki.py` generated leaf server certificates with only **365-day validity**. An expired certificate causes BearSSL's `br_ssl_server_init_full_rsa` to silently abort TLS handshakes, making the web UI unreachable even with the firewall open. This was the root cause of HTTPS connectivity failures starting ~1 year after initial deployment. + +### Fix +`gen_pki.py` now generates both the Root CA and the leaf server certificate with **3650-day validity (10 years)**. The `datetime.utcnow()` deprecation warnings are also resolved by switching to `datetime.now(datetime.timezone.utc)`. + +To regenerate certificates: +```sh +cd orbic_fw_c +python3 gen_pki.py +# Then redeploy: python3 ../deploy_usb.py +``` + +--- + +## Quick Start + +```sh +# Install dependencies +pip install pyusb + +# (macOS) Install ADB +brew install android-platform-tools + +# Generate fresh TLS certificates (first time only) +cd orbic_fw_c && python3 gen_pki.py && cd .. + +# Deploy — plug in USB cable first +python3 deploy_usb.py + +# Or verify an existing install without redeploying +python3 deploy_usb.py --verify-only +``` + +Once deployed, connect to the Orbic's WiFi hotspot and open: +``` +https://192.168.1.1:8443/ +``` +Accept the self-signed certificate warning (or install `root.der` as a trusted CA on your device). + +--- + +## Requirements + +| Requirement | Notes | +|---|---| +| Python 3.8+ | Standard library only for ADB fallback | +| `pyusb` | `pip install pyusb` — needed for USB mode switch & AT+SYSCMD | +| `adb` | Android Debug Bridge in PATH | +| macOS/Linux | `sudo` may be needed to claim USB serial interface on macOS | +| Windows | WSL recommended; native support via `libusb` + Zadig driver | diff --git a/orbic_fw_c/gen_pki.py b/orbic_fw_c/gen_pki.py index f7392d26..1713c917 100644 --- a/orbic_fw_c/gen_pki.py +++ b/orbic_fw_c/gen_pki.py @@ -42,8 +42,8 @@ def save_key_der(key, filename): .issuer_name(root_subject) # Self-signed .public_key(root_key.public_key()) .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.utcnow()) - .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650)) + .not_valid_before(datetime.datetime.now(datetime.timezone.utc)) + .not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=3650)) .add_extension( x509.BasicConstraints(ca=True, path_length=None), critical=True, ) @@ -74,8 +74,8 @@ def save_key_der(key, filename): .issuer_name(root_subject) # Signed by Root .public_key(server_key.public_key()) .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.utcnow()) - .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) + .not_valid_before(datetime.datetime.now(datetime.timezone.utc)) + .not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=3650)) .add_extension( x509.BasicConstraints(ca=False, path_length=None), critical=True, ) From f6728bd57c4110f34df8ce288e4d8cce0d7c3568 Mon Sep 17 00:00:00 2001 From: SimeonOnSecurity <4913771+simeononsecurity@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:03:08 -0500 Subject: [PATCH 2/7] fix: handle pyusb NoBackendError when libusb not installed Catch usb.core.NoBackendError at import time by probing for a working backend with usb.core.find(). Without libusb installed (e.g. on macOS before 'brew install libusb') the script crashed with a traceback at step 2. Now degrades gracefully to ADB-only mode with a clear install hint. --- deploy_usb.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/deploy_usb.py b/deploy_usb.py index 809136e0..2e0c73b6 100644 --- a/deploy_usb.py +++ b/deploy_usb.py @@ -41,12 +41,24 @@ from pathlib import Path # Optional pyusb for USB mode switching and AT+SYSCMD +# We also probe for a working libusb backend at import time so that a +# missing native library (e.g. libusb not installed via brew/apt) degrades +# gracefully to the ADB-only path instead of crashing at runtime. try: import usb.core import usb.util + # Probe for a working backend — raises NoBackendError if libusb is absent + usb.core.find() HAS_PYUSB = True except ImportError: HAS_PYUSB = False +except usb.core.NoBackendError: + HAS_PYUSB = False + print(" [!] pyusb is installed but no libusb backend was found.") + print(" Install libusb to enable USB mode switching and AT+SYSCMD:") + print(" macOS: brew install libusb") + print(" Linux: sudo apt install libusb-1.0-0") + print(" Falling back to ADB-only mode.\n") # ============================================================================= From a51f6ea20f4e7a9c5bd15a4a3b30551d57bed7ed Mon Sep 17 00:00:00 2001 From: SimeonOnSecurity <4913771+simeononsecurity@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:06:02 -0500 Subject: [PATCH 3/7] Update requirements.txt --- requirements.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/requirements.txt b/requirements.txt index e4894ff0..dc780508 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,15 @@ requests>=2.25.0 # Cryptography for PKI generation (gen_pki.py) cryptography>=3.4.0 + +# USB cable deployment (deploy_usb.py) — optional. +# Enables automatic RNDIS->ADB USB mode switching and AT+SYSCMD root ops. +# Requires the native libusb backend in addition to this Python package: +# macOS: brew install libusb +# Linux: sudo apt install libusb-1.0-0 +# Also requires the `adb` CLI (Android Debug Bridge) on your PATH: +# macOS: brew install android-platform-tools +# Linux: sudo apt install adb +# If pyusb/libusb are unavailable, deploy_usb.py falls back to ADB-only mode. +pyusb>=1.2.0 + From 8e6b22be9a3ac02b361a38f95b639738fd4d54ef Mon Sep 17 00:00:00 2001 From: SimeonOnSecurity <4913771+simeononsecurity@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:06:34 -0500 Subject: [PATCH 4/7] docs: document pyusb/libusb/adb deps for USB deployer - requirements.txt: add pyusb (optional) with libusb + adb install notes - README.md: add USB deploy dependencies to Requirements section and new 'Option C: USB Cable Deploy' deployment walkthrough --- README.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 76f490dc..ffa57073 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,17 @@ A terminal-styled custom firmware for the **Orbic RCL400** hotspot with hacking - Orbic RCL400 hotspot - **Windows:** ARM cross-compiler (included in `gcc_win/` folder) - **macOS:** Custom ARM toolchain (included in `gcc_mac/` folder, built with crosstool-ng targeting kernel 3.2 for compatibility) -- Python 3 with `requests` and `cryptography` modules +- Python 3 with `requests` and `cryptography` modules (`pip install -r requirements.txt`) +- **USB cable deploy (`deploy_usb.py`) — optional:** + - `pyusb` Python package (`pip install pyusb`) + native **libusb** backend + - macOS: `brew install libusb` + - Linux: `sudo apt install libusb-1.0-0` + - `adb` (Android Debug Bridge) CLI on your PATH + - macOS: `brew install android-platform-tools` + - Linux: `sudo apt install adb` + - Windows: [platform-tools](https://developer.android.com/tools/releases/platform-tools) + - If `pyusb`/`libusb` are missing, `deploy_usb.py` automatically falls back to ADB-only mode. + ## Building @@ -135,6 +145,32 @@ This uploads and installs: The firmware auto-starts on reboot (port 8443). +### Option C: USB Cable Deploy (no WiFi / no password) + +Deploy entirely over a USB cable — no WiFi connection and no admin password +required. Mirrors Rayhunter's `orbic-usb` installer method. + +```bash +# Install dependencies (see Requirements above) +pip install -r requirements.txt # includes pyusb +brew install libusb # macOS native backend (Linux: apt install libusb-1.0-0) +brew install android-platform-tools # adb CLI + +# Generate certs (first time only) and deploy +cd orbic_fw_c && python3 gen_pki.py && cd .. +python3 deploy_usb.py + +# Verify an existing install without redeploying +python3 deploy_usb.py --verify-only +``` + +`deploy_usb.py` switches the device from RNDIS to ADB mode over USB, pushes the +firmware/certs, configures the firewall, installs boot persistence, and starts +`orbic_app`. If `pyusb`/`libusb` are unavailable it falls back to ADB-only mode +(requires the device to already be in ADB mode). See +[`docs/deploy_usb_feature.md`](docs/deploy_usb_feature.md) for full details. + + ## Accessing Open your browser to: **`https://192.168.1.1:8443/`** From 780a2e997499eac58b08303eb2c31d58ab90e5f1 Mon Sep 17 00:00:00 2001 From: SimeonOnSecurity <4913771+simeononsecurity@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:12:02 -0500 Subject: [PATCH 5/7] feat: add AT-interface health checks, settle pause, and resilient fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the 'Operation timed out' / 'No such device' wall of errors seen on the first run right after a USB mode switch + reboot — the device accepted the serial-interface claim but atfwd_daemon was not ready yet. - Add USB_STABILIZE_SEC settle pause after a reboot before touching AT - Add at_health_check(): probe AT interface with a no-op until responsive - at_syscmd_raw now raises ATDeviceLost on errno 19 (device dropped) so we stop retrying every command against a dead handle - Extract install_via_at() and install_via_adb_shell() helpers - Step 7 now: stabilize -> health-check -> AT install (primary), with automatic fallback to the ADB shell path if the interface is unresponsive or drops mid-install - ADB fallback gained its own readiness wait + echo health check --- deploy_usb.py | 437 +++++++++++++++++++++++++++++++------------------- 1 file changed, 271 insertions(+), 166 deletions(-) diff --git a/deploy_usb.py b/deploy_usb.py index 2e0c73b6..5757c41b 100644 --- a/deploy_usb.py +++ b/deploy_usb.py @@ -76,6 +76,19 @@ SERIAL_OUT_EP = 0x02 SERIAL_IN_EP = 0x82 +# Settle time (seconds) to let the device fully re-enumerate and bring up +# atfwd_daemon after a USB mode switch / reboot before we hammer it with +# AT+SYSCMD commands. Skipping this is what caused "Operation timed out" / +# "No such device" failures on the first post-switch run. +USB_STABILIZE_SEC = 8 + + +class ATDeviceLost(Exception): + """Raised when the USB device disappears mid-AT-command (errno 19 / no + such device). Signals the caller to stop retrying and fall back to ADB.""" + pass + + # ============================================================================= # File paths @@ -380,15 +393,25 @@ def at_syscmd_raw(dev, command: str, timeout_ms: int = 3000) -> bool: return True except usb.core.USBError as exc: + # errno 19 ("No such device") means the device handle is stale — the + # device re-enumerated or dropped off the bus. Retrying is pointless; + # signal the caller to fall back to the ADB shell path. + err_lower = str(exc).lower() + if getattr(exc, "errno", None) == 19 or "no such device" in err_lower: + raise ATDeviceLost(str(exc)) print(f" [!] USB error in AT+SYSCMD ({command[:40]}…): {exc}") return False def at_cmd(dev, command: str, retries: int = 3) -> bool: - """Print and execute a single AT+SYSCMD command with automatic retry.""" + """Print and execute a single AT+SYSCMD command with automatic retry. + + Propagates ATDeviceLost (does not swallow it) so the caller can abort the + AT path immediately instead of retrying every command against a dead handle. + """ print(f" AT+SYSCMD: {command}") for attempt in range(retries): - if at_syscmd_raw(dev, command): + if at_syscmd_raw(dev, command): # may raise ATDeviceLost return True if attempt < retries - 1: time.sleep(1) @@ -396,6 +419,33 @@ def at_cmd(dev, command: str, retries: int = 3) -> bool: return False +def at_health_check(dev, attempts: int = 6, delay: float = 2.0) -> bool: + """ + Confirm the AT+SYSCMD interface is actually responsive before running the + bulk of privileged commands. + + Right after a USB mode switch / reboot the device may accept the interface + claim but still be settling — commands then time out or the handle goes + stale ("No such device"). We send a cheap no-op (`true`) and wait for the + atfwd_daemon to answer, retrying with pauses. Returns False if the device + never responds or drops off the bus. + """ + print(f" Health check: probing AT interface ({attempts} attempts)…") + for i in range(1, attempts + 1): + try: + if at_syscmd_raw(dev, "true", timeout_ms=2000): + print(f" [✓] AT interface responsive (attempt {i}).") + return True + except ATDeviceLost: + print(" [!] AT interface dropped off the bus during health check.") + return False + print(f" [.] AT interface not ready yet (attempt {i}/{attempts}) — waiting {delay:.0f}s…") + time.sleep(delay) + print(" [!] AT interface never became responsive.") + return False + + + def release_at_interface(dev) -> None: """Release the claimed serial interface.""" if dev is None: @@ -587,11 +637,194 @@ def verify_deployment() -> bool: return all_ok +# ============================================================================= +# Install paths (AT+SYSCMD primary, ADB shell fallback) +# ============================================================================= + +def install_via_adb_shell(has_ssl: bool, ssl_root: Path) -> None: + """ + Install DagShell using the root ADB shell (fallback when the AT serial + interface is unavailable or drops out). Stock Orbic RCL400 ADB shells run + as root, so /data/ writes and iptables succeed here. + """ + # The AT path may have killed the ADB server — make sure it's back. + print(" Waiting for ADB to be ready…") + if not wait_for_adb(timeout_sec=30): + print(" [✗] ADB did not reconnect. Run: adb start-server and retry.") + sys.exit(1) + + # Health check: confirm the ADB shell actually answers before we rely on it. + probe = adb_shell("echo dagshell_ready", check=False).strip() + if "dagshell_ready" not in probe: + print(f" [!] ADB shell health check inconclusive (got: {probe[:60]!r}) — continuing anyway.") + else: + print(" [✓] ADB shell responsive.") + + # Check whether the ADB shell is root — determines if /data/ is writable. + uid_line = adb_shell("id", check=False).split("\n")[0].strip() + is_root = "uid=0" in uid_line + print(f" ADB shell identity: {uid_line or '(unknown)'}") + if is_root: + print(" [✓] ADB shell has root — proceeding with adb shell install.") + else: + print(" [!] ADB shell is NOT root. Operations on /data/ may fail silently.") + print(" If files don't copy, retry with: sudo python3 deploy_usb.py") + + print() + print(" Moving files to /data/ via adb shell…") + for src, dst in [ + (REMOTE_TMP_APP, REMOTE_FILE), + (REMOTE_TMP_BOOT, REMOTE_BOOT), + ]: + print(f" mv {src} → {dst}") + adb_shell(f"mv {src} {dst}", check=False) + + print(" Setting permissions…") + adb_shell(f"chmod +x {REMOTE_FILE}", check=False) + adb_shell(f"chmod +x {REMOTE_BOOT}", check=False) + + if has_ssl: + print(" Installing SSL certificates…") + adb_shell("mv /tmp/server.der /data/server.der", check=False) + adb_shell("mv /tmp/server.key.der /data/server.key.der", check=False) + adb_shell("chmod 600 /data/server.key.der", check=False) + if ssl_root.exists(): + adb_shell("mv /tmp/root.der /data/root.der", check=False) + + # Verify the firmware actually landed + check_out = adb_shell(f"ls -la {REMOTE_FILE} 2>&1", check=False) + if "No such file" in check_out or not check_out.strip(): + print(f" [✗] {REMOTE_FILE} not found after copy — ADB shell likely lacks root.") + print(" Retry with: sudo python3 deploy_usb.py") + sys.exit(1) + print(f" [✓] Verified: {check_out.strip()}") + + # ── Configure network (ADB shell has root, so iptables works) ──────── + if is_root: + print() + print(" Configuring network (iptables / NAT)…") + adb_shell("iptables -I INPUT -p tcp --dport 8443 -j ACCEPT", check=False) + adb_shell("iptables -I INPUT -p tcp --dport 8080 -j ACCEPT", check=False) + adb_shell("iptables -t nat -F PREROUTING", check=False) + adb_shell("echo 1 > /proc/sys/net/ipv4/ip_forward", check=False) + adb_shell("iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE", check=False) + adb_shell("iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT", check=False) + adb_shell("iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False) + + # ── Boot persistence via adb shell (works because ADB is root) ─────── + if is_root: + print() + print(" Setting up boot persistence (USB composition hook)…") + adb_shell(f"sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf", check=False) + adb_shell(f"rm -f {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '#!/bin/sh' > {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '# DagShell USB boot wrapper' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo 'sh {REMOTE_BOOT} &' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '{USB_ORIGINAL} \"$@\"' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"chmod +x {USB_WRAPPER_PATH}", check=False) + print(" Boot hook installed.") + + # ── Start the app — survive ADB session disconnect ───────────────── + # + # Android cgroups kill the entire ADB shell process group when the + # connection drops — setsid/nohup alone are not enough. + # + # Strategy: + # 1. Run dagshell_boot.sh via setsid to set up iptables/NAT and start + # the netcat shell listener on port 24 (those may survive briefly). + # 2. Forward port 24 locally and send the orbic_app start command + # through that shell, which lives in init's cgroup rather than the + # ADB cgroup → survives after our ADB session ends. + # 3. Fall back to a direct setsid launch if nc isn't up yet. + print() + print(" Running dagshell_boot.sh (sets up iptables / NAT / nc listener)…") + adb_shell(f"setsid sh {REMOTE_BOOT} /dev/null 2>&1 &", check=False) + # Give the boot script time to start the nc listener (it has sleep 5 + # before launching orbic_app, but nc starts earlier). + time.sleep(8) + + # Try to launch orbic_app through the nc shell on port 24. + launched_via_nc = False + try: + print(" Forwarding port 24 (nc shell) to launch orbic_app from init context…") + subprocess.run( + ["adb", "forward", "tcp:12024", "tcp:24"], + capture_output=True, timeout=10, + ) + time.sleep(1) + import socket + with socket.create_connection(("127.0.0.1", 12024), timeout=5) as s: + cmd = ( + f"pkill -f orbic_app 2>/dev/null; sleep 1; " + f"{REMOTE_FILE} >/data/orbic_app.log 2>&1 &\n" + ) + s.sendall(cmd.encode()) + time.sleep(3) + launched_via_nc = True + print(" orbic_app start command sent via init-context nc shell.") + except Exception as exc: + print(f" [!] nc shell launch failed ({exc}) — falling back to setsid direct launch…") + adb_shell( + f"pkill -f orbic_app 2>/dev/null; sleep 1; " + f"setsid {REMOTE_FILE} /data/orbic_app.log 2>&1 &", + check=False, + ) + time.sleep(3) + + # Verify the process is alive + ps_out = adb_shell("pgrep -f orbic_app 2>/dev/null", check=False).strip() + if ps_out: + print(f" [✓] orbic_app running (PID {ps_out})") + else: + print(" [!] orbic_app did not start — check /data/boot_diag.log or /data/orbic_app.log") + + +def install_via_at(at_dev, has_ssl: bool, ssl_root: Path) -> None: + """ + Install DagShell via AT+SYSCMD over the USB serial interface (root via + atfwd_daemon). May raise ATDeviceLost if the device drops mid-install, in + which case the caller should fall back to install_via_adb_shell(). + """ + print(" [✓] AT interface open — running privileged commands via AT+SYSCMD") + print() + print(" Moving files to /data/ …") + at_cmd(at_dev, f"mv {REMOTE_TMP_APP} {REMOTE_FILE}") + at_cmd(at_dev, f"mv {REMOTE_TMP_BOOT} {REMOTE_BOOT}") + at_cmd(at_dev, f"chmod +x {REMOTE_FILE}") + at_cmd(at_dev, f"chmod +x {REMOTE_BOOT}") + + if has_ssl: + print(" Installing SSL certificates…") + at_cmd(at_dev, "mv /tmp/server.der /data/server.der") + at_cmd(at_dev, "mv /tmp/server.key.der /data/server.key.der") + at_cmd(at_dev, "chmod 600 /data/server.key.der") + if ssl_root.exists(): + at_cmd(at_dev, "mv /tmp/root.der /data/root.der") + + print() + print(" Configuring network…") + at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8443 -j ACCEPT") + at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8080 -j ACCEPT") + at_cmd(at_dev, "iptables -t nat -F PREROUTING") + at_cmd(at_dev, "echo 1 > /proc/sys/net/ipv4/ip_forward") + at_cmd(at_dev, "iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE") + at_cmd(at_dev, "iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT") + at_cmd(at_dev, "iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT") + + print() + setup_autostart(at_dev) + + print() + print(" Starting orbic_app…") + at_cmd(at_dev, f"{REMOTE_FILE} &") + + # ============================================================================= # Main deployment flow # ============================================================================= def deploy() -> None: + banner = "=" * 58 print(banner) print(" DagShell USB Deployer") @@ -718,172 +951,44 @@ def deploy() -> None: print(" Opening USB AT command interface (interface 1)…") at_dev = open_at_interface() - if at_dev is None: - # ─── Fallback: adb shell ────────────────────────────────────────────── - # open_at_interface() killed the ADB server — wait for it to reconnect. - print(" [!] Could not open AT serial interface.") - print(" Waiting for ADB to reconnect after server restart…") - if not wait_for_adb(timeout_sec=30): - print(" [✗] ADB did not reconnect. Run: adb start-server and retry.") - sys.exit(1) - - # Check whether the ADB shell is root — determines if /data/ is writable. - uid_line = adb_shell("id", check=False).split("\n")[0].strip() - is_root = "uid=0" in uid_line - print(f" ADB shell identity: {uid_line or '(unknown)'}") - if is_root: - print(" [✓] ADB shell has root — proceeding with adb shell install.") - else: - print(" [!] ADB shell is NOT root. Operations on /data/ may fail silently.") - print(" If files don't copy, retry with: sudo python3 deploy_usb.py") - - print() - print(" Moving files to /data/ via adb shell…") - for src, dst in [ - (REMOTE_TMP_APP, REMOTE_FILE), - (REMOTE_TMP_BOOT, REMOTE_BOOT), - ]: - print(f" mv {src} → {dst}") - adb_shell(f"mv {src} {dst}", check=False) - - print(" Setting permissions…") - adb_shell(f"chmod +x {REMOTE_FILE}", check=False) - adb_shell(f"chmod +x {REMOTE_BOOT}", check=False) - - if has_ssl: - print(" Installing SSL certificates…") - adb_shell("mv /tmp/server.der /data/server.der", check=False) - adb_shell("mv /tmp/server.key.der /data/server.key.der", check=False) - adb_shell("chmod 600 /data/server.key.der", check=False) - if ssl_root.exists(): - adb_shell("mv /tmp/root.der /data/root.der", check=False) - - # Verify the firmware actually landed - check_out = adb_shell(f"ls -la {REMOTE_FILE} 2>&1", check=False) - if "No such file" in check_out or not check_out.strip(): - print(f" [✗] {REMOTE_FILE} not found after copy — ADB shell likely lacks root.") - print(" Retry with: sudo python3 deploy_usb.py") - sys.exit(1) - print(f" [✓] Verified: {check_out.strip()}") - - # ── Configure network (ADB shell has root, so iptables works) ──────── - if is_root: - print() - print(" Configuring network (iptables / NAT)…") - adb_shell("iptables -I INPUT -p tcp --dport 8443 -j ACCEPT", check=False) - adb_shell("iptables -I INPUT -p tcp --dport 8080 -j ACCEPT", check=False) - adb_shell("iptables -t nat -F PREROUTING", check=False) - adb_shell("echo 1 > /proc/sys/net/ipv4/ip_forward", check=False) - adb_shell("iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE", check=False) - adb_shell("iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT", check=False) - adb_shell("iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False) - - # ── Boot persistence via adb shell (works because ADB is root) ─────── - if is_root: - print() - print(" Setting up boot persistence (USB composition hook)…") - adb_shell(f"sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf", check=False) - adb_shell(f"rm -f {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo '#!/bin/sh' > {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo '# DagShell USB boot wrapper' >> {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo 'sh {REMOTE_BOOT} &' >> {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo '{USB_ORIGINAL} \"$@\"' >> {USB_WRAPPER_PATH}", check=False) - adb_shell(f"chmod +x {USB_WRAPPER_PATH}", check=False) - print(" Boot hook installed.") - - # ── Start the app — survive ADB session disconnect ───────────────── - # - # Android cgroups kill the entire ADB shell process group when the - # connection drops — setsid/nohup alone are not enough. - # - # Strategy: - # 1. Run dagshell_boot.sh via setsid to set up iptables/NAT and start - # the netcat shell listener on port 24 (those may survive briefly). - # 2. Forward port 24 locally and send the orbic_app start command - # through that shell, which lives in init's cgroup rather than the - # ADB cgroup → survives after our ADB session ends. - # 3. Fall back to a direct setsid launch if nc isn't up yet. - print() - print(" Running dagshell_boot.sh (sets up iptables / NAT / nc listener)…") - adb_shell(f"setsid sh {REMOTE_BOOT} /dev/null 2>&1 &", check=False) - # Give the boot script time to start the nc listener (it has sleep 5 - # before launching orbic_app, but nc starts earlier). - time.sleep(8) - - # Try to launch orbic_app through the nc shell on port 24. - # The nc listener (`busybox nc -ll -p 24 -e /bin/sh`) is started near - # the top of dagshell_boot.sh in init context — its children inherit - # that context and survive ADB disconnects. - launched_via_nc = False - try: - print(" Forwarding port 24 (nc shell) to launch orbic_app from init context…") - subprocess.run( - ["adb", "forward", "tcp:12024", "tcp:24"], - capture_output=True, timeout=10, - ) - time.sleep(1) - import socket - with socket.create_connection(("127.0.0.1", 12024), timeout=5) as s: - cmd = ( - f"pkill -f orbic_app 2>/dev/null; sleep 1; " - f"{REMOTE_FILE} >/data/orbic_app.log 2>&1 &\n" - ) - s.sendall(cmd.encode()) - time.sleep(3) - launched_via_nc = True - print(" orbic_app start command sent via init-context nc shell.") - except Exception as exc: - print(f" [!] nc shell launch failed ({exc}) — falling back to setsid direct launch…") - adb_shell( - f"pkill -f orbic_app 2>/dev/null; sleep 1; " - f"setsid {REMOTE_FILE} /data/orbic_app.log 2>&1 &", - check=False, - ) - time.sleep(3) - - # Verify the process is alive - ps_out = adb_shell("pgrep -f orbic_app 2>/dev/null", check=False).strip() - if ps_out: - print(f" [✓] orbic_app running (PID {ps_out})") - else: - print(" [!] orbic_app did not start — check /data/boot_diag.log or /data/orbic_app.log") + # After a USB mode switch + reboot the device often needs extra time before + # atfwd_daemon will answer. Pause, then health-check the AT interface so we + # don't fire a wall of commands at a half-booted device (which produced the + # "Operation timed out" / "No such device" failures previously). + if at_dev is not None: + if need_reboot: + print(f" Letting device settle for {USB_STABILIZE_SEC}s after reboot…") + time.sleep(USB_STABILIZE_SEC) + if not at_health_check(at_dev): + print(" [!] AT interface not responsive — switching to ADB shell fallback.") + release_at_interface(at_dev) + try: + subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) + except Exception: + pass + at_dev = None + if at_dev is None: + # ─── Fallback: ADB shell (no usable AT interface) ───────────────────── + print(" [!] Using ADB shell install path.") + install_via_adb_shell(has_ssl, ssl_root) else: - # ─── Full install via AT+SYSCMD ─────────────────────────────────────── - print(" [✓] AT interface open — running privileged commands via AT+SYSCMD") - print() - print(" Moving files to /data/ …") - at_cmd(at_dev, f"mv {REMOTE_TMP_APP} {REMOTE_FILE}") - at_cmd(at_dev, f"mv {REMOTE_TMP_BOOT} {REMOTE_BOOT}") - at_cmd(at_dev, f"chmod +x {REMOTE_FILE}") - at_cmd(at_dev, f"chmod +x {REMOTE_BOOT}") - - if has_ssl: - print(" Installing SSL certificates…") - at_cmd(at_dev, "mv /tmp/server.der /data/server.der") - at_cmd(at_dev, "mv /tmp/server.key.der /data/server.key.der") - at_cmd(at_dev, "chmod 600 /data/server.key.der") - if ssl_root.exists(): - at_cmd(at_dev, "mv /tmp/root.der /data/root.der") - - print() - print(" Configuring network…") - at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8443 -j ACCEPT") - at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8080 -j ACCEPT") - at_cmd(at_dev, "iptables -t nat -F PREROUTING") - at_cmd(at_dev, "echo 1 > /proc/sys/net/ipv4/ip_forward") - at_cmd(at_dev, "iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE") - at_cmd(at_dev, "iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT") - at_cmd(at_dev, "iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT") - - print() - setup_autostart(at_dev) - - print() - print(" Starting orbic_app…") - at_cmd(at_dev, f"{REMOTE_FILE} &") - - release_at_interface(at_dev) + # ─── Primary: AT+SYSCMD, with automatic ADB fallback on mid-run drop ── + try: + install_via_at(at_dev, has_ssl, ssl_root) + except ATDeviceLost as exc: + print(f" [!] AT interface dropped mid-install ({exc}).") + print(" Restarting ADB and finishing via ADB shell fallback…") + release_at_interface(at_dev) + at_dev = None + try: + subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) + except Exception: + pass + install_via_adb_shell(has_ssl, ssl_root) + finally: + if at_dev is not None: + release_at_interface(at_dev) # ── Done ────────────────────────────────────────────────────────────────── print() From b6fc196eef63c74c96c9ecdfa4a33cfa0bbf8bb7 Mon Sep 17 00:00:00 2001 From: SimeonOnSecurity <4913771+simeononsecurity@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:40:55 -0500 Subject: [PATCH 6/7] feat: add SUID rootshell binary + fix orbic_app fd 0 crash rootshell: 408-byte ARM asm ELF, SUID root escalation (mirrors Rayhunter) dagshell_boot.sh: start orbic_app with 408 bytes orbic_fw_c/rootshell.S | 81 +++++++++++++++++++++++++++++++++++++++++ orbic_fw_c/rootshell.c | 47 ++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100755 orbic_fw_c/rootshell create mode 100644 orbic_fw_c/rootshell.S create mode 100644 orbic_fw_c/rootshell.c diff --git a/dagshell_boot.sh b/dagshell_boot.sh index 060ae11d..c0300cdd 100644 --- a/dagshell_boot.sh +++ b/dagshell_boot.sh @@ -72,8 +72,10 @@ if [ -f "$CONFIG_FILE" ]; then fi # 7. Start DagShell +# NOTE: > $BOOTLOG echo "[OK] orbic_app started PID=$!" >> $BOOTLOG echo "=== BOOT COMPLETE ===" >> $BOOTLOG diff --git a/orbic_fw_c/rootshell b/orbic_fw_c/rootshell new file mode 100755 index 0000000000000000000000000000000000000000..29eeaf4c17aee3e7ec8229b1beb2b1ccd8e21805 GIT binary patch literal 408 zcmb<-^>JflWMqH=CI$@#5HAEsngHn^APFW0RucvVpoj(o3j;G)9wf{FW-&4x0kS}t z6(l6Uz>pv?|0xs0g2(3y7CZ)uyk`KiuOYFoLfJM3^Pe&}%zert(EEtNpz$fALdzp2 z#l{EB3=1A|L)9<>^)f=)ySW({_5iVdQf8ihaR$R0p#58b*ijH@OJZtbQYHfr8QCw0~l9FjX3J)P9b+8Zy(Li-@a1F>(g!3SFfw%y{DnG~o literal 0 HcmV?d00001 diff --git a/orbic_fw_c/rootshell.S b/orbic_fw_c/rootshell.S new file mode 100644 index 00000000..11b6748f --- /dev/null +++ b/orbic_fw_c/rootshell.S @@ -0,0 +1,81 @@ + /* + * rootshell.S — SUID root escalation shell for Orbic RCL400 (pure ARM asm) + * + * Mirrors Rayhunter's rootshell but as a standalone ELF with zero libc deps. + * When owned by root with SUID bit (chmod 4755), escalates uid=2000 → uid=0. + * + * Build: + * arm-cortex_a8-linux-gnueabi-as -meabi=5 -o rootshell.o rootshell.S + * arm-cortex_a8-linux-gnueabi-ld -o rootshell rootshell.o + * arm-cortex_a8-linux-gnueabi-strip rootshell + * + * Usage: /bin/rootshell -c "command" or /bin/rootshell (interactive) + * + * Syscall numbers (ARM EABI, 32-bit): + * __NR_setgroups32 = 206 + * __NR_setuid32 = 213 + * __NR_setgid32 = 214 + * __NR_execve = 11 + */ + + .text + .global _start + .arm + +_start: + /* ---- setgroups(2, [3003, 3004]) ---- */ + /* Android paranoid networking: AID_INET=3003, AID_NET_RAW=3004 */ + ldr r1, =groups @ r1 = pointer to groups array + mov r0, #2 @ r0 = count + mov r7, #206 @ __NR_setgroups32 + svc #0 + + /* ---- setgid(0) ---- */ + mov r0, #0 + mov r7, #214 @ __NR_setgid32 + svc #0 + + /* ---- setuid(0) ---- */ + mov r0, #0 + mov r7, #213 @ __NR_setuid32 + svc #0 + + /* ---- execve("/bin/sh", argv, environ) ---- + * + * We rewrite argv[0] to point to "/bin/sh" so the shell sees the right + * name, then pass the ORIGINAL argc/argv through (including any -c "cmd"). + * + * On entry, the Linux process stack is: + * [sp+0] = argc + * [sp+4] = argv[0] + * [sp+8] = argv[1] ... + * after NULL terminator: envp[] + * + * We overwrite argv[0] and use the whole argv array as-is. + */ + ldr r3, =shell_path @ r3 = "/bin/sh" + ldr r4, [sp] @ r4 = argc + add r1, sp, #4 @ r1 = &argv[0] + str r3, [r1] @ argv[0] = "/bin/sh" + + /* Find envp: skip past argv[] (argc+1 pointers including NULL) */ + add r2, r4, #1 @ argc + 1 (for NULL terminator) + add r2, r1, r2, lsl #2 @ r2 = argv + (argc+1)*4 = envp + + mov r0, r3 @ r0 = "/bin/sh" path + /* r1 = argv, r2 = envp already set */ + mov r7, #11 @ __NR_execve + svc #0 + + /* If execve returns (error), exit(1) */ + mov r0, #1 + mov r7, #1 @ __NR_exit + svc #0 + + .align 2 +groups: + .word 3003 @ AID_INET + .word 3004 @ AID_NET_RAW + +shell_path: + .asciz "/bin/sh" diff --git a/orbic_fw_c/rootshell.c b/orbic_fw_c/rootshell.c new file mode 100644 index 00000000..db639d61 --- /dev/null +++ b/orbic_fw_c/rootshell.c @@ -0,0 +1,47 @@ +/* + * rootshell — SUID root escalation shell for Orbic RCL400 + * + * Mirrors Rayhunter's rootshell (rootshell/src/main.rs). + * When this binary is owned by root and has the SUID bit set (chmod 4755), + * it escalates the caller (ADB shell uid=2000) to uid=0 and exec's /bin/sh. + * + * Usage: + * /bin/rootshell — interactive root shell + * /bin/rootshell -c "cmd" — run cmd as root + * + * Build (from orbic_fw_c/): + * export PATH="$PWD/../gcc_mac/bin:$PATH" + * arm-cortex_a8-linux-gnueabi-gcc -static -Os -nostdinc \ + * -o rootshell rootshell.c + */ + +/* Avoid broken sysroot headers — declare only what we need. + * Function implementations come from glibc (libc.a for static linking). */ +typedef unsigned int gid_t; +typedef unsigned int uid_t; + +extern int setgroups(int size, const gid_t *list); +extern int setgid(gid_t gid); +extern int setuid(uid_t uid); +extern int execv(const char *path, char *const argv[]); +extern long write(int fd, const void *buf, long count); +extern void _exit(int status); + +int main(int argc, char *argv[]) { + /* Android "paranoid networking" — supplementary groups for socket access */ + gid_t groups[] = { 3003, 3004 }; /* AID_INET, AID_NET_RAW */ + setgroups(2, groups); + + /* Escalate to root (works because binary is SUID root) */ + setgid(0); + setuid(0); + + /* Exec /bin/sh, passing through any arguments (e.g. -c "command") */ + argv[0] = "/bin/sh"; + execv("/bin/sh", argv); + + /* Only reached on error */ + write(2, "rootshell: execv failed\n", 24); + _exit(1); + return 1; +} From 756e6d119d40f6d77b49e30b279c9209b238f73c Mon Sep 17 00:00:00 2001 From: SimeonOnSecurity <4913771+simeononsecurity@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:52:05 -0500 Subject: [PATCH 7/7] feat: rootshell-based install flow + GitHub Actions + updated docs - deploy_usb.py v2.0: 8-step pipeline with rootshell SUID root gain - AT+SYSCMD installs rootshell (3 cmds: cp, chown, chmod 4755) - All privileged ops via 'adb shell /bin/rootshell -c' - Rayhunter compatible (detects existing rootshell, skips AT step) - Device reboot after install (boot script applies iptables) - .github/workflows/build-rootshell.yml: cross-compile on release - docs/deploy_usb_feature.md: rootshell details + compatibility matrix --- .github/workflows/build-rootshell.yml | 62 +++ deploy_usb.py | 730 ++++++++++++++------------ docs/deploy_usb_feature.md | 156 ++++-- 3 files changed, 583 insertions(+), 365 deletions(-) create mode 100644 .github/workflows/build-rootshell.yml diff --git a/.github/workflows/build-rootshell.yml b/.github/workflows/build-rootshell.yml new file mode 100644 index 00000000..39d00d98 --- /dev/null +++ b/.github/workflows/build-rootshell.yml @@ -0,0 +1,62 @@ +name: Build rootshell + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build: + name: Cross-compile rootshell (ARM) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install ARM cross-compiler + run: | + sudo apt-get update + sudo apt-get install -y binutils-arm-linux-gnueabi + + - name: Build rootshell from assembly + working-directory: orbic_fw_c + run: | + # Assemble — target ARMv7 (Cortex-A8), EABI5 + arm-linux-gnueabi-as -meabi=5 -o rootshell.o rootshell.S + # Link — static, no libc + arm-linux-gnueabi-ld -o rootshell rootshell.o + # Strip — minimize binary size + arm-linux-gnueabi-strip rootshell + # Verify + file rootshell + ls -la rootshell + + - name: Verify ELF properties + working-directory: orbic_fw_c + run: | + echo "=== File type ===" + file rootshell + echo "" + echo "=== ELF headers ===" + arm-linux-gnueabi-readelf -h rootshell + echo "" + echo "=== Size ===" + ls -la rootshell + echo "" + # Ensure it's a static ARM ELF + file rootshell | grep -q "ARM" || { echo "ERROR: not an ARM binary"; exit 1; } + file rootshell | grep -q "statically linked" || { echo "ERROR: not statically linked"; exit 1; } + echo "✓ rootshell is a valid static ARM ELF" + + - name: Upload rootshell artifact + uses: actions/upload-artifact@v4 + with: + name: rootshell-arm + path: orbic_fw_c/rootshell + + - name: Attach rootshell to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: orbic_fw_c/rootshell diff --git a/deploy_usb.py b/deploy_usb.py index 5757c41b..3a32a5f1 100644 --- a/deploy_usb.py +++ b/deploy_usb.py @@ -10,9 +10,23 @@ 2. Sends a USB vendor control request to switch it into ADB/debug mode (PID 0xf601) 3. Device reboots and re-enumerates with ADB interface exposed 4. Pushes firmware files to /tmp/ via 'adb push' (ADB user can write /tmp) - 5. Uses AT+SYSCMD via USB serial (interface 1) to move files to /data/ and chmod as root - 6. Sets up boot persistence via USB composition hook (same hook as deploy_base64.py) - 7. Opens firewall and starts orbic_app + 5. Installs a SUID rootshell binary via AT+SYSCMD (3 commands: cp, chown, chmod) + 6. Uses 'adb shell /bin/rootshell -c "cmd"' for all remaining privileged ops + 7. Sets up boot persistence via USB composition hook + 8. Reboots device — boot script applies iptables and starts orbic_app + +Root Gain Strategy (mirrors Rayhunter setup_rootshell()): + Fresh Orbic devices have ADB shell as uid=2000 (NOT root). + AT+SYSCMD runs via atfwd_daemon which IS root — used only for the 3-command + rootshell install (cp, chown root, chmod 4755). After that, all privileged + operations go through 'adb shell /bin/rootshell -c "command"'. + + If Rayhunter was previously installed, /bin/rootshell already exists and the + AT+SYSCMD step is skipped entirely. + + NOTE: iptables via rootshell FAILS (SUID doesn't grant CAP_NET_ADMIN). + Firewall rules are applied by dagshell_boot.sh which runs from init context + (has full capabilities) on every boot. USB IDs (from Rayhunter installer/src/orbic.rs): Vendor: 0x05c6 (Qualcomm) @@ -78,8 +92,7 @@ # Settle time (seconds) to let the device fully re-enumerate and bring up # atfwd_daemon after a USB mode switch / reboot before we hammer it with -# AT+SYSCMD commands. Skipping this is what caused "Operation timed out" / -# "No such device" failures on the first post-switch run. +# AT+SYSCMD commands. USB_STABILIZE_SEC = 8 @@ -89,7 +102,6 @@ class ATDeviceLost(Exception): pass - # ============================================================================= # File paths # ============================================================================= @@ -98,20 +110,32 @@ class ATDeviceLost(Exception): FIRMWARE_DIR = SCRIPT_DIR / "orbic_fw_c" FIRMWARE_FILE = "orbic_app" BOOT_SCRIPT_FILE = "dagshell_boot.sh" +ROOTSHELL_FILE = "rootshell" FIRMWARE_PATH = FIRMWARE_DIR / FIRMWARE_FILE BOOT_SCRIPT_PATH = SCRIPT_DIR / BOOT_SCRIPT_FILE +ROOTSHELL_PATH = FIRMWARE_DIR / ROOTSHELL_FILE # Remote locations on device -REMOTE_TMP_APP = "/tmp/orbic_app" -REMOTE_FILE = "/data/orbic_app" -REMOTE_TMP_BOOT = "/tmp/dagshell_boot.sh" -REMOTE_BOOT = "/data/dagshell_boot.sh" +REMOTE_TMP_APP = "/tmp/orbic_app" +REMOTE_FILE = "/data/orbic_app" +REMOTE_TMP_BOOT = "/tmp/dagshell_boot.sh" +REMOTE_BOOT = "/data/dagshell_boot.sh" +REMOTE_TMP_ROOTSHELL = "/tmp/rootshell" +REMOTE_ROOTSHELL = "/bin/rootshell" # USB persistence hook (same method used by deploy_base64.py) USB_WRAPPER_PATH = "/data/usb/boot_hsusb_composition" USB_ORIGINAL = "/sbin/usb/compositions/PRJ_SLT779_9025" +# Boot hook wrapper content — written as a file push rather than echo chains +# to avoid AT+SYSCMD quoting issues +BOOT_HOOK_CONTENT = f"""#!/bin/sh +# DagShell USB boot wrapper +sh {REMOTE_BOOT} & +{USB_ORIGINAL} "$@" +""" + # ============================================================================= # Prerequisites @@ -187,6 +211,19 @@ def adb_shell(cmd: str, check: bool = True) -> str: return adb(["shell", cmd], check=check, timeout=30) +def rootshell_cmd(command: str, check: bool = False) -> str: + """ + Run a privileged command via /bin/rootshell -c on the device. + This is the primary way to execute root operations after rootshell is + installed — mirrors Rayhunter's AdbConnection which routes all commands + through rootshell. + """ + # Shell-escape: wrap command in single quotes for rootshell -c, + # but the whole thing is passed through adb shell which also interprets. + # Safest: pass as a single argument to rootshell. + return adb_shell(f'/bin/rootshell -c "{command}"', check=check) + + # ============================================================================= # USB mode switch (mirrors Rayhunter enable_command_mode()) # ============================================================================= @@ -247,15 +284,13 @@ def switch_to_adb_mode() -> bool: print(" Sending mode-switch vendor control request…") try: - # Set configuration so we can issue control requests try: dev.set_configuration() except Exception: pass - # Send the vendor control request (same semantics as Rayhunter nusb call) dev.ctrl_transfer( - bmRequestType=0x40, # Host→Device | Vendor | Device + bmRequestType=0x40, bRequest=0xa0, wValue=0, wIndex=0, @@ -266,8 +301,6 @@ def switch_to_adb_mode() -> bool: return True except usb.core.USBError as exc: - # A pipe/stall/timeout error is expected: the device reboots during the - # transfer, which Rayhunter also silently ignores. err_lower = str(exc).lower() if any(kw in err_lower for kw in ("pipe", "stall", "timeout", "no data")): print(" Mode-switch sent (device is rebooting)…") @@ -299,9 +332,6 @@ def open_at_interface(): if not HAS_PYUSB: return None - # Kill the ADB daemon so it releases its hold on the USB device. - # This is necessary because adbd claims the ADB interface (0) and keeps - # the device open, preventing pyusb from claiming the serial interface (1). print(" Stopping ADB daemon to release USB device…") try: subprocess.run(["adb", "kill-server"], capture_output=True, timeout=8) @@ -314,15 +344,11 @@ def open_at_interface(): print(" [!] Orbic ADB device not found after killing adb server.") return None - # Set USB configuration (required before claiming interfaces) try: dev.set_configuration() except Exception: pass - # Detach the kernel CDC ACM / serial driver from interface 1. - # On Linux this usually works. On macOS it requires root or an entitlement; - # if it fails the claim attempt below will surface a clearer error. try: if dev.is_kernel_driver_active(SERIAL_INTERFACE): dev.detach_kernel_driver(SERIAL_INTERFACE) @@ -339,7 +365,6 @@ def open_at_interface(): print(" On macOS/Linux try: sudo python3 deploy_usb.py") elif "busy" in err or "resource" in err: print(" Serial interface is busy. Try unplugging and replugging the device.") - # Restart ADB so fallback can still use adb shell try: subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) except Exception: @@ -363,8 +388,6 @@ def at_syscmd_raw(dev, command: str, timeout_ms: int = 3000) -> bool: payload = f"\r\nAT+SYSCMD={command}\r\n".encode() try: - # Step 1 — enable serial port (SET_CONTROL_LINE_STATE, RTS+DTR = 3) - # bmRequestType = 0x21 = Host→Device | Class | Interface dev.ctrl_transfer( bmRequestType=0x21, bRequest=0x22, @@ -374,28 +397,21 @@ def at_syscmd_raw(dev, command: str, timeout_ms: int = 3000) -> bool: timeout=timeout_ms, ) - # Step 2 — send the AT command dev.write(SERIAL_OUT_EP, payload, timeout=timeout_ms) - # Step 3 — consume the echoed command try: dev.read(SERIAL_IN_EP, 256, timeout=timeout_ms) except usb.core.USBTimeoutError: pass - # Step 4 — read the actual response try: raw = bytes(dev.read(SERIAL_IN_EP, 256, timeout=timeout_ms)) resp = raw.decode("utf-8", errors="replace") return "\r\nOK\r\n" in resp except usb.core.USBTimeoutError: - # Some commands (background processes) don't return before timeout — treat as OK return True except usb.core.USBError as exc: - # errno 19 ("No such device") means the device handle is stale — the - # device re-enumerated or dropped off the bus. Retrying is pointless; - # signal the caller to fall back to the ADB shell path. err_lower = str(exc).lower() if getattr(exc, "errno", None) == 19 or "no such device" in err_lower: raise ATDeviceLost(str(exc)) @@ -411,7 +427,7 @@ def at_cmd(dev, command: str, retries: int = 3) -> bool: """ print(f" AT+SYSCMD: {command}") for attempt in range(retries): - if at_syscmd_raw(dev, command): # may raise ATDeviceLost + if at_syscmd_raw(dev, command): return True if attempt < retries - 1: time.sleep(1) @@ -423,12 +439,6 @@ def at_health_check(dev, attempts: int = 6, delay: float = 2.0) -> bool: """ Confirm the AT+SYSCMD interface is actually responsive before running the bulk of privileged commands. - - Right after a USB mode switch / reboot the device may accept the interface - claim but still be settling — commands then time out or the handle goes - stale ("No such device"). We send a cheap no-op (`true`) and wait for the - atfwd_daemon to answer, retrying with pauses. Returns False if the device - never responds or drops off the bus. """ print(f" Health check: probing AT interface ({attempts} attempts)…") for i in range(1, attempts + 1): @@ -445,42 +455,246 @@ def at_health_check(dev, attempts: int = 6, delay: float = 2.0) -> bool: return False - def release_at_interface(dev) -> None: - """Release the claimed serial interface.""" + """Release the claimed serial interface and restart ADB server.""" if dev is None: return try: usb.util.release_interface(dev, SERIAL_INTERFACE) except Exception: pass + # Always restart ADB server after releasing the AT interface + try: + subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) + except Exception: + pass + + +# ============================================================================= +# Rootshell install (mirrors Rayhunter setup_rootshell() in orbic.rs) +# ============================================================================= + +def check_rootshell_exists() -> bool: + """ + Check if /bin/rootshell is already installed and functional on the device. + Returns True if rootshell exists and grants uid=0. + + If Rayhunter was previously installed, rootshell will already be at + /bin/rootshell with correct SUID permissions — no reinstall needed. + """ + try: + out = adb_shell('/bin/rootshell -c "id"', check=False).strip() + if "uid=0" in out: + return True + except Exception: + pass + return False + + +def install_rootshell_via_at(at_dev) -> bool: + """ + Install the rootshell SUID binary using AT+SYSCMD (3 commands). + + Mirrors Rayhunter setup_rootshell() (installer/src/orbic.rs): + 1. Push rootshell to /tmp/rootshell via ADB + 2. AT+SYSCMD: cp /tmp/rootshell /bin/rootshell + 3. AT+SYSCMD: chown root:root /bin/rootshell + 4. AT+SYSCMD: chmod 4755 /bin/rootshell + 5. Verify: adb shell /bin/rootshell -c id → uid=0 + + AT+SYSCMD runs via atfwd_daemon (PID 1 child, uid=0) which has the + authority to chown/chmod files owned by any user. + + Returns True if rootshell is installed and verified, False otherwise. + May raise ATDeviceLost if the device drops mid-install. + """ + print(" Installing rootshell via AT+SYSCMD (3 commands)…") + + # The rootshell binary was already pushed to /tmp/rootshell via ADB + # in the file push step. Now use AT+SYSCMD to install it with root perms. + if not at_cmd(at_dev, f"cp {REMOTE_TMP_ROOTSHELL} {REMOTE_ROOTSHELL}"): + print(" [!] Failed to copy rootshell to /bin/") + return False + + if not at_cmd(at_dev, f"chown root:root {REMOTE_ROOTSHELL}"): + print(" [!] Failed to chown rootshell") + return False + + if not at_cmd(at_dev, f"chmod 4755 {REMOTE_ROOTSHELL}"): + print(" [!] Failed to chmod rootshell") + return False + + print(" rootshell installed. Releasing AT interface for verification…") + + # Release AT interface and restart ADB to verify rootshell + release_at_interface(at_dev) + time.sleep(2) + + if not wait_for_adb(timeout_sec=30): + print(" [!] ADB did not reconnect after rootshell install.") + return False + + # Verify rootshell grants uid=0 + out = adb_shell('/bin/rootshell -c "id"', check=False).strip() + if "uid=0" in out: + print(f" [✓] rootshell verified: {out}") + return True + else: + print(f" [✗] rootshell verification failed: {out}") + return False # ============================================================================= -# Boot persistence (mirrors deploy_base64.py setup_autostart) +# Install via rootshell (primary path for all privileged operations) # ============================================================================= -def setup_autostart(at_dev) -> None: +def install_via_rootshell(has_ssl: bool, ssl_root: Path) -> None: """ - Hook DagShell into the USB composition init script so it starts on every boot. - The script /data/usb/boot_hsusb_composition is executed by the USB init daemon - on MDM9207. We replace it with a wrapper that runs dagshell_boot.sh first, - then chains to the original composition script. + Install DagShell using /bin/rootshell for all privileged operations. + This is the primary install path — works on both fresh devices (after + AT+SYSCMD rootshell install) and Rayhunter-first devices (rootshell + already exists). + + Mirrors Rayhunter's setup_rayhunter() which routes all commands through + its AdbConnection (which uses rootshell -c internally). + + NOTE: iptables via rootshell FAILS with "Permission denied, you must be + root" because SUID doesn't grant CAP_NET_ADMIN capability. Firewall rules + are applied by dagshell_boot.sh which runs from init context on boot. """ + print() + print(" Installing files via rootshell…") + + # Move files from /tmp to /data + for src, dst, label in [ + (REMOTE_TMP_APP, REMOTE_FILE, "firmware"), + (REMOTE_TMP_BOOT, REMOTE_BOOT, "boot script"), + ]: + print(f" rootshell: cp {src} → {dst} ({label})") + rootshell_cmd(f"cp {src} {dst}") + + # Set permissions + print(" rootshell: setting permissions…") + rootshell_cmd(f"chmod 755 {REMOTE_FILE}") + rootshell_cmd(f"chmod 755 {REMOTE_BOOT}") + + # Install SSL certificates + if has_ssl: + print(" rootshell: installing SSL certificates…") + rootshell_cmd("cp /tmp/server.der /data/server.der") + rootshell_cmd("cp /tmp/server.key.der /data/server.key.der") + rootshell_cmd("chmod 600 /data/server.key.der") + if ssl_root.exists(): + rootshell_cmd("cp /tmp/root.der /data/root.der") + + # Verify firmware landed + check_out = rootshell_cmd(f"ls -la {REMOTE_FILE}", check=False) + if "No such file" in check_out or not check_out.strip(): + print(f" [✗] {REMOTE_FILE} not found after copy!") + sys.exit(1) + print(f" [✓] Verified: {check_out.strip()}") + + # Install boot hook — push wrapper script to /tmp, then rootshell cp + print() print(" Setting up boot persistence (USB composition hook)…") - # Remove any stale dnsmasq references - at_cmd(at_dev, "sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf") + # Write boot hook content to a temp file locally, push via ADB, then cp + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as f: + f.write(BOOT_HOOK_CONTENT) + tmp_hook = f.name + + try: + adb_push(tmp_hook, "/tmp/boot_hook.sh") + finally: + Path(tmp_hook).unlink(missing_ok=True) + + # Clean stale dnsmasq references + rootshell_cmd("sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf") + + # Install the hook + rootshell_cmd(f"mkdir -p /data/usb") + rootshell_cmd(f"cp /tmp/boot_hook.sh {USB_WRAPPER_PATH}") + rootshell_cmd(f"chmod 755 {USB_WRAPPER_PATH}") + + # Verify hook + hook_check = rootshell_cmd(f"cat {USB_WRAPPER_PATH}", check=False) + if "dagshell_boot.sh" in hook_check: + print(" [✓] Boot hook installed and verified.") + else: + print(" [!] Boot hook may not have installed correctly.") + print(f" Content: {hook_check[:200]}") + + # NOTE: We do NOT apply iptables here — rootshell lacks CAP_NET_ADMIN. + # dagshell_boot.sh applies firewall rules from init context on boot. + print() + print(" [i] Firewall rules will be applied on next boot by dagshell_boot.sh") + print(" (rootshell lacks CAP_NET_ADMIN for iptables)") + + +# ============================================================================= +# Fallback: install via root ADB shell (Rayhunter-first devices) +# ============================================================================= + +def install_via_adb_root(has_ssl: bool, ssl_root: Path) -> None: + """ + Fallback install path when ADB shell is already root (uid=0). + This happens on Rayhunter-first devices or when the stock device + was already rooted. Uses direct adb shell commands. + """ + print(" [✓] ADB shell has root — using direct adb shell install.") + print() + print(" Moving files to /data/ via adb shell…") + for src, dst in [ + (REMOTE_TMP_APP, REMOTE_FILE), + (REMOTE_TMP_BOOT, REMOTE_BOOT), + ]: + print(f" cp {src} → {dst}") + adb_shell(f"cp {src} {dst}", check=False) + + print(" Setting permissions…") + adb_shell(f"chmod 755 {REMOTE_FILE}", check=False) + adb_shell(f"chmod 755 {REMOTE_BOOT}", check=False) + + if has_ssl: + print(" Installing SSL certificates…") + adb_shell("cp /tmp/server.der /data/server.der", check=False) + adb_shell("cp /tmp/server.key.der /data/server.key.der", check=False) + adb_shell("chmod 600 /data/server.key.der", check=False) + if ssl_root.exists(): + adb_shell("cp /tmp/root.der /data/root.der", check=False) - # Build the wrapper script in /data/usb/ - at_cmd(at_dev, f"rm -f {USB_WRAPPER_PATH}") - at_cmd(at_dev, f"echo '#!/bin/sh' > {USB_WRAPPER_PATH}") - at_cmd(at_dev, f"echo '# DagShell USB boot wrapper' >> {USB_WRAPPER_PATH}") - at_cmd(at_dev, f"echo 'sh {REMOTE_BOOT} &' >> {USB_WRAPPER_PATH}") - at_cmd(at_dev, f"echo '{USB_ORIGINAL} \"$@\"' >> {USB_WRAPPER_PATH}") - at_cmd(at_dev, f"chmod +x {USB_WRAPPER_PATH}") + # Verify firmware landed + check_out = adb_shell(f"ls -la {REMOTE_FILE} 2>&1", check=False) + if "No such file" in check_out or not check_out.strip(): + print(f" [✗] {REMOTE_FILE} not found after copy!") + sys.exit(1) + print(f" [✓] Verified: {check_out.strip()}") - print(" Boot hook installed.") + # Configure network (ADB root shell has full capabilities) + print() + print(" Configuring network (iptables / NAT)…") + adb_shell("iptables -I INPUT -p tcp --dport 8443 -j ACCEPT", check=False) + adb_shell("iptables -I INPUT -p tcp --dport 8080 -j ACCEPT", check=False) + adb_shell("iptables -t nat -F PREROUTING", check=False) + adb_shell("echo 1 > /proc/sys/net/ipv4/ip_forward", check=False) + adb_shell("iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE", check=False) + adb_shell("iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT", check=False) + adb_shell("iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state " + "--state RELATED,ESTABLISHED -j ACCEPT", check=False) + + # Boot persistence + print() + print(" Setting up boot persistence (USB composition hook)…") + adb_shell(f"sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf", check=False) + adb_shell(f"mkdir -p /data/usb", check=False) + adb_shell(f"rm -f {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '#!/bin/sh' > {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '# DagShell USB boot wrapper' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo 'sh {REMOTE_BOOT} &' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"echo '{USB_ORIGINAL} \"$@\"' >> {USB_WRAPPER_PATH}", check=False) + adb_shell(f"chmod +x {USB_WRAPPER_PATH}", check=False) + print(" [✓] Boot hook installed.") # ============================================================================= @@ -495,7 +709,6 @@ def verify_deployment() -> bool: adb_command(device, &["wget", "-O", "-", "http://localhost:8080/index.html"]) We do the same for DagShell on port 8443 (HTTPS) plus extra port / log checks. - All commands run on the device itself through the existing ADB session. """ print("\n" + "=" * 58) print(" Verifying deployment (via ADB — no WiFi needed)") @@ -513,13 +726,10 @@ def verify_deployment() -> bool: all_ok = False # ── Check 2: port 8443 listening ────────────────────────────────────────── - # dagshell_boot.sh has a `sleep 5` before launching orbic_app; give it time. print(" Waiting 10s for orbic_app to bind port 8443…", end="", flush=True) time.sleep(10) print(" done.") - # Try netstat first, then nc -z, then /proc/net/tcp{,6} - # 8443 decimal = 0x20FB hex port_open = False ns = adb_shell("netstat -tlnp 2>/dev/null | grep ':8443'", check=False).strip() @@ -528,14 +738,12 @@ def verify_deployment() -> bool: port_open = True if not port_open: - # nc -z does a TCP connect-only probe (no TLS) — fastest/most reliable nc_out = adb_shell("nc -z 127.0.0.1 8443 2>&1; echo rc=$?", check=False).strip() if "rc=0" in nc_out: print(" [✓] Port 8443 : REACHABLE (nc -z probe)") port_open = True if not port_open: - # /proc/net/tcp stores local ports in big-endian hex: 8443 = 20FB for tcp_file in ("/proc/net/tcp6", "/proc/net/tcp"): entry = adb_shell( f"grep -i ' 20FB' {tcp_file} 2>/dev/null | head -1", check=False @@ -546,24 +754,14 @@ def verify_deployment() -> bool: break if not port_open: - print(" [!] Port 8443 : not yet visible via netstat/nc/proc — " - "will confirm via TLS probe below") - - # ── Check 3: HTTP(S) response from localhost via adb shell ──────────────── - # This is the key check: hit the server directly on the device through ADB, - # exactly like Rayhunter's wget-to-localhost test — no WiFi connection needed. - # - # "Connection reset by peer" from busybox wget = SUCCESS: - # • The TCP connection WAS established (port is open) - # • The TLS handshake started but busybox's SSL implementation is - # incompatible with BearSSL cipher suites → reset after ClientHello - # • This is DIFFERENT from "Connection refused" (server not running) + print(" [!] Port 8443 : not yet visible — will confirm via TLS probe below") + + # ── Check 3: TLS probe ──────────────────────────────────────────────────── MAX_ATTEMPTS = 5 web_ok = False print(f" Probing https://127.0.0.1:8443/ via adb shell ({MAX_ATTEMPTS} attempts)…") for attempt in range(1, MAX_ATTEMPTS + 1): - # busybox wget: -q quiet, -O - stdout, --no-check-certificate skip TLS verify raw = adb_shell( "wget -q -O - --no-check-certificate https://127.0.0.1:8443/ 2>&1 | head -5", check=False, @@ -576,12 +774,10 @@ def verify_deployment() -> bool: web_ok = True break elif raw and any(kw in raw_lower for kw in ("ssl", "tls", "handshake", "certificate")): - # TLS handshake visible → server IS up, wget just can't finish it print(f" [✓] Web server: TLS handshake confirmed — server is up (attempt {attempt})") web_ok = True break elif raw and any(kw in raw_lower for kw in ("connection reset", "reset by peer", "peer reset")): - # TCP connect succeeded; TLS reset = BearSSL rejected busybox cipher → server IS up print(f" [✓] Web server: TCP+TLS confirmed (reset by peer = BearSSL active, " f"attempt {attempt})") web_ok = True @@ -590,20 +786,15 @@ def verify_deployment() -> bool: print(f" [!] Attempt {attempt}/{MAX_ATTEMPTS}: connection refused — " "server not listening yet") elif raw: - # Unknown output — log it but keep trying print(f" [?] Attempt {attempt}/{MAX_ATTEMPTS}: {raw[:120]}") if attempt < MAX_ATTEMPTS: time.sleep(3) - # If wget failed every time but nc says port is open → treat as OK - # (wget may simply not support --no-check-certificate on this busybox build) if not web_ok and port_open: - print(" [✓] Web server: port 8443 is open — " - "wget probe inconclusive but server IS running") + print(" [✓] Web server: port 8443 is open — server IS running") web_ok = True - # TLS response confirms the port IS open even if netstat/nc/proc missed it if web_ok and not port_open: print(" [✓] Port 8443 : confirmed reachable (TLS response received)") port_open = True @@ -615,7 +806,7 @@ def verify_deployment() -> bool: if not port_open: all_ok = False - # ── Check 4: show boot log ───────────────────────────────────────────────── + # ── Check 4: boot log ───────────────────────────────────────────────────── boot_log = adb_shell("tail -15 /data/boot_diag.log 2>/dev/null", check=False).strip() if boot_log: print() @@ -637,188 +828,6 @@ def verify_deployment() -> bool: return all_ok -# ============================================================================= -# Install paths (AT+SYSCMD primary, ADB shell fallback) -# ============================================================================= - -def install_via_adb_shell(has_ssl: bool, ssl_root: Path) -> None: - """ - Install DagShell using the root ADB shell (fallback when the AT serial - interface is unavailable or drops out). Stock Orbic RCL400 ADB shells run - as root, so /data/ writes and iptables succeed here. - """ - # The AT path may have killed the ADB server — make sure it's back. - print(" Waiting for ADB to be ready…") - if not wait_for_adb(timeout_sec=30): - print(" [✗] ADB did not reconnect. Run: adb start-server and retry.") - sys.exit(1) - - # Health check: confirm the ADB shell actually answers before we rely on it. - probe = adb_shell("echo dagshell_ready", check=False).strip() - if "dagshell_ready" not in probe: - print(f" [!] ADB shell health check inconclusive (got: {probe[:60]!r}) — continuing anyway.") - else: - print(" [✓] ADB shell responsive.") - - # Check whether the ADB shell is root — determines if /data/ is writable. - uid_line = adb_shell("id", check=False).split("\n")[0].strip() - is_root = "uid=0" in uid_line - print(f" ADB shell identity: {uid_line or '(unknown)'}") - if is_root: - print(" [✓] ADB shell has root — proceeding with adb shell install.") - else: - print(" [!] ADB shell is NOT root. Operations on /data/ may fail silently.") - print(" If files don't copy, retry with: sudo python3 deploy_usb.py") - - print() - print(" Moving files to /data/ via adb shell…") - for src, dst in [ - (REMOTE_TMP_APP, REMOTE_FILE), - (REMOTE_TMP_BOOT, REMOTE_BOOT), - ]: - print(f" mv {src} → {dst}") - adb_shell(f"mv {src} {dst}", check=False) - - print(" Setting permissions…") - adb_shell(f"chmod +x {REMOTE_FILE}", check=False) - adb_shell(f"chmod +x {REMOTE_BOOT}", check=False) - - if has_ssl: - print(" Installing SSL certificates…") - adb_shell("mv /tmp/server.der /data/server.der", check=False) - adb_shell("mv /tmp/server.key.der /data/server.key.der", check=False) - adb_shell("chmod 600 /data/server.key.der", check=False) - if ssl_root.exists(): - adb_shell("mv /tmp/root.der /data/root.der", check=False) - - # Verify the firmware actually landed - check_out = adb_shell(f"ls -la {REMOTE_FILE} 2>&1", check=False) - if "No such file" in check_out or not check_out.strip(): - print(f" [✗] {REMOTE_FILE} not found after copy — ADB shell likely lacks root.") - print(" Retry with: sudo python3 deploy_usb.py") - sys.exit(1) - print(f" [✓] Verified: {check_out.strip()}") - - # ── Configure network (ADB shell has root, so iptables works) ──────── - if is_root: - print() - print(" Configuring network (iptables / NAT)…") - adb_shell("iptables -I INPUT -p tcp --dport 8443 -j ACCEPT", check=False) - adb_shell("iptables -I INPUT -p tcp --dport 8080 -j ACCEPT", check=False) - adb_shell("iptables -t nat -F PREROUTING", check=False) - adb_shell("echo 1 > /proc/sys/net/ipv4/ip_forward", check=False) - adb_shell("iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE", check=False) - adb_shell("iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT", check=False) - adb_shell("iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False) - - # ── Boot persistence via adb shell (works because ADB is root) ─────── - if is_root: - print() - print(" Setting up boot persistence (USB composition hook)…") - adb_shell(f"sed -i '/dagshell_boot.sh/d' /data/dnsmasq.conf", check=False) - adb_shell(f"rm -f {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo '#!/bin/sh' > {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo '# DagShell USB boot wrapper' >> {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo 'sh {REMOTE_BOOT} &' >> {USB_WRAPPER_PATH}", check=False) - adb_shell(f"echo '{USB_ORIGINAL} \"$@\"' >> {USB_WRAPPER_PATH}", check=False) - adb_shell(f"chmod +x {USB_WRAPPER_PATH}", check=False) - print(" Boot hook installed.") - - # ── Start the app — survive ADB session disconnect ───────────────── - # - # Android cgroups kill the entire ADB shell process group when the - # connection drops — setsid/nohup alone are not enough. - # - # Strategy: - # 1. Run dagshell_boot.sh via setsid to set up iptables/NAT and start - # the netcat shell listener on port 24 (those may survive briefly). - # 2. Forward port 24 locally and send the orbic_app start command - # through that shell, which lives in init's cgroup rather than the - # ADB cgroup → survives after our ADB session ends. - # 3. Fall back to a direct setsid launch if nc isn't up yet. - print() - print(" Running dagshell_boot.sh (sets up iptables / NAT / nc listener)…") - adb_shell(f"setsid sh {REMOTE_BOOT} /dev/null 2>&1 &", check=False) - # Give the boot script time to start the nc listener (it has sleep 5 - # before launching orbic_app, but nc starts earlier). - time.sleep(8) - - # Try to launch orbic_app through the nc shell on port 24. - launched_via_nc = False - try: - print(" Forwarding port 24 (nc shell) to launch orbic_app from init context…") - subprocess.run( - ["adb", "forward", "tcp:12024", "tcp:24"], - capture_output=True, timeout=10, - ) - time.sleep(1) - import socket - with socket.create_connection(("127.0.0.1", 12024), timeout=5) as s: - cmd = ( - f"pkill -f orbic_app 2>/dev/null; sleep 1; " - f"{REMOTE_FILE} >/data/orbic_app.log 2>&1 &\n" - ) - s.sendall(cmd.encode()) - time.sleep(3) - launched_via_nc = True - print(" orbic_app start command sent via init-context nc shell.") - except Exception as exc: - print(f" [!] nc shell launch failed ({exc}) — falling back to setsid direct launch…") - adb_shell( - f"pkill -f orbic_app 2>/dev/null; sleep 1; " - f"setsid {REMOTE_FILE} /data/orbic_app.log 2>&1 &", - check=False, - ) - time.sleep(3) - - # Verify the process is alive - ps_out = adb_shell("pgrep -f orbic_app 2>/dev/null", check=False).strip() - if ps_out: - print(f" [✓] orbic_app running (PID {ps_out})") - else: - print(" [!] orbic_app did not start — check /data/boot_diag.log or /data/orbic_app.log") - - -def install_via_at(at_dev, has_ssl: bool, ssl_root: Path) -> None: - """ - Install DagShell via AT+SYSCMD over the USB serial interface (root via - atfwd_daemon). May raise ATDeviceLost if the device drops mid-install, in - which case the caller should fall back to install_via_adb_shell(). - """ - print(" [✓] AT interface open — running privileged commands via AT+SYSCMD") - print() - print(" Moving files to /data/ …") - at_cmd(at_dev, f"mv {REMOTE_TMP_APP} {REMOTE_FILE}") - at_cmd(at_dev, f"mv {REMOTE_TMP_BOOT} {REMOTE_BOOT}") - at_cmd(at_dev, f"chmod +x {REMOTE_FILE}") - at_cmd(at_dev, f"chmod +x {REMOTE_BOOT}") - - if has_ssl: - print(" Installing SSL certificates…") - at_cmd(at_dev, "mv /tmp/server.der /data/server.der") - at_cmd(at_dev, "mv /tmp/server.key.der /data/server.key.der") - at_cmd(at_dev, "chmod 600 /data/server.key.der") - if ssl_root.exists(): - at_cmd(at_dev, "mv /tmp/root.der /data/root.der") - - print() - print(" Configuring network…") - at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8443 -j ACCEPT") - at_cmd(at_dev, "iptables -I INPUT -p tcp --dport 8080 -j ACCEPT") - at_cmd(at_dev, "iptables -t nat -F PREROUTING") - at_cmd(at_dev, "echo 1 > /proc/sys/net/ipv4/ip_forward") - at_cmd(at_dev, "iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE") - at_cmd(at_dev, "iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT") - at_cmd(at_dev, "iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT") - - print() - setup_autostart(at_dev) - - print() - print(" Starting orbic_app…") - at_cmd(at_dev, f"{REMOTE_FILE} &") - - # ============================================================================= # Main deployment flow # ============================================================================= @@ -827,12 +836,13 @@ def deploy() -> None: banner = "=" * 58 print(banner) - print(" DagShell USB Deployer") + print(" DagShell USB Deployer v2.0") print(" (Rayhunter orbic-usb method — USB cable, no WiFi needed)") + print(" Root gain: AT+SYSCMD → rootshell SUID → privileged ops") print(banner) # ── Step 1: prerequisites ───────────────────────────────────────────────── - print("\n[1/7] Checking prerequisites…") + print("\n[1/8] Checking prerequisites…") if not check_adb(): print(" [✗] 'adb' not found in PATH. Install Android Debug Bridge:") @@ -860,8 +870,18 @@ def deploy() -> None: sys.exit(1) print(f" [✓] Boot script: {BOOT_SCRIPT_PATH.name}") + if not ROOTSHELL_PATH.exists(): + print(f" [✗] rootshell binary not found: {ROOTSHELL_PATH}") + print(" Build it: cd orbic_fw_c && " + "arm-cortex_a8-linux-gnueabi-as -meabi=5 -o rootshell.o rootshell.S && " + "arm-cortex_a8-linux-gnueabi-ld -o rootshell rootshell.o && " + "arm-cortex_a8-linux-gnueabi-strip rootshell") + sys.exit(1) + rs_size = ROOTSHELL_PATH.stat().st_size + print(f" [✓] rootshell: {ROOTSHELL_PATH.name} ({rs_size} bytes)") + # ── Step 2: detect device ───────────────────────────────────────────────── - print("\n[2/7] Detecting Orbic device…") + print("\n[2/8] Detecting Orbic device…") if HAS_PYUSB: dev_obj, cur_pid = find_orbic() @@ -880,7 +900,7 @@ def deploy() -> None: print(" (pyusb unavailable — skipping USB detection)") # ── Step 3: mode switch ─────────────────────────────────────────────────── - print("\n[3/7] Switching to ADB mode…") + print("\n[3/8] Switching to ADB mode…") need_reboot = False if HAS_PYUSB and cur_pid != PRODUCT_ID_ADB: @@ -900,7 +920,7 @@ def deploy() -> None: time.sleep(wait_secs) # ── Step 4: wait for ADB ────────────────────────────────────────────────── - print("\n[4/7] Waiting for ADB device…") + print("\n[4/8] Waiting for ADB device…") if not wait_for_adb(timeout_sec=90): print(" [✗] ADB device not detected after 90 seconds.") print(" Troubleshooting:\n" @@ -910,21 +930,19 @@ def deploy() -> None: sys.exit(1) # ── Step 5: prepare device ──────────────────────────────────────────────── - print("\n[5/7] Preparing device…") + print("\n[5/8] Preparing device…") print(" Stopping any running orbic_app…") adb_shell("pkill -f orbic_app 2>/dev/null; true", check=False) print(" Ensuring /tmp is writable…") adb_shell("mkdir -p /tmp", check=False) time.sleep(1) - # ── Step 6: push files via adb ──────────────────────────────────────────── - print("\n[6/7] Pushing files to device via ADB…") + # ── Step 6: push files via ADB ──────────────────────────────────────────── + print("\n[6/8] Pushing files to device via ADB…") - # Push firmware to /tmp/ — ADB shell user can always write here adb_push(str(FIRMWARE_PATH), REMOTE_TMP_APP) - - # Push boot script adb_push(str(BOOT_SCRIPT_PATH), REMOTE_TMP_BOOT) + adb_push(str(ROOTSHELL_PATH), REMOTE_TMP_ROOTSHELL) # Push SSL certs if they exist ssl_cert = FIRMWARE_DIR / "server.der" @@ -944,51 +962,113 @@ def deploy() -> None: print(" All files pushed to /tmp/") - # ── Step 7: install via AT+SYSCMD ───────────────────────────────────────── - print("\n[7/7] Installing — AT+SYSCMD root operations…") - - # Open the USB serial interface to talk to atfwd_daemon (runs as root) - print(" Opening USB AT command interface (interface 1)…") - at_dev = open_at_interface() - - # After a USB mode switch + reboot the device often needs extra time before - # atfwd_daemon will answer. Pause, then health-check the AT interface so we - # don't fire a wall of commands at a half-booted device (which produced the - # "Operation timed out" / "No such device" failures previously). - if at_dev is not None: - if need_reboot: - print(f" Letting device settle for {USB_STABILIZE_SEC}s after reboot…") - time.sleep(USB_STABILIZE_SEC) - if not at_health_check(at_dev): - print(" [!] AT interface not responsive — switching to ADB shell fallback.") - release_at_interface(at_dev) - try: - subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) - except Exception: - pass - at_dev = None - - if at_dev is None: - # ─── Fallback: ADB shell (no usable AT interface) ───────────────────── - print(" [!] Using ADB shell install path.") - install_via_adb_shell(has_ssl, ssl_root) + # ── Step 7: install rootshell ───────────────────────────────────────────── + print("\n[7/8] Installing rootshell (SUID root escalation)…") + + # Check if rootshell is already installed (Rayhunter-first device) + rootshell_ready = check_rootshell_exists() + + if rootshell_ready: + print(" [✓] rootshell already installed and functional (Rayhunter compatible)") + print(" Skipping AT+SYSCMD rootshell install.") else: - # ─── Primary: AT+SYSCMD, with automatic ADB fallback on mid-run drop ── - try: - install_via_at(at_dev, has_ssl, ssl_root) - except ATDeviceLost as exc: - print(f" [!] AT interface dropped mid-install ({exc}).") - print(" Restarting ADB and finishing via ADB shell fallback…") - release_at_interface(at_dev) - at_dev = None - try: - subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) - except Exception: - pass - install_via_adb_shell(has_ssl, ssl_root) - finally: + print(" rootshell not found — installing via AT+SYSCMD…") + + # Also check if ADB shell is already root (no rootshell needed) + uid_line = adb_shell("id", check=False).split("\n")[0].strip() + is_adb_root = "uid=0" in uid_line + print(f" ADB shell identity: {uid_line or '(unknown)'}") + + if is_adb_root: + print(" [✓] ADB shell is root — installing rootshell directly.") + adb_shell(f"cp {REMOTE_TMP_ROOTSHELL} {REMOTE_ROOTSHELL}", check=False) + adb_shell(f"chown root:root {REMOTE_ROOTSHELL}", check=False) + adb_shell(f"chmod 4755 {REMOTE_ROOTSHELL}", check=False) + rootshell_ready = check_rootshell_exists() + if rootshell_ready: + print(" [✓] rootshell installed via root ADB shell.") + else: + print(" [!] rootshell install via ADB failed — will use direct root ADB path.") + else: + # Fresh device, non-root ADB: must use AT+SYSCMD + print(" ADB shell is NOT root — using AT+SYSCMD for rootshell install.") + print(" Opening USB AT command interface (interface 1)…") + at_dev = open_at_interface() + if at_dev is not None: - release_at_interface(at_dev) + if need_reboot: + print(f" Letting device settle for {USB_STABILIZE_SEC}s after reboot…") + time.sleep(USB_STABILIZE_SEC) + + if at_health_check(at_dev): + try: + rootshell_ready = install_rootshell_via_at(at_dev) + at_dev = None # released inside install_rootshell_via_at + except ATDeviceLost as exc: + print(f" [!] AT interface dropped during rootshell install ({exc}).") + release_at_interface(at_dev) + at_dev = None + else: + print(" [!] AT interface not responsive.") + release_at_interface(at_dev) + at_dev = None + else: + print(" [!] Could not open AT interface.") + + if not rootshell_ready: + print() + print(" [✗] FATAL: Cannot gain root on this device.") + print(" rootshell install via AT+SYSCMD failed and ADB shell is not root.") + print() + print(" Troubleshooting:") + print(" • Ensure the USB cable supports data (not charge-only)") + print(" • Try: sudo python3 deploy_usb.py (for USB permissions)") + print(" • Install Rayhunter first (provides rootshell)") + sys.exit(1) + + # ── Step 8: install DagShell ────────────────────────────────────────────── + print("\n[8/8] Installing DagShell…") + + # Ensure ADB is ready + if not wait_for_adb(timeout_sec=30): + print(" [✗] ADB did not reconnect.") + sys.exit(1) + + # Check which install path to use + uid_line = adb_shell("id", check=False).split("\n")[0].strip() + is_adb_root = "uid=0" in uid_line + + if rootshell_ready: + # Primary path: rootshell for all privileged ops + install_via_rootshell(has_ssl, ssl_root) + elif is_adb_root: + # Fallback: ADB shell is root (Rayhunter-first device without rootshell) + install_via_adb_root(has_ssl, ssl_root) + else: + # Should never reach here — caught in step 7 + print(" [✗] No root access available.") + sys.exit(1) + + # ── Reboot device to activate boot hook ─────────────────────────────────── + print() + print(" Rebooting device to activate boot hook…") + print(" (dagshell_boot.sh will apply iptables + start orbic_app on boot)") + adb_shell("reboot", check=False) + + # Wait for reboot cycle + print(" Waiting 30s for reboot…", end="", flush=True) + time.sleep(30) + print(" done.") + + # Reconnect ADB after reboot + if not wait_for_adb(timeout_sec=90): + print(" [!] ADB did not reconnect after reboot.") + print(" The device may need more time. Try:") + print(" python3 deploy_usb.py --verify-only") + else: + # Wait for boot script to finish (has sleep 5 + startup time) + print(" Waiting 15s for dagshell_boot.sh to complete…") + time.sleep(15) # ── Done ────────────────────────────────────────────────────────────────── print() @@ -997,6 +1077,7 @@ def deploy() -> None: print(banner) print(f" Firmware : {REMOTE_FILE}") print(f" Boot hook : {REMOTE_BOOT} → {USB_WRAPPER_PATH}") + print(f" rootshell : {REMOTE_ROOTSHELL} (SUID root)") if has_ssl: print(" Web UI : https://192.168.1.1:8443/") print(" (Connect to device WiFi, then open the URL in your browser.)") @@ -1006,7 +1087,12 @@ def deploy() -> None: print(banner) # ── Verify ──────────────────────────────────────────────────────────────── - verify_deployment() + if adb_devices(): + verify_deployment() + else: + print() + print(" [!] ADB not available for verification.") + print(" Once device boots, run: python3 deploy_usb.py --verify-only") if __name__ == "__main__": diff --git a/docs/deploy_usb_feature.md b/docs/deploy_usb_feature.md index 7bbe78ce..41012a61 100644 --- a/docs/deploy_usb_feature.md +++ b/docs/deploy_usb_feature.md @@ -2,7 +2,7 @@ ## Overview -`deploy_usb.py` is a new one-command installer that deploys DagShell firmware to the Orbic RCL400 entirely over a USB cable — no existing WiFi connection or network access to the device required. It mirrors the approach used by the [Rayhunter](https://github.com/EFForg/rayhunter) project's `orbic-usb` installer and extends it with DagShell-specific setup, TLS certificate management, firewall configuration, and post-deploy verification. +`deploy_usb.py` is a one-command installer that deploys DagShell firmware to the Orbic RCL400 entirely over a USB cable — no existing WiFi connection or network access to the device required. It mirrors the approach used by the [Rayhunter](https://github.com/EFForg/rayhunter) project's `orbic-usb` installer and extends it with DagShell-specific setup, TLS certificate management, and post-deploy verification. --- @@ -10,69 +10,127 @@ Previous DagShell installers (`deploy_base64.py`, `deploy_net.py`) required the user to already be connected to the Orbic's WiFi hotspot or have the device in a known network state. This created a chicken-and-egg problem for fresh devices, brick-recovery scenarios, or cases where the WiFi interface was misconfigured. -`deploy_usb.py` eliminates that dependency: if you can plug in a USB cable and run `python3 deploy_usb.py`, you can get DagShell running. +`deploy_usb.py` eliminates that dependency: if you can plug in a USB cable and run `python3 deploy_usb.py`, you can get DagShell running — even on a **completely fresh device** that has never been rooted. --- -## What It Does +## Root Gain Strategy (`rootshell`) -### 1. USB Mode Switch (mirrors Rayhunter `enable_command_mode()`) -Detects the Orbic on USB via PyUSB. If the device is in normal RNDIS hotspot mode (`PID 0xf626`) it sends the vendor control request (`bmRequestType=0x40`, `bRequest=0xa0`) that reboots the device into ADB/debug mode (`PID 0xf601`). This is the identical mechanism Rayhunter uses. +### The Problem +Fresh Orbic RCL400 devices have an ADB shell that runs as `uid=2000` (shell user), **NOT root**. This means operations like writing to `/data/`, `chmod`, `chown`, and `iptables` all fail silently or with permission errors. -### 2. File Deployment via ADB -Once in ADB mode, all firmware files are pushed to `/tmp/` (always writable by the ADB shell user), then moved to `/data/` with correct permissions via either: -- **AT+SYSCMD** (preferred) — runs as root via the device's `atfwd_daemon` system process, matching Rayhunter's `adb_serial_cmd` approach. -- **ADB shell fallback** — used automatically if the USB serial interface can't be claimed (e.g., macOS permissions). Requires ADB shell to have root (which it does on stock Orbic RCL400). +### The Solution: SUID rootshell (mirrors Rayhunter `setup_rootshell()`) +DagShell includes a tiny (408-byte) SUID root binary called `rootshell` — pure ARM assembly with zero libc dependencies. When owned by root with the SUID bit set (`chmod 4755`), it escalates `uid=2000` → `uid=0` and executes `/bin/sh`. -Files deployed: -| Local | Device | -|---|---| -| `orbic_fw_c/orbic_app` | `/data/orbic_app` | -| `dagshell_boot.sh` | `/data/dagshell_boot.sh` | -| `orbic_fw_c/server.der` | `/data/server.der` | -| `orbic_fw_c/server.key.der` | `/data/server.key.der` | -| `orbic_fw_c/root.der` | `/data/root.der` | - -### 3. Firewall & NAT Configuration -Applies iptables rules immediately (not just on next boot): +**How rootshell is installed (3 AT+SYSCMD commands):** +1. `adb push orbic_fw_c/rootshell /tmp/rootshell` — ADB user can always write to `/tmp` +2. `AT+SYSCMD=cp /tmp/rootshell /bin/rootshell` — atfwd_daemon runs as root +3. `AT+SYSCMD=chown root:root /bin/rootshell` — set ownership +4. `AT+SYSCMD=chmod 4755 /bin/rootshell` — set SUID bit + +After installation, **all remaining privileged operations** use: ```sh -iptables -I INPUT -p tcp --dport 8443 -j ACCEPT # DagShell HTTPS UI -iptables -I INPUT -p tcp --dport 8080 -j ACCEPT # Rayhunter / HTTP probe -iptables -t nat -A POSTROUTING -o rmnet_data0 -j MASQUERADE -iptables -A FORWARD -i bridge0 -o rmnet_data0 -j ACCEPT -iptables -A FORWARD -i rmnet_data0 -o bridge0 -m state --state RELATED,ESTABLISHED -j ACCEPT -echo 1 > /proc/sys/net/ipv4/ip_forward +adb shell /bin/rootshell -c "command" +``` + +This is the same approach Rayhunter uses — their `AdbConnection` routes all privileged commands through rootshell internally. + +### Rayhunter Compatibility +If Rayhunter was previously installed on the device, `/bin/rootshell` already exists with the correct SUID permissions. **DagShell detects this and skips the AT+SYSCMD install entirely** — no reinstall or conflict. + +### CAP_NET_ADMIN Limitation +SUID binaries do not inherit `CAP_NET_ADMIN`, so `iptables` commands via rootshell fail with "Permission denied, you must be root". Firewall rules are instead applied by `dagshell_boot.sh` which runs from the init context on boot (has full capabilities). + +### rootshell Source (`orbic_fw_c/rootshell.S`) +```asm +_start: + setgroups(2, [3003, 3004]) @ AID_INET, AID_NET_RAW + setgid(0) + setuid(0) + execve("/bin/sh", argv, envp) @ preserves -c "cmd" args +``` + +Build locally: +```sh +cd orbic_fw_c +arm-cortex_a8-linux-gnueabi-as -meabi=5 -o rootshell.o rootshell.S +arm-cortex_a8-linux-gnueabi-ld -o rootshell rootshell.o +arm-cortex_a8-linux-gnueabi-strip rootshell ``` -### 4. Boot Persistence (USB Composition Hook) -Installs a wrapper at `/data/usb/boot_hsusb_composition` that runs `dagshell_boot.sh` on every USB init cycle, then chains to the original composition script. This is the same persistence mechanism used by the existing `deploy_base64.py`. +A prebuilt binary is included in the repo. GitHub Actions automatically cross-compiles rootshell on every release (see `.github/workflows/build-rootshell.yml`). -### 5. Persistent Process Launch -Starting a daemon from an ADB shell is non-trivial on Android: the OS uses cgroups and kills all child processes of an ADB session when the connection drops — `nohup` and `setsid` alone are insufficient. +--- -`deploy_usb.py` solves this using a two-phase approach: -1. Run `dagshell_boot.sh` via `setsid` to bring up iptables and — crucially — the `busybox nc -ll -p 24 -e /bin/sh` listener. -2. Connect back to that netcat shell (forwarded via `adb forward tcp:12024 tcp:24`) and issue the `orbic_app` start command *from within that shell*. Because the nc listener was started from the USB init cgroup (not the ADB cgroup), its children survive the ADB disconnect. +## What It Does -If the nc shell is not yet available, the script falls back to a direct `setsid` launch with output logged to `/data/orbic_app.log`. +### 1. USB Mode Switch (mirrors Rayhunter `enable_command_mode()`) +Detects the Orbic on USB via PyUSB. If the device is in normal RNDIS hotspot mode (`PID 0xf626`) it sends the vendor control request (`bmRequestType=0x40`, `bRequest=0xa0`) that reboots the device into ADB/debug mode (`PID 0xf601`). This is the identical mechanism Rayhunter uses. -### 6. Post-Deploy Verification (mirrors Rayhunter `test_rayhunter()`) -After deployment, the script verifies DagShell is live entirely through ADB — no WiFi needed: +### 2. File Deployment via ADB +Once in ADB mode, all firmware files are pushed to `/tmp/` (always writable by the ADB shell user): + +| Local | Device (tmp) | Device (final) | +|---|---|---| +| `orbic_fw_c/orbic_app` | `/tmp/orbic_app` | `/data/orbic_app` | +| `dagshell_boot.sh` | `/tmp/dagshell_boot.sh` | `/data/dagshell_boot.sh` | +| `orbic_fw_c/rootshell` | `/tmp/rootshell` | `/bin/rootshell` | +| `orbic_fw_c/server.der` | `/tmp/server.der` | `/data/server.der` | +| `orbic_fw_c/server.key.der` | `/tmp/server.key.der` | `/data/server.key.der` | +| `orbic_fw_c/root.der` | `/tmp/root.der` | `/data/root.der` | + +### 3. rootshell Install (Step 7) +Uses AT+SYSCMD (only 3 commands) to install rootshell with SUID root permissions. After verification, all subsequent privileged operations go through `rootshell -c`. + +### 4. DagShell Install (Step 8) +Uses `rootshell -c` to: +- Copy files from `/tmp/` to `/data/` +- Set file permissions +- Install the boot hook wrapper at `/data/usb/boot_hsusb_composition` + +### 5. Boot Persistence (USB Composition Hook) +Installs a wrapper at `/data/usb/boot_hsusb_composition` that runs `dagshell_boot.sh` on every USB init cycle, then chains to the original composition script. Written as a file push + rootshell cp (avoids AT+SYSCMD quoting issues). + +### 6. Device Reboot +After installation, the device is rebooted to activate the boot hook. On boot, `dagshell_boot.sh`: +- Opens firewall ports 8443 and 8080 (from init context, has CAP_NET_ADMIN) +- Configures NAT/masquerade for internet passthrough +- Starts `orbic_app` with `