反序列化漏洞基础

xiaojinjin 发布于 25 天前 247 次阅读


什么是反序列化漏洞?

反序列化的本质其实是代码的链式反应

反序列化漏洞是指程序在反序列化期间通过特殊的调用链引发的一系列安全问题,事实上一切具有反序列化瘀法功能的编程语言都有存在反序列化漏洞的可能性,不同的编程语言反序列化漏洞是截然不同的,因为它们在实现序列化和反序列化的功能时是截然不同的

反序列化漏洞就是指当目标程序对攻击者可控的数据进行反序列化处理时而产生的安全问题

为什么需要序列化和反序列化的功能?

现代高级编程语言很多都是面向对象设计的,程序与程序之间通常需要传递数据:

当传递的是数字和字符串时

只需要直接发送和接收即可

如果是一个对象呢

这就需要把对象打包,接收方式按照同样的方法进行“解包”,即可还原出对象的全部特征,主要是传输和存储

什么是序列化和反序列化

序列化

将程序内存中的对象转换成一种具有代表性的数据字节流,由对象转换得到字节流的过程就称作序列化

反序列化

对于获取序列化数据的一方,需要从这些字节流里还原得到对应的对象状态信息,由字节流转换得到对象的过程称为反序列化

序列化就是将对象转换成数据字节流,反序列化就是将数据字节流转换成对象

序列化是一个比较宽泛的概念,并没有统一的固定形式。反序列化也一样,对于不同的编程语言,不同的数据处理库来说,序列化在具体的实现机制上都会有所不同,从而导致相应的反序列化漏洞利用条件或难度会有所不同

Java反序列化基础

java.io.Serializable

根据java原生标准里提供的反序列化机制,如果要对一个对象进行序列化和反序列化机制,那么这个对象的类就必须要实现java.io.Serializable接口

writeObject

java.io.ObjectOutputStream类的writeObject方法用来对一个Java对象进行序列化,例如,将表示当前日期的Date对象序列化道本地文件中

FileOutputStream f =new FileOutputStream("data.ser");
ObjectOutput s = new ObjectOutputStream(f);
s.writeObject(new Date());
s.flush();
第一行:
创建一个文件字节输出流。它会在当前项目的运行目录下寻找一个名为 data.ser 的文件;如果文件不存在,就会自动创建它。.ser 是 Java 序列化文件常用的约定扩展名(代表 serialization)。这个流的作用仅仅是“向这个文件写入原始的字节数据”。
第二行:
FileOutputStream 只能写入原始的二进制字节。为了能直接写入 Java 对象,需要用到“装饰器模式”,用 ObjectOutputStream 把刚才的 f 包装起来。这样,s 就具备了将复杂的 Java 对象打碎成字节序列(序列化)的能力,然后再通过内部的 f 把这些字节写进文件里。
第三行:
new Date() 创建了一个代表当前系统时间的日期对象(属于 java.util.Date 类)。
writeObject(...) 是对象流的核心方法。它会抓取这个 Date 对象的当前状态,将其转换为一串特定的字节序列,并写入到 data.ser 文件中。
注意: 只有实现了 java.io.Serializable 接口的类才能被这样写入,而 Date 类恰好在 Java 源码中实现了这个接口。
第四行:
Java 在写入数据时通常会先将数据暂存在内存的一个缓冲区 (Buffer) 中,等缓冲区满了再统一写到硬盘。flush() 方法的作用就是强制命令 Java:“别等了,立刻把缓冲区里现有的数据全部真正写入到物理文件中”,以防止程序突然崩溃导致数据丢失。

readObject

java.io.ObjectInputStream类的readObject方法用来对序列化格式的字节流数据进行反序列化

例如,将本地文件中的序列化数据反序列化得到Date对象,代码如下

