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.

The device

A quick teardown from the inside:

  • SoC: Qualcomm sdxlemur (SDX6x class), ARMv7 hard-float + NEON
  • OS: QTI reference Linux (qti-distro-nogplv3-debug), systemd + BusyBox, glibc 2.31, kernel 5.4.254
  • Storage: UBI on NAND — read-only rootfs//usr//etc, with separate writable volumes (/data, /cache, /etc_rw, /persist)
  • Firmware: TLS_AU_MU5120V1.0.0B12 (a carrier build)

Not Android, not OpenWrt — a Qualcomm reference Linux userland, which matters a lot later.

Getting root (the front door was unlocked)

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.

What I achieved

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.

The limitation: root isn't full control (until you touch flash)

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:

  • Read-only rootfs, enforced below root. /, /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.
  • systemd unit dirs are read-only. /run/systemd/system is writable but it's tmpfs — wiped every boot.
  • No boot service runs anything from a writable volume. I checked every ExecStart; nothing points at /data, /cache, /etc_rw, or /persist.
  • The vendor's own SSH hooks are gutted. There's an 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.
  • No config-based code execution. Shell injection through config fields doesn't work — POSIX shells don't re-scan variable expansions for operators, so a crafted DDNS/hostname value becomes a literal argument, not a command. No eval/backtick of config values anywhere in the boot path.
  • No cron at boot, and the "scheduled reboot" feature isn't cron-based — it's handled internally by a vendor daemon.
  • DEBUG mode doesn't persist. There's a 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.

The practical compromise — and how to re-arm

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.sh

The 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.)

Executing it after a reboot

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.sh

That 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.

Making it one command

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.

Takeaways

  • Root ≠ full control on a modern locked embedded device. A read-only rootfs enforced by the kernel is a real boundary that uid=0 alone doesn't cross.
  • The vendor's debug mode is the door — no exploit needed, which says something about threat models for carrier CPEs.
  • Static binaries are the great equalizer — zig cc static-musl for C, and Go's static binaries, sidestep the glibc-version trap entirely on ARM.
  • Persistence is the hard part, by design. The firmware is clearly built to resist exactly the thing an owner (or an attacker) would want: making something survive a reboot.

Everything here was done on hardware I own, for interoperability and to learn. Your mileage, firmware version, and brick risk will vary.