Java 反序列化基础与漏洞入门:从 readObject 到第一条调用链


1. 为什么要写这个专题

很多 Web 漏洞的学习路径相对直接。

比如 SQL 注入,先理解用户输入进入 SQL 语句,再理解拼接、闭合、注释、联合查询、报错、盲注,基本就能顺着一条主线学下去。

再比如 XSS,先理解用户输入进入 HTML、JavaScript 或 DOM,再理解输出位置、编码方式和浏览器解析规则,也能比较快建立分析框架。

文件上传、命令注入、路径穿越、SSRF 也是类似。它们当然也有复杂场景,但入门时通常能很快抓住核心:用户控制了什么输入,服务端把这个输入用在了什么危险位置。

Java 反序列化漏洞不太一样。

它不是看见一个参数、一个 SQL、一个 HTML 输出点就能马上理解的漏洞。它中间隔着很多 Java 机制:

  • 对象如何被序列化成字节流?
  • 字节流如何恢复成对象?
  • readObject() 为什么会自动执行?
  • 对象字段什么时候恢复?
  • 反射为什么能动态调用方法?
  • classpath 为什么决定某条链能不能用?
  • 依赖库里的普通类为什么能被拼成调用链的一部分?
  • 为什么同一个 payload 在一个环境能成功,换个版本就失败?

网上关于 Java 反序列化的资料很多,但常见问题是两头断开:

  • 有些文章直接贴工具命令和 payload,能复现,但没有把前置基础讲清楚。
  • 有些文章分析某条调用链,细节很多,但默认读者已经懂反射、序列化、对象图、类加载和调用栈。
  • 有些文章只讲某个 CVE,读完能跟着做一次,但很难迁移到其他组件或真实项目分析。

这也是很多人看 Java 反序列化文章会觉得吃力的原因:不是漏洞本身没有资料,而是资料没有按学习路径把必要知识串起来。

这个专题要解决的就是这个问题。

本文要完成三件事:

  1. 先把后续必须用到的基础知识讲够。
  2. 先从最小反序列化漏洞开始,然后循序渐进。
  3. 用手写代码和调用栈,让读者看见一条调用链是怎么被触发的。

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.jarjackson-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("当前用户是管理员");
        }
    }
}

以上代码的整体流程如下:

  1. 提交 JSON。
  2. 后端反序列化恢复 Java 对象。
  3. 验证账号密码。
  4. 序列化 LoginSession 对象。
  5. 保存到服务端存储,如 Redis、数据库或文件。
  6. 返回给客户端会话 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 transientstatic 不按普通字段保存

transient 字段不会被序列化:

private transient String token;

static 字段属于类,不属于某个对象实例,也不会作为对象字段保存。

所以判断某个字段能不能被反序列化数据控制时,要注意它是不是 transientstatic

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.TransformerInvokerTransformerChainedTransformer 等类和方法。

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.ser

6.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,字段是否可序列化也会影响对象恢复。
  • 知道 transientstatic 字段不会按普通对象字段参与 Java 原生序列化。
  • 知道 serialVersionUID 会影响序列化数据和类版本是否兼容。
  • 知道 source 是用户可控数据进入程序的位置,gadget 是调用链中负责继续传递调用的代码片段,sink 是最终产生安全影响的位置。
  • 知道调用栈可以帮助我们确认反序列化链路真实发生了什么。
  • 知道反射会让方法调用变得动态,后续真实 Gadget 链中经常会用到这种能力。
  • 知道 classpath 决定 JVM 运行时能去哪里找类和 jar 包,因此同一条链在不同环境中可能结果不同。

这篇文章只完成基础铺垫。下一篇开始进入真实经典案例:Apache Commons Collections 反序列化漏洞

声明:Hack All Sec的博客|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - Java 反序列化基础与漏洞入门:从 readObject 到第一条调用链


Hacker perspective for security