Part 2 Hacking the Telstra-Branded ZTE Z50 Pro
Part 1 ended with a rooted modem, working SSH and Tailscale, and an annoying limitation: everything needed manually starting after a reboot. I concluded that true persistence would require rewriting the read-only firmware.
That conclusion was too strong.
There was a boot hook. I hadn't found it.
Since then, the same Telstra-branded ZTE MU5120 has acquired automatic startup, a custom LCD dashboard, a small web interface, and a sing-box client that can proxy traffic for devices connected over Wi-Fi or LAN. All without rewriting a rootfs image or touching raw UBI/MTD volumes.
Getting the programs running was the easy part. Making routing, DNS, failure recovery, and reboot behaviour agree with each other took considerably more work.
The boot hook hiding behind the daemon
In Part 1, I checked the systemd units and found nothing launching scripts from writable storage. That observation was accurate, but incomplete: systemd wasn't the end of the boot path.
The vendor daemon, /usr/bin/zte_topsw_daemon, invokes:
/usr/zte/zte_conf/scripts/zte_daemon_start_3rd_apps.shThat script reads a configuration field called rcs_3rd_path and executes the file it names.
The field was empty. Pointing it at /etc_rw/rearm.sh supplied the missing connection between the stock boot sequence and writable storage.
The resulting startup path is:
Vendor daemon
→ third-party application script
→ rcs_3rd_path
→ /etc_rw/rearm.sh
→ custom systemd servicesThe launcher copies persistent unit templates from /etc_rw/modem-services into /run/systemd/system, reloads systemd, and starts the services. /run still disappears at reboot; the launcher recreates what belongs there.
PID 1 owns the processes, which also solves the ADB session problem from Part 1. Disconnecting the workstation no longer takes the services with it.
The read-only rootfs remains read-only. The mistake was assuming that every executable path through it had been accounted for.
DEBUG mode can come back too
The ineffective debug_mode_is_persist flag wasn't necessary.
A small ARM helper calls the same vendor library function used by the web interface:
zte_adapter_usb_set_usbmode_sync(1)It checks the current mode first and requests DEBUG when the modem is in NORMAL mode. A boot service runs it after a short delay.
An earlier reboot test with USB connected verified that ADB returned automatically. Later tests worked over Wi-Fi with USB disconnected, so recovery no longer depended on having a cable attached.
SSH and Tailscale start independently of that USB mode switch.
Giving the LCD something useful to say
With a non-Telstra SIM installed, the stock screen displays “Non Telstra SIM detected.”
I already knew.
I wanted the screen to show my email address, a Wi-Fi connection QR code, the Tailscale address, and data usage. That expanded slightly once the information was available: uptime, battery percentage, CPU/load, service status, and the selected proxy server.
The stock interface is a Qt application. The customization uses a shared library loaded into that application through LD_PRELOAD. A replacement launch script lives on writable storage and is bind-mounted over the stock UI launcher at boot.
The firmware file underneath is unchanged. The boot service recreates the mount after each restart, and the vendor watchdog uses the overridden launcher if it restarts the UI.
The screen now has the email address and menu at the top, system information above a centered Wi-Fi QR code, and network/proxy information below it. Data usage is condensed to:
since boot / this monthBoth values are accumulated GB; there is no invented monthly allowance.
The original UI remains accessible, with a Stats button at the bottom to return to the custom display. The QR code was also decoded from a framebuffer capture to check that it still worked after fitting everything onto the small screen.
A homepage the modem can actually afford
The web dashboard uses a static ARM build of lighttpd, plain HTML/CSS/JavaScript, and a small C collector.
It shows signal information, WAN/LAN/Tailscale addresses, connected clients, traffic totals, uptime, battery, CPU/load, and service status. The LCD reads a snapshot from the same collector, so the two displays share their measurements.
The web server and collector are small: an early measurement put their resident memory at roughly 456 KiB and 276 KiB respectively, excluding the other services.
One important detail: /proc/net/dev wasn't a reliable source for cellular usage on this device. Hardware-offloaded traffic could bypass the counters I expected to use. The collector therefore reads the vendor's cellular counters.
The dashboard also distinguishes Running from Auto Start enabled. Those answer different questions. A process can be running now and disappear after reboot; a configured service can also fail to start.
For these custom units, systemctl is-enabled alone doesn't describe persistence. The vendor hook and saved launcher are part of the answer.
Tailscale Serve worked—until I tried the phone
Tailscale Serve exposes the dashboard over HTTPS on port 8443. The stock administration service already occupies 443.
Remote access through the tailnet worked. Then a phone connected to the modem's own Wi-Fi resolved the same hostname to the correct Tailscale address and got:
Connection refusedDNS was doing its job. The connection was arriving through a different interface.
On this setup, the Serve listener was bound to Tailscale ingress. A packet reaching that address through the LAN bridge didn't reach an equivalent listener.
The fix was an additional HTTPS listener restricted to the LAN bridge, using the same hostname and certificate and forwarding to the same local web server. The original Serve listener continues handling tailnet connections.
The address now works from both places. Neither listener requires exposing the dashboard on the cellular WAN.
Putting the whole Wi-Fi network through sing-box
The next addition was sing-box, controlled from the homepage.
The panel supports multiple saved servers, VLESS imports, editing, server selection, start/stop, optional startup after reboot, and binary updates. Each saved server also has a QR Code button for sharing its configuration.
Those QR codes are generated locally in the browser. There is no external QR service receiving the proxy credentials.
The difficult part was making the modem act as the gateway I actually wanted:
Simply starting a TUN interface didn't achieve that. At one point the phone still appeared to connect directly because the modem's accelerated forwarding path was bypassing the intended handling.
While proxy routing is active, the implementation switches the relevant acceleration into software forwarding. Stopping the proxy restores it.
A running daemon was never sufficient evidence. Checking the public exit address from a connected client was.
IPv6 was an easy way around an IPv4 proxy
The intended policy became explicit: sing-box handles IPv4, and ordinary forwarded IPv6 internet traffic is blocked while proxy routing is active.
That does not disable IPv6 on the modem. Local and Tailscale traffic retain their exceptions.
When the proxy stops or fails its health check, the added restrictions are removed and native IPv4/IPv6 internet access returns.
This matters because a phone can happily use IPv6 while an IPv4 proxy is working perfectly. Without testing both, “the proxy is running” can be a misleading success message.
Tailscale on the router doesn't automatically mean Tailscale for its clients
The modem could reach the tailnet before its Wi-Fi clients could.
Making it a useful gateway required forwarding, source NAT for LAN traffic leaving through Tailscale, accepted subnet routes, and route priorities that kept directly connected LAN destinations local.
DNS needed separate attention.
I wanted Tailscale DNS to become the default while healthy, including access to MagicDNS and 100.100.100.100. But native Tailscale DNS management ran into the read-only /etc layout when trying to manage the resolver.
The workaround manages the existing writable resolver target in RAM. It saves the native upstreams, switches to Tailscale DNS after a successful health check, and restores native DNS when Tailscale becomes unavailable.
There is a subtle restart trap here: if Tailscale starts while the resolver already points at itself, it can learn the wrong upstream. A pre-start step restores the native resolver before the daemon starts.
The LAN/DNS helper runs independently of sing-box. Stopping the proxy shouldn't remove access to the tailnet.
Failure recovery is part of the feature
The chosen policy is fail-open: if the proxy fails, keep the internet working through the carrier connection.
That also means fallback traffic uses the normal connection. This is an availability choice, not a kill switch.
To test it, I froze the actual sing-box process rather than pressing Stop. The health monitor detected the failure and restored direct internet in about 18 seconds in that test. Resuming the process allowed proxy routing to recover automatically.
Stopping Tailscale separately verified that native DNS returned, and restarting it brought tailnet routing and DNS back.
The distinction is useful: a clean shutdown tests the shutdown code. A stalled process tests whether the rest of the system notices.
Less logging, more careful state handling
I don't want a small NAND-backed modem continuously writing web and proxy logs.
The custom services discard stdout/stderr, lighttpd access logging is disabled, and sing-box logging is disabled. Tailscale also needed its internal logging disabled; redirecting its service output alone wasn't enough.
This doesn't mean zero writes. Saved configuration, credentials, certificates, and updates still need persistent storage. Live measurements and status snapshots live in RAM. Stock firmware logging is unchanged.
The review also found several less visible problems worth fixing:
These are easy details to miss when the immediate goal is getting a button to work.
The reboot test—and the remaining accounting wrinkle
A real reboot brought back the custom services without manually running the launcher. Temporarily enabling sing-box autostart also verified that proxy routing returned with it.
Client internet access, tailnet routes, DNS, and the LAN HTTPS dashboard recovered. Afterwards, sing-box was returned to its previous stopped state with autostart disabled.
The reboot also exposed an imperfection: the monthly traffic total was roughly 4 MB lower than expected when accounting for post-boot traffic.
The dashboard uses the vendor's existing monthly counters. The result is consistent with recent usage not having been checkpointed before reboot, but I haven't established the firmware's exact persistence behaviour.
For now, those totals are useful monitoring figures, not billing-grade accounting. Making them more durable would require additional persistent accounting state and a deliberate decision about write frequency.
Part 1 ended with a manual recovery script. Part 2 ends with a modem that starts its own services, displays useful information, and recovers its routing when a component fails.
The biggest correction is also the simplest: before deciding that persistence requires rewriting flash, follow the vendor's boot daemons all the way through. On this firmware, one empty configuration field changed the answer.