1. 为什么要写这个专题
很多 Web 漏洞的学习路径相对直接。
比如 SQL 注入,先理解用户输入进入 SQL 语句,再理解拼接、闭合、注释、联合查询、报错、盲注,基本就能顺着一条主线学下去。
再比如 XSS,先理解用户输入进入 HTML、JavaScript 或 DOM,再理解输出位置、编码方式和浏览器解析规则,也能比较快建立分析框架。
文件上传、命令注入、路径穿越、SSRF 也是类似。它们当然也有复杂场景,但入门时通常能很快抓住核心:用户控制了什么输入,服务端把这个输入用在了什么危险位置。
Java 反序列化漏洞不太一样。
它不是看见一个参数、一个 SQL、一个 HTML 输出点就能马上理解的漏洞。它中间隔着很多 Java 机制:
- 对象如何被序列化成字节流?
- 字节流如何恢复成对象?
readObject()为什么会自动执行?- 对象字段什么时候恢复?
- 反射为什么能动态调用方法?
- classpath 为什么决定某条链能不能用?
- 依赖库里的普通类为什么能被拼成调用链的一部分?
- 为什么同一个 payload 在一个环境能成功,换个版本就失败?
网上关于 Java 反序列化的资料很多,但常见问题是两头断开:
- 有些文章直接贴工具命令和 payload,能复现,但没有把前置基础讲清楚。
- 有些文章分析某条调用链,细节很多,但默认读者已经懂反射、序列化、对象图、类加载和调用栈。
- 有些文章只讲某个 CVE,读完能跟着做一次,但很难迁移到其他组件或真实项目分析。
这也是很多人看 Java 反序列化文章会觉得吃力的原因:不是漏洞本身没有资料,而是资料没有按学习路径把必要知识串起来。
这个专题要解决的就是这个问题。
本文要完成三件事:
- 先把后续必须用到的基础知识讲够。
- 先从最小反序列化漏洞开始,然后循序渐进。
- 用手写代码和调用栈,让读者看见一条调用链是怎么被触发的。
2. Java 序列化和反序列化到底是什么
先从一个常见的 Web 业务场景说起:用户登录和会话管理。
用户登录时,前端会把账号和密码提交给后端;后端验证通过后,会创建一份登录会话信息,用来表示“这个用户已经登录”。
在实际业务中,JSON 是常用的数据格式。下面我们用 Jackson 演示这一业务场景中的序列化和反序列化流程。开发者实现这个功能时,先定义登录请求对象:
// 登录请求对象:只用于接收用户提交的账号和密码
public class LoginRequest {
private String username;
private String password;
// JSON 反序列化时,Jackson 需要无参构造方法来创建对象
public LoginRequest() {
}
// 登录请求对象的属性访问器方法
public String getUsername() {
return username;
}
// 登录请求对象的属性修改器方法
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}再定义登录成功后的会话对象:
// 登录会话对象:表示登录成功后的用户状态,不保存明文密码
public class LoginSession {
private Long userId;
private String username;
private String role;
private String sessionId;
// JSON 反序列化时,Jackson 需要无参构造方法来创建对象
public LoginSession() {
}
// 登录成功后,服务端创建会话对象时使用,包含用户 ID、账号、角色、会话 ID 等信息
public LoginSession(Long userId, String username, String role, String sessionId) {
this.userId = userId;
this.username = username;
this.role = role;
this.sessionId = sessionId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
// 方便打印对象内容,观察序列化和反序列化结果
@Override
public String toString() {
return "LoginSession{userId=" + userId + ", username='" + username + "', role='" + role + "', sessionId='" + sessionId + "'}";
}
}Maven 项目中通过在 pom.xml 中添加依赖引入 Jackson Databind 库,以 2.17.2 版本为例:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>添加好后 Maven 会自动下载 Jackson 需要的 jar 文件,如 jackson-core-2.17.2.jar、jackson-databind-2.17.2.jar 等。
然后开发者在 Java 代码中导入 ObjectMapper 并实现登录和会话管理功能,示例代码如下:
// 导入 ObjectMapper 类,用于 JSON 序列化和反序列化
import com.fasterxml.jackson.databind.ObjectMapper;
// 定义一个 JSON 序列化演示类,用于展示登录请求反序列化、登录会话序列化、会话反序列化的基本流程
public class JsonSerializeDemo {
public static void main(String[] args) throws Exception {
// ObjectMapper 是 Jackson 提供的 JSON 处理类,用于 JSON 序列化和反序列化操作
ObjectMapper objectMapper = new ObjectMapper();
// 模拟前端提交的登录请求 JSON,里面包含账号和密码
String requestJson = "{\"username\":\"zhangsan\",\"password\":\"123456\"}";
// 反序列化:把登录请求 JSON 恢复成 LoginRequest 对象
LoginRequest loginRequest = objectMapper.readValue(requestJson, LoginRequest.class);
System.out.println("登录请求对象:" + loginRequest.getUsername());
// 模拟验证账号密码。真实业务中通常会查询数据库,并比较密码哈希,这里只做演示
if (!"zhangsan".equals(loginRequest.getUsername()) || !"123456".equals(loginRequest.getPassword())) {
System.out.println("账号密码错误");
return;
}
// 登录成功后,服务端创建会话对象。注意:会话对象不保存明文密码
LoginSession loginSession = new LoginSession(
10001L,
loginRequest.getUsername(),
"admin",
"SESSION-10001"
);
// 序列化:把 LoginSession 对象转换成 JSON 字符串,方便保存到 Redis、数据库或文件中
String sessionJson = objectMapper.writeValueAsString(loginSession);
System.out.println("服务端保存的 Session JSON:" + sessionJson);
// 真实业务中,客户端通常只拿到 sessionId,例如通过 Set-Cookie 返回
String cookieValue = loginSession.getSessionId();
System.out.println("返回给客户端的 Cookie:SESSIONID=" + cookieValue);
// 后续请求到来时,服务端根据 sessionId 从 Redis、数据库或文件中取出 Session JSON
String sessionJsonFromStorage = sessionJson;
// 反序列化:把 Session JSON 恢复成 LoginSession 对象,继续判断登录态和权限
LoginSession restoredSession = objectMapper.readValue(sessionJsonFromStorage, LoginSession.class);
System.out.println("恢复后的 Session 对象:" + restoredSession);
// 恢复成对象后,后端就可以继续判断角色、权限、会话状态等业务逻辑
if ("admin".equals(restoredSession.getRole())) {
System.out.println("当前用户是管理员");
}
}
}以上代码的整体流程如下:
- 提交 JSON。
- 后端反序列化恢复 Java 对象。
- 验证账号密码。
- 序列化
LoginSession对象。 - 保存到服务端存储,如 Redis、数据库或文件。
- 返回给客户端会话 ID,如 Cookie 或 Token。
这段代码里有两个关键动作:
readValue():把 JSON 字符串恢复成 Java 对象(反序列化)。writeValueAsString():把 Java 对象转换成 JSON 字符串(序列化)。
所以,序列化就是把对象转换成二进制字节流、JSON、XML 等数据的过程。反序列化就是把二进制字节流、JSON、XML 等数据恢复成对象的过程。
3. Java 原生反序列化的入口:readObject()
接下来开始看 Java 原生反序列化漏洞会用到的核心机制。Java 原生反序列化最核心的入口是 readObject()。这是 JDK 自带的类 ObjectInputStream 的一个方法。它从输入流中读取 Java 原生序列化数据,恢复成 Java 对象。
使用时不需要额外引入第三方 jar 包,只需要导入 JDK 标准库即可。一个最小的读取代码如下:
import java.io.InputStream;
// 导入 ObjectInputStream 类,用于读取 Java 原生序列化数据
import java.io.ObjectInputStream;
public class NativeDeserializeDemo {
public Object read(InputStream inputStream) throws Exception {
// ObjectInputStream 用来读取 Java 原生序列化数据
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
// readObject() 会从输入流中恢复 Java 对象
return objectInputStream.readObject();
}
}当 ObjectInputStream.readObject() 恢复对象时,如果这个对象所属的类定义了自己的 readObject()(对象恢复过程中的回调方法)方法,Java 会自动调用这个方法,从而执行其中的代码。
4. Java 原生序列化的几个基础规则
要使用 Java 原生序列化是有条件的,通常要求被序列化对象所属的类满足一些基础规则。
4.1 Serializable:类是否允许被序列化
一个对象要使用 Java 原生序列化,最基本的条件是它所属的类实现 Serializable 接口。这里说的是通过 implements Serializable 实现接口,不是继承某个父类:
import java.io.Serializable;
public class LoginUser implements Serializable {
private String username;
private String role;
}Serializable 本身没有方法,它更像一个标记,表示这个类的对象允许被 Java 原生序列化。
但要注意:实现 Serializable 只是最核心的条件。一个对象里如果还引用了其他对象,这些被引用的对象通常也需要能被序列化。例如:
public class LoginUser implements Serializable {
private UserProfile profile;
}如果 UserProfile 没有实现 Serializable,序列化 LoginUser 时也可能失败,并抛出异常。
如果某个字段不想参与序列化,可以使用 transient 修饰。被 transient 修饰的字段不会写入序列化数据,也就不要求这个字段引用的对象必须可序列化。例如:
private transient UserProfile profile;这和后面的调用链有关:不是任意对象都能被放进 Java 原生序列化数据里,相关类首先要满足可序列化条件。
4.2 字段值会影响对象恢复后的行为
Java 原生序列化会保存对象字段和字段值。反序列化时,这些字段和字段值都会被恢复回来。后面看漏洞时,字段很重要。因为很多调用链不是直接写死的,而是由对象字段影响后续执行路径。
4.3 transient 和 static 不按普通字段保存
transient 字段不会被序列化:
private transient String token;static 字段属于类,不属于某个对象实例,也不会作为对象字段保存。
所以判断某个字段能不能被反序列化数据控制时,要注意它是不是 transient 或 static。
4.4 serialVersionUID 影响版本兼容
private static final long serialVersionUID = 1L;它用于判断序列化数据和当前类是否兼容。如果生成序列化数据时是一版代码,反序列化时类结构已经变化,就可能出现异常。这也是很多反序列化实验在一个环境成功、另一个环境失败的原因之一。
5. gadget、source、sink:把前面的概念串成漏洞链
学习反序列化漏洞时,要理解 gadget、source、sink 这三个概念。
5.1 source:数据从哪里进入
source 就是用户可控输入进入程序的入口,通俗理解就是传参入口。在 Java 原生反序列化里,source 通常就是外部数据进入 readObject() 的地方。例如:
ObjectInputStream in = new ObjectInputStream(request.getInputStream());
Object object = in.readObject();这里的 HTTP 请求体就是 source。常见 source 包括:
- HTTP 请求体。
- Cookie。
- 文件上传内容。
- 消息队列消息。
- 缓存或数据库里可被用户污染的数据。
5.2 gadget:中间怎么传过去
gadget 是目标 Java 程序运行环境中已经存在的、可以被串进调用链的一段代码逻辑。它本身不是漏洞代码,可能也没有被业务调用,只是存在于运行环境的一个普通类或普通方法;但如果它能在反序列化过程中被触发,并且可以继续调用其他方法,就成为了攻击链中的一环。比如:
- 业务代码中的某个类自定义了
readObject()方法,例如com.example.UserSession.readObject()。 - JDK 中
java.util.HashMap在处理 key 时可能触发 key 对象的hashCode()方法。 - JDK 中
java.util.PriorityQueue在维护队列顺序时可能触发比较器的compare()方法。 - JDK 动态代理相关的
java.lang.reflect.Proxy/InvocationHandler机制可能触发InvocationHandler.invoke()方法。 - 第三方依赖 Apache Commons Collections,也就是
commons-collections-3.x.jar中的org.apache.commons.collections.Transformer、InvokerTransformer、ChainedTransformer等类和方法。
5.3 sink:最后到达哪里
sink 就是漏洞最终造成影响的地方。比如多个 gadget 串成一条调用链,最后执行了命令、写了文件、发起了 JNDI 请求等,那么这个最终被触发的危险操作点就叫 sink。比如:
- 反射调用。
- JNDI 查询。
- 文件写入。
- 表达式执行。
- 命令执行。
6. 原生反序列化的最小漏洞示例
现在用一个最小例子,把前面的概念串起来。这个例子不依赖第三方库,不执行危险命令,只证明一件事:程序只调用了 readObject(),但对象恢复过程中自动触发了其他方法。
建议创建下面的实验目录:
deserialize-demo/
├── data/ # 需要手动创建,用于保存 payload.ser
│ └── payload.ser # 运行 PayloadBuilder 后生成的序列化数据(无需手动创建)
├── out/ # 需要手动创建,javac 编译后的 class 文件输出目录
└── src/
├── VulnerableReader.java # 模拟存在风险的反序列化入口
├── DemoAction.java # 模拟 sink,最终被触发的方法在这里
├── DemoTrigger.java # 模拟 gadget,反序列化时自动触发 readObject()
└── PayloadBuilder.java # 构造对象图并生成 payload.ser6.1 模拟存在风险的业务代码
下面这个类模拟一个存在风险的业务逻辑:它从文件中读取序列化数据,然后直接交给 ObjectInputStream.readObject() 处理。
创建 src/VulnerableReader.java:
import java.io.FileInputStream;
import java.io.ObjectInputStream;
// 模拟存在风险的业务代码:从文件读取外部序列化数据并直接反序列化
public class VulnerableReader {
public static void main(String[] args) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(
new FileInputStream("data/payload.ser"))) {
// source:外部数据进入 readObject()
in.readObject();
}
}
}data/payload.ser 中的内容是我们可以修改的,这就是 source。
接下来我们在攻击者的视角下,讲述如何构造一份能触发这个入口的序列化数据。
6.2 编写模拟 sink
创建 src/DemoAction.java:
import java.io.Serializable;
// 模拟 sink:最终被触发的方法在这里
public class DemoAction implements Serializable {
private static final long serialVersionUID = 1L;
private final String message;
// 构造函数,用于初始化 message 字段
public DemoAction(String message) {
this.message = message;
}
// 模拟危险操作,例如写文件、发请求等。这里为了安全,只打印日志
public void run() {
System.out.println("DemoAction.run 被触发:" + message);
}
}我们的目标是让业务程序执行我们定义的模拟危险操作 run()。真实漏洞里,sink 可能是命令执行、文件写入、JNDI 请求、反射调用(后续文章会讲)等。
6.3 编写模拟 gadget
为了理解 gadget 的作用,我们在本例中创建一个模拟 gadget DemoTrigger 来模拟真实运行环境中存在,但没有被业务调用的类或方法。创建 src/DemoTrigger.java:
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
// 模拟 gadget:反序列化时自动触发 readObject()
public class DemoTrigger implements Serializable {
private static final long serialVersionUID = 1L;
private DemoAction action;
public DemoTrigger(DemoAction action) {
this.action = action;
}
// 反序列化 DemoTrigger 对象时,Java 会自动调用这个 readObject()
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
// 先按默认规则恢复字段,包括 action 字段
in.defaultReadObject();
// 字段恢复后,继续调用 DemoAction.run()
if (action != null) {
action.run();
}
}
}6.4 构造并生成 payload
我们需要根据业务需要的序列化数据格式,构造和生成 payload,创建 src/PayloadBuilder.java:
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
// 构造对象图并生成 data/payload.ser
public class PayloadBuilder {
public static void main(String[] args) throws Exception {
DemoAction action = new DemoAction("反序列化链路触发成功");
DemoTrigger trigger = new DemoTrigger(action);
// 序列化 DemoTrigger 对象到文件
try (ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream("data/payload.ser"))) {
out.writeObject(trigger);
}
}
}在这个例子中,被序列化的主对象是 DemoTrigger,它的 action 字段引用了一个 DemoAction 对象。反序列化恢复 DemoTrigger 时,action 字段也会被一起恢复,所以 DemoTrigger.readObject() 中才会继续调用 action.run()。这种对象之间的引用关系,就可以理解为对象图。
6.5 编译并运行
在 deserialize-demo 目录下执行:
# 编译 src 目录下的所有 Java 文件,包含模拟业务和构造的 payload
javac -encoding UTF-8 -d out src/*.java
# 先生成序列化数据 data/payload.ser
java -cp out PayloadBuilder
# 再运行模拟业务,触发反序列化读取 payload.ser
java -cp out VulnerableReader运行后会看到:
DemoAction.run 被触发:反序列化链路触发成功6.1 的模拟业务中只调用了 JDK 提供的原生类的 readObject() 方法,没有调用其他任何方法,但是却输出了 DemoAction.run() 调用的结果。现在清楚 Java 原生反序列化漏洞的机制了吧,其实它的调用链是:
VulnerableReader.main():运行业务代码后进入主方法,模拟服务端读取外部序列化数据。
-> ObjectInputStream.readObject():source 入口,读取并反序列化 data/payload.ser 中的 payload。
-> DemoTrigger.readObject():gadget,反序列化恢复 DemoTrigger 对象时被自动调用,并继续调用 action.run()。
-> DemoAction.run():sink,最终被触发的方法。本例中只是打印日志,真实漏洞里可能是命令执行、文件写入、JNDI 请求等危险操作。
为了后续方便理解并分析实际的反序列化漏洞链,还需要理解下面概念。
7. 反射:调用为什么可以变得不直观
前面已经见过正常调用方法的写法了:in.readObject()。但 Java 有一个机制,允许程序在运行时再决定要找哪个类、调用哪个方法,这就是反射。例如:
// 运行时找到 LoginUser 类
Class<?> clazz = Class.forName("LoginUser");
// 运行时创建 LoginUser 对象
Object object = clazz.getConstructor().newInstance();
// 运行时找到 toString 方法
Object result = clazz.getMethod("toString").invoke(object);
System.out.println(result);反射本身不是漏洞。很多框架都会用反射,比如 Spring、MyBatis、Jackson 等。但反射会让调用关系变得不直观。你看到的不是 loginUser.toString(),而是 method.invoke(object)。
后面很多调用链会利用这种动态调用能力,把字段里保存的类名、方法名、参数,变成真正的方法调用。
8. classpath:为什么同一条链有时能用、有时不能用
classpath 可以简单理解为:Java 程序运行时查找类和 jar 包的路径范围。也就是说,classpath 是在告诉 JVM:程序运行时要去哪些目录或 jar 包里找类。比如运行 Java 程序时,可以通过 -cp 指定 classpath:
java -cp "out:/usr/local/app/lib/commons-collections-3.2.1.jar" VulnerableReader如果某个类不在这些路径范围内,JVM 就找不到它,可能会抛出 ClassNotFoundException 异常。
反序列化数据里会记录对象的类信息,服务端反序列化时,JVM 会根据类名去 classpath 指定的范围里查找对应类;如果找不到,对象就无法正常恢复。所以后面看真实漏洞时,先记住一句话:Gadget 链不是凭空出现的,它依赖目标环境 classpath 中已经存在的类和 jar 包。
所以有时候,Gadget 链在不同环境中可能结果不同。
9. 本文小结
读完本文后,你应该掌握这些内容:
- 知道序列化和反序列化为什么会出现在真实业务中,例如登录态、Session、缓存、数据库存储等场景。
- 知道 JSON 是业务中常见的序列化格式,并理解
readValue()和writeValueAsString()分别对应反序列化和序列化。 - 知道 Java 原生反序列化的核心入口是 JDK 自带的
ObjectInputStream.readObject()。 - 知道
ObjectInputStream.readObject()恢复对象时,可能自动调用目标类中自定义的readObject()回调方法。 - 知道 Java 原生序列化需要满足一些基础条件,例如类通常需要实现
Serializable,字段是否可序列化也会影响对象恢复。 - 知道
transient和static字段不会按普通对象字段参与 Java 原生序列化。 - 知道
serialVersionUID会影响序列化数据和类版本是否兼容。 - 知道
source是用户可控数据进入程序的位置,gadget是调用链中负责继续传递调用的代码片段,sink是最终产生安全影响的位置。 - 知道调用栈可以帮助我们确认反序列化链路真实发生了什么。
- 知道反射会让方法调用变得动态,后续真实 Gadget 链中经常会用到这种能力。
- 知道 classpath 决定 JVM 运行时能去哪里找类和 jar 包,因此同一条链在不同环境中可能结果不同。
这篇文章只完成基础铺垫。下一篇开始进入真实经典案例:Apache Commons Collections 反序列化漏洞

Comments | NOTHING