FileOutputStream in = new FileInputStream("data.ser");
ObjectInputStream s = new ObjectInputStream(in);
Date date = (Date)s.readObject();
第一行:
这行代码创建了一个文件字节输入流。它会去当前目录下寻找之前保存的 data.ser 文件,并打开一条读取通道。如果这个文件不存在,Java 会抛出 FileNotFoundException 异常。此时的 in 只能顺着管道读取最原始的 0 和 1(二进制字节)。
第二行:
同样是“装饰器模式”的应用。我们把原始的 in 传给 ObjectInputStream 包装起来。这样,s 就像带上了一个高级翻译器,拥有了把毫无意义的字节码按照 Java 的规则重新拼装成复杂对象的能力。
第三行:
readObject() 是反序列化的核心。它会从 data.ser 中读取字节数据,在内存中重新构建出这个对象。
为什么需要 (Date) 强制类型转换? 因为 readObject() 方法不知道读出来的是什么类型的对象,它的返回值是所有类的老祖宗 Object 类型。由于我们明确知道存进去的是一个时间,所以必须加上 (Date) 将它强制转换回原形,否则无法调用 Date 类的专属方法。
注意: 如果在读取时,当前 Java 程序里找不到 Date 这个类的定义文件,它会抛出 ClassNotFoundException 异常。

对于要进行序列化的对象,如果它的类定义了 writeObject 方法,那么在这个对象被序列化的过程中,该方法会被调用。

对于要进行反序列化的对象,如果它的类定义了 readObject 方法,那么在这个对象被反序列化的过程中,该方法会被调用。

这句话描述的是 Java 序列化框架提供的一个“后门接口”。只要你按规矩在这个后门里写了代码,Java 就会把序列化的控制权交给你。当然,有写就有读,通常写了自定义的 writeObject,也会对应地写一个 readObject 方法来把加密或处理过的数据还原回来。

Java反序列化漏洞原理

当Java应用程序对来自外部输入的不可信数据进行反序列化处理时,就形成了反序列化漏洞

InputStream input = request.getInputStream();
ObjectInputStream ois = new ObjectInputrStream(input);
Object obj = ois.readObject();

这段代码首先获取了HTTP请求的数据包Body部分的字节流,然后用它来构造ObjectInout-Steeam输入流,最后调用了readObject方法,触发了反序列化

由于http数据包Body部分一般情况下是用户可操控的,属于不可信数据,这段代码也就形成了一个反序列化漏洞

假设这个程序中有这样一个类,这个Dummy类在反序列化时,从输入数据流里解码读取一个字符串,并将其设置为属性cmd,然后以属性cmd的值作为系统命令进行执行

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class Dummy implements Serializable {
 //定义了一个名为 Dummy(通常意为“假人/占位符”,在安全领域常用来指代测试用的恶意类)的公开类。它实现了 Serializable 接口,意味着这个类的对象可以被打包成字节流,或者从字节流中还原。

   private String cmd;
 //声明了一个私有的字符串变量 cmd(command的缩写)。

   public Dummy(String cmd) {
       this.cmd = cmd;
  }
 //构造函数。攻击者在创建这个恶意对象时,可以通过它把想要执行的系统命令塞进 cmd 变量里,例如 new Dummy("计算器命令")。

   private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
       cmd = s.readUTF();  // 这里从输入数据流里解码读取一个字符串,并将其设置为属性 cmd
       Runtime.getRuntime().exec(cmd); // 这是 Java 中用来直接执行底层操作系统命令的方法,以cmd作为系统命令进行执行
  }

   private void writeObject(ObjectOutputStream s) throws IOException {
       s.writeUTF(cmd);
  }
}

下面是恶意命令

