JavaSec系列 - 1. 反序列化与反射
本章源码: https://github.com/hey3e/JavaSec-Code/tree/main/javasec1
(1)序列化与反序列化
在Java中,我们创建的对象会随着其JVM的销毁而销毁。但有时,我们希望能在其他JVM、或是其他机器上复用这个对象。序列化允许我们将Java对象转换为字节流,便于存储到本地,以及通过网络发送给其他机器。而反序列化允许我们重新将序列化的字节流还原为Java对象。
我们首先来看一下序列化的实现:
1 | String string1 = "test"; |
这段代码中,通过调用ObjectOutputStream类的writeObject方法,我们序列化了”test”字符串对象,并将其字节流存储到本地:
1 | aced 0005 7400 0474 6573 74 |
其中,aced 0005是序列化数据的特征。
再来看一下反序列化的实现:
1 | FileInputStream fileInputStream = new FileInputStream("test.db"); |
这段代码调用ObjectInputStream类的readObject方法实现反序列化,恢复了”test”字符串对象。

(2)反射
反射是指程序在运行过程中动态地去获取指定对象。
对于打开计算器的代码:
1 | Runtime.getRuntime().exec("/System/Applications/Calculator.app/Contents/MacOS/Calculator"); |
用反射实现为:
1 | Class clz = Class.forName("java.lang.Runtime"); |
其中,forName用于获取类,getMethod和invoke分别用于获取和执行方法。
这里我们重点讨论一下forName方法。构造这样一个类:
1 | package javasec1; |
它包含一个静态初始化块static{}和一个构造方法。其中,static{}会在类加载时执行,构造方法会在类初始化时执行。
我们调用forName来获取它:
1 | Class clz = Class.forName("javasec1.ReflectionClass"); |
运行这单行代码,发现static{}被执行:

可以得知,forName查找并加载了类,但未初始化。初始化调用newInstance方法即可:
1 | Class clz = Class.forName("javasec1.ReflectionClass"); |

(3)反序列化利用
反序列化的利用实际上与反射息息相关。我们来看这样两段代码:(两段代码此处需要分开运行,后面会给出解释)
1 | ReflectionClass reflectionClass = new ReflectionClass(); |
1 | FileInputStream fileInputStream = new FileInputStream("util.db"); |
很明显,两段代码分别对前文构造的ReflectionClass类进行了序列化与反序列化的操作。执行第一段代码,发现报错:

这是因为现在的ReflectionClass类缺乏序列化的必要条件:实现Serializable接口。于是对ReflectionClass进行修改,使其实现Serializable接口:
1 | package javasec1; |
序列化成功后,我们执行第二段代码进行反序列化:

发现static{}被执行了。据此,我们推测:readObject方法中调用了forName或其他能够加载类的方法。
于是,我们跟进readObject,果然,ObjectInputStream类的resolveClass方法调用了forName,返回了ReflectionClass类:

具体调用栈如下:

但是,此处的forName执行完后,程序并没有打印”[+] Static Constructor Method.”:

回看刚刚的forName调用,我们发现这里其第二个参数initialize是false:

而我们在反射一节执行的Class clz = Class.forName("javasec1.ReflectionClass");,实际上默认设置initialize为true:

可知,initialize的trueorfalse,决定了在forName找到目标类后,是否进行加载。
因此,ObjectInputStream类的readObject方法调用了forName得证,即反射确确实实存在于反序列化过程中。但由于initialize设置为false,static{}没有在此处执行。
那么问题来了,static{}在哪里执行的呢?
继续跟进,最终,在ObjectStreamClass类的computeDefaultSUID方法中,找到了一个用于检查类是否有静态初始化的方法hasStaticInitializer:

就是这个方法,触发了static{}中代码的执行:

具体调用栈如下:

问题解决,现在来解释为什么刚才的序列化与反序列化操作需要分开进行。我们合起来运行看下效果:

合起来后,首先执行ReflectionClass reflectionClass = new ReflectionClass();,对类进行了加载和初始化,因此有了图中的两行输出。但按照前文对反序列化的分析来看,理应还有一行”[+] Static Constructor Method.”。
我们debug合起来的代码,发现在ObjectStreamClass类的getSerialVersionUID方法中,对类的suid是否为null进行了判断,而此时的suid并不为null,因此没能进入到doPrivileged方法中,也就没有了先前对hasStaticInitializer的调用,没有了对静态初始化的检查,也就不会打印”[+] Static Constructor Method.”。

那么,分开运行时反序列化过程中的suid应该为null才对。我们回头验证一下:

正确。
要理解其中的原因,我们首先要认识一下suid,它是JVM分发给每个可序列化类的一串数字。于是,合起来运行时,第一段代码中对ReflectionClass的初始化使得JVM“认识”了它,有了其suid的记录。而分开运行的第二段代码中,ReflectionClass对于JVM,是一个全新的存在,因此JVM会为其新生成一个suid,这个过程中,触发了类的静态初始化。
值得注意的是,分开运行生成的suid与一起运行的一致,这也体现了suid本身的目的:验证对象的唯一性。

至此,static{}在何处被执行以及序列化与反序列化过程共同、分别运行的差别,均已解释完毕,我们可以继续向下跟进。
ObjectInputStream类的readSerialData方法通过调用hasReadObjectMethod(),检查类是否重写了readObject方法,如果没有,则执行默认方法,即defaultReadFields:

我们尝试在类中重写readObject方法:
1 | package javasec1; |
这里我们可以把序列化和反序列化一起运行了,结果如下:

可见重写的readObject方法被执行。再次跟进到ObjectInputStream类的readSerialData方法,发现程序进入了if分支,调用invokeReadObject方法通过反射invoke了我们重写的readObject方法。


本节输出了反射、类的静态初始化和类重写readObject方法三者在反序列化过程中的利用。至于恶意利用,你可以构造一个恶意类,尝试将反射一节开头展示的计算器代码分别写在static{}和readObject中,随后对其进行序列化与反序列化,查看效果。
JavaSec系列 - 1. 反序列化与反射