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
的true
orfalse
,决定了在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. 反序列化与反射