Dummy dummy = new Dummy("calc");//创建了一个 Dummy 类的实例,并向其构造函数传入了 "calc"(Windows 系统下的计算器程序)。此时,这个对象的 cmd 属性被赋值为了要执行的恶意命令。
ByteArrayOutputStream baos = new ByteArrayOutputStream();//ByteArrayOutputStream作用是在内存中开辟一块空间,相当于一个“虚拟文件”,稍后序列化产生的所有字节数据都会暂存到这个内存缓冲区中。
ObjectOutputStream oos = new ObjectOutputStream(baos);//将刚才的内存字节流传给 ObjectOutputStream 进行包装,使其具备将 Java 对象转换为字节序列的能力。
oos.writeObject(obj);//这是最关键的一步。当执行这行代码时,Java 虚拟机会检查 Dummy 类,发现它自定义了 writeObject 方法,于是调用 Dummy 类中自定义的逻辑,将 "calc" 这个字符串按指定格式写入到 baos 的内存缓冲区中。
byte[] evilbytes = baos.toByteArray();//将内存缓冲区中积累的所有序列化后的字节数据,一次性提取出来,赋值给 evilbytes 数组。

黑盒测试和白盒测试

黑盒测试

主要靠观察通信流量来确定入口点。需要在目标通信的流量中寻找Java序列化特征字节或字符串 0xac ed 00 05 (二进制数据的16进制表示)或rO0AB(对二进制数据base64编码后的字符串)

这些特征字节是Java序列化数据的标志。具体来说,0xaced是序列化的Magic Bytes,0x0005则表示序列化的版本号,流量中出现这些标志基本可以确定目标会对传输的序列化数据进行反序列化解析处理

白盒测试

可以直接在应用代码里搜索是否出现readObject相关方法的调用,如果存在,再看看反序列化的输入数据流来源是否用户可控,若可控则证明反序列化漏洞存在

Java反序列化漏洞利用

Java反序列化漏洞攻击的难点不是如何发现而是如何利用

为了最终完成执行危险操作的目的,现实世界的反序列化漏洞利用攻击中,往往需要组合多个不同的Serialiabale接口实现类的方法调用,形成复杂的调用链,我们习惯称之为反序列化利用链,也叫Gadget Chain

重点关注这三类代码模式:

1. 危险的 readObject() 重写

业务代码如果重写了 readObject(),这是最高危的信号,里面很可能有逻辑问题。

java

// 危险示例1:直接对反序列化来的对象做危险操作
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
  in.defaultReadObject();
  // 直接执行了从流中恢复的Runnable对象
  this.runnable.run();
}

// 危险示例2:将反序列化数据直接传入危险方法
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
  in.defaultReadObject();
  // 假设存在一个命令执行方法
  this.executor.execute(this.cmd);
}

// 危险示例3:利用流中的数据调用反射
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
  in.defaultReadObject();
  // this.targetMethod 和 this.targetObject 都来自序列化流
  this.targetClass.getMethod(this.targetMethod).invoke(this.targetObject);
}

审计技巧:在 IDA 或 jadx 中,对所有重写了 readObject 的类逐一排查,看里面有没有调用任何传入对象的方法,或者将字段传递给危险 sink。

2. 隐式触发点:Map/SortedSet 接口的“自动调用”

这是在没有直接 Gadget 时,最常见的手工构造链方式。许多业务逻辑会在 readObject 后,将反序列化出来的对象放入一个 Map 或 SortedSet。

  • hashCode() / equals() 触发 如果一个 HashMap 被反序列化,它会重新计算键的哈希值并进行键比较,从而自动调用键对象的 hashCode()equals() 方法。如果业务代码中有一个类的 hashCode() 方法写得有问题,就能被利用。java// 审计目标:找一个可序列化的类,其hashCode或equals方法有危险操作
    public class DangerousObject implements Serializable {
      @Override
      public int hashCode() {
          // 这里如果调用了危险方法,就能通过HashMap触发
          Runtime.getRuntime().exec("calc");
          return 1;
      }
    }
  • compareTo() 触发 同理,TreeMapTreeSet 在反序列化时会调用键/元素的 compareTo() 方法来重建红黑树。如果业务代码中一个实现了 Comparable 的类,其 compareTo() 方法存在漏洞,也能被触发。

