JavaSec系列 - 1. 反序列化与反射

本章源码: https://github.com/hey3e/JavaSec-Code/tree/main/javasec1

(1)序列化与反序列化

在Java中,我们创建的对象会随着其JVM的销毁而销毁。但有时,我们希望能在其他JVM、或是其他机器上复用这个对象。序列化允许我们将Java对象转换为字节流,便于存储到本地,以及通过网络发送给其他机器。而反序列化允许我们重新将序列化的字节流还原为Java对象。

我们首先来看一下序列化的实现:

1
2
3
4
5
6
String string1 = "test";

FileOutputStream fileOutputStream = new FileOutputStream("test.db");
ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
objectOutputStream.writeObject(string1);
objectOutputStream.close();

这段代码中,通过调用ObjectOutputStream类的writeObject方法,我们序列化了”test”字符串对象,并将其字节流存储到本地:

1
aced 0005 7400 0474 6573 74

其中,aced 0005是序列化数据的特征。

再来看一下反序列化的实现:

1
2
3
4
5
FileInputStream fileInputStream = new FileInputStream("test.db");
ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
String string2 = (String) objectInputStream.readObject();
System.out.println(string2);
objectInputStream.close();

这段代码调用ObjectInputStream类的readObject方法实现反序列化,恢复了”test”字符串对象。

(2)反射

反射是指程序在运行过程中动态地去获取指定对象。

对于打开计算器的代码:

1
Runtime.getRuntime().exec("/System/Applications/Calculator.app/Contents/MacOS/Calculator");

用反射实现为:

1
2
3
4
Class clz = Class.forName("java.lang.Runtime");
Method getRuntime = clz.getMethod("getRuntime");
Runtime runtime = (Runtime) getRuntime.invoke(clz);
clz.getMethod("exec", String.class).invoke(runtime, "/System/Applications/Calculator.app/Contents/MacOS/Calculator");

其中,forName用于获取类,getMethodinvoke分别用于获取和执行方法。

这里我们重点讨论一下forName方法。构造这样一个类:

1
2
3
4
5
6
7
8
9
10
11
package javasec1;

public class ReflectionClass {
static {
System.out.println("[+] Static Constructor Method.");
}

public ReflectionClass() {
System.out.println("[+] Constructor Method.");
}
}

它包含一个静态初始化块static{}和一个构造方法。其中,static{}会在类加载时执行,构造方法会在类初始化时执行。

我们调用forName来获取它:

1
Class clz = Class.forName("javasec1.ReflectionClass");

运行这单行代码,发现static{}被执行:

可以得知,forName查找并加载了类,但未初始化。初始化调用newInstance方法即可:

1
2
Class clz = Class.forName("javasec1.ReflectionClass");
clz.newInstance();

(3)反序列化利用

反序列化的利用实际上与反射息息相关。我们来看这样两段代码:(两段代码此处需要分开运行,后面会给出解释)

1
2
3
4
5
6
ReflectionClass reflectionClass = new ReflectionClass();

FileOutputStream fileOutputStream = new FileOutputStream("util.db");
ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
objectOutputStream.writeObject(reflectionClass);
objectOutputStream.close();
1
2
3
4
FileInputStream fileInputStream = new FileInputStream("util.db");
ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
ReflectionClass object = (ReflectionClass) objectInputStream.readObject();
objectInputStream.close();

很明显,两段代码分别对前文构造的ReflectionClass类进行了序列化与反序列化的操作。执行第一段代码,发现报错:

这是因为现在的ReflectionClass类缺乏序列化的必要条件:实现Serializable接口。于是对ReflectionClass进行修改,使其实现Serializable接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
package javasec1;

import java.io.Serializable;

public class ReflectionClass implements Serializable {
static {
System.out.println("[+] Static Constructor Method.");
}

public ReflectionClass() {
System.out.println("[+] Constructor Method.");
}
}

序列化成功后,我们执行第二段代码进行反序列化:

发现static{}被执行了。据此,我们推测:readObject方法中调用了forName或其他能够加载类的方法。

于是,我们跟进readObject,果然,ObjectInputStream类的resolveClass方法调用了forName,返回了ReflectionClass类:

具体调用栈如下:

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

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

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

可知,initializetrueorfalse,决定了在forName找到目标类后,是否进行加载。

因此,ObjectInputStream类的readObject方法调用了forName得证,即反射确确实实存在于反序列化过程中。但由于initialize设置为falsestatic{}没有在此处执行。

那么问题来了,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package javasec1;

import java.io.IOException;
import java.io.Serializable;

public class ReflectionClass implements Serializable {
static {
System.out.println("[+] Static Constructor Method.");
}

public ReflectionClass() {
System.out.println("[+] Constructor Method.");
}

private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
System.out.println("[+] My readObject.");
}
}

这里我们可以把序列化和反序列化一起运行了,结果如下:

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

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

Author

yekc1m

Posted on

2021-12-22

Updated on

2021-12-27

Licensed under