Hacking the Telstra-Branded ZTE Z50 Pro
I picked up a Telstra-branded ZTE 5G modem (retailed as the "Z50 Pro"; internally it reports as the ZTE MU5120) and wanted what the stock firmware won't give you: a real shell, SSH from my LAN, and remote management from anywhere. Here's how far root gets you on a device like this — and, more interestingly, where a locked-down carrier build draws the line even after you're root.
A quick teardown from the inside:
sdxlemur (SDX6x class), ARMv7 hard-float + NEONqti-distro-nogplv3-debug), systemd + BusyBox, glibc 2.31, kernel 5.4.254rootfs//usr//etc, with separate writable volumes (/data, /cache, /etc_rw, /persist)TLS_AU_MU5120V1.0.0B12 (a carrier build)Not Android, not OpenWrt — a Qualcomm reference Linux userland, which matters a lot later.
No exploit required. The stock web UI has an undocumented page, #mode_switch, that calls a goform endpoint (DEVICE_MODE_SET) to flip the USB composition into a DEBUG mode. That re-enumerates the USB device and exposes an ADB interface:
adb shell → uid=0(root)Full root, out of the box, via the vendor's own diagnostic feature. The same admin session also exposes hidden network-debug pages (band lock, cell lock, cell selection) that the normal UI hides.
The whole device is driven by the classic ZTE goform HTTP API — login is a SHA-256 challenge, writes carry an anti-tamper signature — so once you understand it you can script the modem completely: signal stats, band locking, APN, DHCP, reboot scheduling, SMS.
SSH over the LAN. There's no SSH server in this build, and the userland is glibc — so a random prebuilt binary won't run. I cross-compiled a static Dropbear for arm-linux-musleabihf with zig cc (static-musl means zero libc dependency), dropped it on a writable volume, generated host keys, and bound it to the LAN IP (never the cellular WAN).
Remote access with Tailscale. The official static ARM tailscaled is a pure-Go binary, so glibc is irrelevant. It joined my tailnet and gave me the modem from anywhere. I ran it first in userspace-networking mode (safe — no kernel routing changes), then switched it to kernel-TUN mode and turned the modem into a subnet router, exposing its LAN to the tailnet with full L3 routing (ICMP, throughput) — all verified not to disturb the modem's own cellular data path.
Reconfiguration via the API. Changed the LAN subnet, read live signal/band data, and disabled the vendor's scheduled nightly reboot — all through the goform API.
Here's the part worth the blog post. I had uid=0, and I still couldn't make my services start on boot. Every autostart mechanism was blocked:
/, /etc, /usr are UBI mounted read-only, and the kernel refuses remount,rw even for root. You can't drop a file where boot reads from./run/systemd/system is writable but it's tmpfs — wiped every boot.ExecStart; nothing points at /data, /cache, /etc_rw, or /persist.ssh_dropbear_enable flag and an ssh_dropbear.sh, but the /etc/init.d/dropbear it calls and the native binary are stripped from this build./systemrw is deliberately volatile. The firmware's boot script mounts it as tmpfs (the persistent UBI mount is commented out), so anything there vanishes on reboot.eval/backtick of config values anywhere in the boot path.debug_mode_is_persist nvram flag; setting it does nothing across a real reboot. The modem comes back in NORMAL mode, so ADB is gone until you re-enable DEBUG from the web UI again.The boot chain executes entirely from the read-only medium before your root shell exists. A root shell after boot can't inject into that sequence through the filesystem.
The only way to true boot persistence is going under the mount — writing the raw UBI/MTD flash (ubiupdatevol on the rootfs volume, editing the init script, repacking). Root can do that. But it means rewriting a firmware volume image, and a mistake or a power blip bricks your only internet uplink — with no recovery unless you have the stock firmware image and an EDL/firehose programmer for the SoC. I don't have the carrier firmware, so I drew the line there: the downside is an unrecoverable brick, not an inconvenience.
Since nothing auto-starts, I keep the launching logic in one script on a writable volume, /etc_rw/rearm.sh:
#!/bin/sh
# /etc_rw/rearm.sh — relaunch Tailscale + SSH after a reboot. Safe to run repeatedly.
LOG=/tmp/rearm.log; exec >>"$LOG" 2>&1
echo "[`date`] rearm invoked"
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1
# tailscaled, started via systemd-run so PID 1 owns it and it survives the adb session
if ! systemctl is-active --quiet ts-mu5120 2>/dev/null; then
rm -f /tmp/tailscaled.sock
systemctl reset-failed ts-mu5120 2>/dev/null
systemd-run --no-block --unit=ts-mu5120 /cache/tailscale/bin/tailscaled \
--statedir=/etc_rw/tailscale --socket=/tmp/tailscaled.sock
fi
# wait for the tailnet interface to come up, then start SSH bound to LAN + tailnet IP
i=0; while [ $i -lt 20 ]; do ip addr show tailscale0 2>/dev/null | grep -q "inet " && break; i=$((i+1)); sleep 1; done
sh /etc_rw/dropbear/start.shThe one subtlety that cost me an afternoon: on this device adbd SIGKILLs the whole session's process tree when the ADB connection closes. A daemon backgrounded with &, setsid, or even start-stop-daemon dies the moment you disconnect. The fix is systemd-run, which hands the process to PID 1 — completely detached from your shell. (Also: never pkill -f tailscaled from an ADB one-liner — the pattern matches the shell command running it and kills your own session.)
A reboot drops the modem back to NORMAL USB mode, so ADB is gone and you re-arm in two steps.
Step 1 — turn DEBUG/ADB back on. Either flip it in the web UI (open the admin page → #mode_switch → DEBUG → Apply), or script it against the goform API:
# pseudo — log in (SHA-256 challenge), then POST the mode switch with the anti-tamper sig:
# goformId=DEVICE_MODE_SET TypeCmd=DEBUG AD=<signature>The USB device re-enumerates (PID 19d2:1405 → 19d2:1404) and ADB reappears.
Step 2 — run the re-arm over ADB:
adb wait-for-device
adb shell sh /etc_rw/rearm.shThat brings Tailscale back online (it reconnects with the saved node key — no re-auth) and restarts SSH on the LAN and tailnet IPs. Everything persistent — the binaries on /cache, keys and Tailscale state on /etc_rw — survives the reboot; only the starting of them is manual.
To avoid remembering the steps, I wrap both into a single host-side helper that enables DEBUG via the API, waits for the device, then runs the re-arm:
#!/bin/sh
# modem-rearm — run on the workstation after the modem reboots
python3 enable_debug.py # goform DEVICE_MODE_SET TypeCmd=DEBUG (reads your admin pw from a 0600 file)
adb wait-for-device
adb shell sh /etc_rw/rearm.sh
adb shell '/cache/tailscale/bin/tailscale --socket=/tmp/tailscaled.sock status | head -1'So the full recovery after a power-cycle becomes: modem-rearm, wait a few seconds, done.
uid=0 alone doesn't cross.zig cc static-musl for C, and Go's static binaries, sidestep the glibc-version trap entirely on ARM.Everything here was done on hardware I own, for interoperability and to learn. Your mileage, firmware version, and brick risk will vary.