审计技巧:搜索实现了 SerializableComparable 的类,以及重写了 hashCode / equals 的类,检查其实现逻辑。

3. 终极 sink:JNDI 注入与类加载

在业务逻辑中寻找可以触发类加载或 JNDI 查询的“后门”。

  • JNDI 注入:搜索 InitialContext.lookup() 方法,看其参数是否可控(直接或间接来自反序列化字段)。即使在高版本 JDK,如果本地有可用的 Gadget,仍可能利用。在低版本 JDK 下则能直接用于远程加载类。
  • 类加载:寻找 Class.forName()URLClassLoader,看参数能否控制。
  • 脚本引擎:如果有 ScriptEngine.eval(),且脚本内容或引擎名可控。

手工编织三步法:

  1. 找起始点:确定一个会在反序列化时自动执行的方法。
    • readObject() 内的直接调用。
    • hashCode() / equals() / compareTo() 等间接调用。
  2. 找跳板:连接起始点和最终 sink 的中间类。
    • 动态代理:通常用来将任意方法调用路由到指定类的 invoke() 方法。
    • 反射调用Method.invoke() 是核心,可以让你调用任意方法。如果一个类的 hashCode() 里恰好有反射调用,它就是绝佳的跳板。
    • 方法引用ConsumerSupplier 等函数式接口也可能被利用来传递调用。
  3. 找终点:最终执行命令的类。
    • 危险命令执行类:寻找有没有类似 System.exec() 封装好的工具类。
    • 文件操作类:读写文件的逻辑,如果能控制路径,可能通过写 SSH key、计划任务等方式间接执行命令。
    • 写 webshell:如果目标是个 web 应用,寻找能写文件的类,直接写一个 webshell 到 web 目录下。

假设审计出: A.readObject() -> 调用了 this.map.put(key, value) B.hashCode() -> 调用了 Runtime.exec(this.cmd) 那么利用链就是:序列化一个包含 B 对象作为 key 的 HashMap,把 HashMap 赋给 A.map。当 A 被反序列化时,put 操作触发 B.hashCode(),从而执行命令。

除了常规的命令执行,业务系统往往有更直接的破坏或控制手段。

  • 数据库操作:找 JdbcTemplateMyBatis 的 Mapper 接口。如果能控制 SQL 或参数,直接通过数据库写文件或执行存储过程。
  • RMI/网络请求:寻找 HttpURLConnectionRestTemplateRMI 调用。控制 URL 可做 SSRF,控制 RMI 参数可能进行反制。
  • 流程控制:有些类能修改业务状态,如“审批通过”、“权限提升”。改变对象状态就等于攻击成功,不一定要 exec

实战工具与流程

  1. 静态分析:用 jadx 或 IDA Pro 打开 jar/war 包,全局搜索这些关键字:
    • readObject
    • Invoke (反射)
    • hashCode / equals / compareTo
    • exec / lookup / forName / eval
    • FileOutputStream / FileWriter
  2. 动态验证:在本地搭建调试环境,编写测试代码来触发你发现的类和方法。javapublic class ExploitTest {
      public static void main(String[] args) {
          // 实例化你找到的可疑类
          MyDangerousObject obj = new MyDangerousObject();
          obj.setCmd("touch /tmp/pwned");
           
          // 模拟反序列化触发逻辑,看效果
          HashMap map = new HashMap();
          map.put(obj, "test"); // 观察是否会触发obj.hashCode()
      }
    }
  3. 定制 Payload:一旦链跑通,自己写一个 Java 程序,生成序列化 payload。java// 生成payload的代码
    FileOutputStream fos = new FileOutputStream("payload.ser");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(yourCraftedObject); // 这里放入你构造好的对象
    oos.close();
此作者没有提供个人介绍。
最后更新于 2026-07-27