java反序列化-基础

前言

本文主要分成四部分

  • java序列化与反序列化的正常使用
  • java反序列化readObject是如何构成漏洞的
  • java反序列化的服务器环境中是否需要序列化的类的定义,才能触发反序列化漏洞?
  • 为何readObject复写是需要使用private属性并且传参java.io.ObjectInputStream这个特定的格式?

首先从使用java反序列化开始

序列化与反序列化的正常使用

先来看代码

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
import java.io.*;
import java.nio.file.attribute.UserDefinedFileAttributeView;
import java.util.Arrays;
import java.util.Base64;

public class trytry {
public static void main(String [] args) throws IOException, ClassNotFoundException {
//整一个需要序列化的对象
user u = new user();
u.setName("lala");
//序列化输出至屏幕
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(u);
System.out.println();
//序列化写入文件
FileOutputStream f = new FileOutputStream("test.bin");
ObjectOutputStream fout = new ObjectOutputStream(f);
fout.writeObject(u);
//序列化写入到变量中
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
ObjectOutputStream objOut = new ObjectOutputStream(bOut);
objOut.writeObject(u);
byte[] str= bOut.toByteArray();
System.out.println(new String(str));
//从变量中反序列化
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(str));
user u_d = (user) ois.readObject();
System.out.println(u_d.getName());
}
}

class user implements Serializable {
private String name;

public String getName() {
return name;
}

public void setName(String name){
this.name = name;
}
}

执行结果如下:

1
2
3
�� sr user_AO���j L namet Ljava/lang/String;xpt lala
�� sr user_AO���j L namet Ljava/lang/String;xpt lala
lala

一个类的对象想要序列化成功,需要满足一个条件:

  1. 该类必须实现java.io.Serializable或Externalizable接口

同时类中不是全部内容都是可以序列化的,还有不能被序列化的情况:

  1. 如果该类有父类,则分两种情况来考虑:
    • 如果该父类已经实现了可序列化接口,则其父类的相应字段及属性的处理和该类相同;
    • 如果该类的父类没有实现可序列化接口,则该类的父类所有的字段属性将不会序列化,并且反序列化时会调用父类的默认构造函数来初始化父类的属性,而子类却不调用默认构造函数,而是直接从流中恢复属性的值。
  2. 如果该类的某个属性标识为static类型的,则该属性不能序列化。
  3. 如果该类的某个属性采用transient关键字标识,则该属性不能序列化。

如果你想知道一个 Java 标准类是否是可序列化的,可以通过查看该类的文档,查看该类有没有实现 java.io.Serializable接口。

漏洞原理

漏洞关键在于调用readObject(),截取以上代码的关键漏洞代码

1
2
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(str))
user u_d = (user) ois.readObject();

那我们试着用网上的payload来复写readObject,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class user implements Serializable {
private String name;

public String getName() {
return name;
}

public void setName(String name){
this.name = name;
}
private void readObject(java.io.ObjectInputStream in) throws IOException {
Runtime.getRuntime().exec("calc.exe");
}
}

修改以上user类执行,会弹出计算器。可见执行的readObject是我们写入的readObject。

第一个问题

那么先来回顾以下,我们以上的代码做了以下事情:

  1. 自己写了一个可覆盖readObject函数的user类
  2. 创建了一个user类的实例
  3. 对之进行序列化,并存入str(这就是我们的payload)
  4. 再从str中反序列化恢复成user类,并输出了user类中的name

那么问题来了,在反序列化的时候,是不是一定要代码中定义了user这个类才会执行我们的payload中的恶意readObject函数呢
因为实际上服务器一般不可能存在一个我们用于复写恶意代码的类,如果必须存在user这个可复写的恶意类,我们的攻击通常不会起作用。

那么试验下

我们做以下实验:

  1. 用之前的代码写入paylaod到一个文件
  2. 在另一个没有user这个类的main函数中中直接读取这个文件内容并进行反序列化。
  3. 全部注释原来java文件中的user这个类(重要)
1
2
3
4
5
6
7
8
9
10
import java.io.*;

public class test_user_class {
public static void main(String [] args) throws IOException, ClassNotFoundException {
//序列化写入文件
FileInputStream f = new FileInputStream("test.bin");
ObjectInputStream fin = new ObjectInputStream(f);
fin.readObject();
}
}


执行代码发生报错:找不到user这个类

  1. 注释user类的所有接口,只留下user这个类的空壳


class接口错误

  1. 只注释user类中的复写readObject函数


未报错,但是未执行calc.exe

  1. 不注释user类


执行calc.exe,弹计算器成功

那么我们可知普通的java反序列化攻击必须要满足以下条件:

  1. 反序列化点输入可控
  2. 使用readObject函数执行反序列化
  3. 当前class空间中存在一个可复写readObject的类
  4. 由于未知的可序列化的readObject类不一定会允许任意代码执行,我们还需要根据当前环境再这个复写readObject类中进一步构筑可利用链(示例代码用的user类简化了这一步骤,实际利用链不在这篇讨论范围内,并且现在的我也不会哈哈)

通常的攻击流程就是:

  1. 先通过目标服务器中可复写的readObject类来构建可任意代码执行的序列化payload
  2. 再通过可控输入点输入payload,执行readObject()函数,从而执行复写的readObject函数,再通过可利用链形成任意代码执行
    (笼统来说是这样的嗷)

第二个问题

我们重新回头看这个我们写的这个user类的readObject函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class user implements Serializable {
private String name;

public String getName() {
return name;
}

public void setName(String name){
this.name = name;
}
private void readObject(java.io.ObjectInputStream in) throws IOException {
Runtime.getRuntime().exec("calc.exe");
}
}

此处存在两个注意点:

  1. readObject函数必须是private属性
  2. readObject函数必须传参java.io.ObjectInputStream in

那么问题来了,到底为什么readObject函数要使用private属性,传参java.io.ObjectInputStream呢?

那么深究一下为啥呢

先在Runtime.getRuntime().exec("calc.exe")处下断点,运行。
得到如下调用栈

根据调用栈的层次来回溯是如何执行到Runtime.getRuntime().exec("calc.exe")
先是ObjectInputStream.javareadObject函数

idea正向调试时,有些函数需要强制进入,虽然在调用最末端下断点是不需要的,这里简单提一下

1
2
3
4
5
6
7
8
9
10
11
12
13
public final Object readObject()
throws IOException, ClassNotFoundException
{
if (enableOverride) {
return readObjectOverride();
}

// if nested read, passHandle contains handle of enclosing object
int outerHandle = passHandle;
try {
//进入这里,默认传入false
Object obj = readObject0(false);
handles.markDependency(outerHandle, passHandle);

一开始我还以为是如果覆盖了enableOverride为true,直接通过readObjectOverride去调用我们的函数。实际上….完全不是= =

1
2
3
4
5
6
7
8
9
10
11
12
13
 private Object readObject0(boolean unshared) throws IOException {
// ...省略...
byte tc;
while ((tc = bin.peekByte()) == TC_RESET) { //此处bin.peekByte()赋值为115,TC_RESET为121。等式不成立。由于自己太菜,不细究为什么115这些底层细节
bin.readByte();
handleReset();
}

try {
switch (tc) {//此时tc是115
// ...省略...
case TC_OBJECT://TC_OBJECT为115,感觉可能是反序列化的目标是一个Object的意思?
return checkResolve(readOrdinaryObject(unshared));//先进入readOrdinaryObject函数,后进入checkResolve函数

先执行readOrdinaryObject函数

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
private Object readOrdinaryObject(boolean unshared)
//省略一个抛出异常,没看懂

ObjectStreamClass desc = readClassDesc(false);//此处会形成一个user类的ObjectStreamClass类
desc.checkDeserialize();//检查是否反序列化?

Class<?> cl = desc.forClass();//获取class user
if (cl == String.class || cl == Class.class
|| cl == ObjectStreamClass.class) {//一些类型不提供支持,报错
throw new InvalidClassException("invalid class descriptor");
}

Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;//很关键 先判断是否有构造器,然后获取class user的实例化obj
} catch (Exception ex) {
throw (IOException) new InvalidClassException(
desc.forClass().getName(),
"unable to create instance").initCause(ex);
}
//以下没搞懂,做一些判断
passHandle = handles.assign(unshared ? unsharedMarker : obj);
ClassNotFoundException resolveEx = desc.getResolveException();
if (resolveEx != null) {
handles.markException(passHandle, resolveEx);
}

if (desc.isExternalizable()) {//进行一次判断,如果序列化的接口是Externalizable类型,就进入readExternalData,否则进入readSerialData
readExternalData((Externalizable) obj, desc);
} else {
readSerialData(obj, desc);//我们进入这个readSerialData函数
}

Externalizable类型的反序列化类型,可以通过writeExternal()和readExternal()方法指定一个类的部分数据进行序列化与反序列化。
Serializable接口也可以实现类似的机制:将不想要序列化的部分添加一个关键字:transient(临时的)。它声明的变量实行序列化操作的时候不会写入到序列化文件中去。

继续readSerialData(),这里我们传入了class user的实例obj,以及class user的ObjectStreamClass类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void readSerialData(Object obj, ObjectStreamClass desc)
throws IOException
{

ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();
for (int i = 0; i < slots.length; i++) {//这边好像是在做遍历需要反序列化的数据
ObjectStreamClass slotDesc = slots[i].desc;

if (slots[i].hasData) {//判断是否有数据
if (obj == null || handles.lookupException(passHandle) != null) {//做一些不知都是什么的意外判断,总之我们进入了else
defaultReadFields(null, slotDesc); // skip field values
} else if (slotDesc.hasReadObjectMethod()) {//很关键,判定我们反序列化的当前class是否存在ReadObject函数,结果为真
//接下来就没看懂在干啥了..好像是在获取上下文?
ThreadDeath t = null;
boolean reset = false;
SerialCallbackContext oldContext = curContext;
if (oldContext != null)
oldContext.check();
try {
curContext = new SerialCallbackContext(obj, slotDesc);

bin.setBlockDataMode(true);
slotDesc.invokeReadObject(obj, this);//进入了这个函数,
....

slotDesc.invokeReadObject(obj, this);

  • slotDesc是我们反序列化的class user的ObjectStreamClass类
  • obj是class user的实例化
  • this就是ObjectInputStream in一类

继续 invokeReadObject()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void invokeReadObject(Object obj, ObjectInputStream in)
throws ClassNotFoundException, IOException,
UnsupportedOperationException
{
requireInitialized();
if (readObjectMethod != null) {//判断是否有readObjectMethod函数
try {
readObjectMethod.invoke(obj, new Object[]{ in });//调用了这个函数,并传入了一个ObjectInputStream的Object列表
} catch (InvocationTargetException ex) {
Throwable th = ex.getTargetException();
if (th instanceof ClassNotFoundException) {
throw (ClassNotFoundException) th;
} else if (th instanceof IOException) {
throw (IOException) th;
} else {
// 省略...

一开始不是理解透彻反射机制,出了java反射机制一文学习了之后回来继续

readObjectMethod.invoke(obj, new Object[]{ in }) 其实就是一处反射机制的使用:

  • readObjectMethod 是 Method类型,和反射中getMethod返回的结果一样
  • readObjectMethod.invoke,调用Method类型的invoke方法,传输进入参数去调用该readObjectMethod函数,也就是我们复写的readObject函数
  • 那么invoke方法需要传入的参数是:
    • 一个之前生成的class user的实例obj
    • 之前传入的ObjectInputStream类型,用new Object[]进行封装(因为invoke()函数接受Object... args作为参数,需要加一层封装保证原始数据,具体参考java反射机制一文)

那么这里就使用反射机制完成了我们写入的readObject函数的调用。
但是我们依旧没有解决为什么我们的user类中复写的readObject函数要使用private属性,传参java.io.ObjectInputStream呢?

我们需要一个对照组

不能复写成功的对照组

对照组user class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class user implements Serializable {
private String name;

public String getName() {
return name;
}

public void setName(String name){
this.name = name;
}
//修改以下readObject类 private 为 public
public void readObject(java.io.ObjectInputStream in) throws IOException {
Runtime.getRuntime().exec("calc.exe");
}
}

先根据对比结果,总结之前的调用栈,做一个总的对比,然后细说

1
2
3
4
5
6
ObjectInputStream.ReadObject() 
-> readObject0()
-> readOrdinaryObject()
-> readSerialData() ===对照组发生分支==> defaultReadFields(obj, slotDesc);
--复写组的调用路径--> invokeReadObject()
-> (反射机制调用)readObjectMethod.invoke(obj, new Object[]{ in });

两组的调用栈前面都是一致的,在readSerialData()中发生了判断分支:

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
private void readSerialData(Object obj, ObjectStreamClass desc)
throws IOException
{

ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();
for (int i = 0; i < slots.length; i++) {//这边好像是在做遍历需要反序列化的数据
ObjectStreamClass slotDesc = slots[i].desc;

if (slots[i].hasData) {//判断是否有数据
if (obj == null || handles.lookupException(passHandle) != null) {//做一些不知都是什么的意外判断,总之我们进入了else
defaultReadFields(null, slotDesc); // skip field values
} else if (slotDesc.hasReadObjectMethod()) {//世界线变动之处,判定我们反序列化的当前class是否存在ReadObject函数,对照组的结果为假
ThreadDeath t = null;
boolean reset = false;
SerialCallbackContext oldContext = curContext;
if (oldContext != null)
oldContext.check();
try {
curContext = new SerialCallbackContext(obj, slotDesc);

bin.setBlockDataMode(true);
slotDesc.invokeReadObject(obj, this);//复写成功的情况,进入了这个函数,
....
} else {
defaultReadFields(obj, slotDesc);//复写失败的对照组,进入了这个函数,最后调用了本身的readObject
}

那么我们可以知道,readObject的复写成功与否与slotDesc.hasReadObjectMethod()紧密相关,

slotDesc其实就是我们反序列化的class,而hasReadObjectMethod()其实就是判断这个反序列化class的ObjectInputStream类中的readObjectMethod参数是否为空。

那么readObjectMethod其实就是在生成ObjectInputStream类的时候就定义好了,到底是怎么生成的呢?我们需要去ObjectInputStream的构造函数中寻找答案

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
43
44
45
private ObjectStreamClass(final Class<?> cl) {
this.cl = cl;
name = cl.getName();
isProxy = Proxy.isProxyClass(cl);
isEnum = Enum.class.isAssignableFrom(cl);
serializable = Serializable.class.isAssignableFrom(cl);
externalizable = Externalizable.class.isAssignableFrom(cl);

Class<?> superCl = cl.getSuperclass();
superDesc = (superCl != null) ? lookup(superCl, false) : null;
localDesc = this;

if (serializable) {//查看是否可反序列化
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (isEnum) {
suid = Long.valueOf(0);
fields = NO_FIELDS;
return null;
}
if (cl.isArray()) {
fields = NO_FIELDS;
return null;
}

suid = getDeclaredSUID(cl);
try {
fields = getSerialFields(cl);
computeFieldOffsets();
} catch (InvalidClassException e) {
serializeEx = deserializeEx =
new ExceptionInfo(e.classname, e.getMessage());
fields = NO_FIELDS;
}

if (externalizable) {//是否Externalizable类型反序列化,为false
cons = getExternalizableConstructor(cl);
} else {//开始解析该类,并填充ObjectStreamClass类的常量,包括我们关注的readObjectMethod
cons = getSerializableConstructor(cl);
writeObjectMethod = getPrivateMethod(cl, "writeObject",
new Class<?>[] { ObjectOutputStream.class },
Void.TYPE);
readObjectMethod = getPrivateMethod(cl, "readObject",
new Class<?>[] { ObjectInputStream.class },
Void.TYPE);//赋值readObjectMethod

查看getPrivateMethod()函数,我们传入了以下参数:

  • cl:class user类
  • name:readObject字符串
  • argTypes:ObjectInputStream.class 的Class列表封装
  • returnType:void类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Returns non-static private method with given signature defined by given
* class, or null if none found. Access checks are disabled on the
* returned method (if any).
*/
private static Method getPrivateMethod(Class<?> cl, String name,
Class<?>[] argTypes,
Class<?> returnType)
{
try {
//获取class user中名为name:readObject字符串,并且接受参数类型为ObjectInputStream.class的方法
Method meth = cl.getDeclaredMethod(name, argTypes);
//开放访问限制
meth.setAccessible(true);
//获取该方法的修饰符
int mods = meth.getModifiers();
return ((meth.getReturnType() == returnType) &&//该方法的返回类型需要为void
((mods & Modifier.STATIC) == 0) &&//该方法不能有Static修饰类型
((mods & Modifier.PRIVATE) != 0)) ? meth : null;//该方法需要是private的,满足所有条件,返回这个方法
} catch (NoSuchMethodException ex) {
return null;
}
}

那么通过getPrivateMethod函数的判定,只有满足以下条件的方法,readObjectMethod才会被赋值(即,readObject才会被赋值,即进入复写的世界线,执行被复写的参数):

  • 方法名为readObject
  • 返回类型为void
  • 传入参数为一个ObjectInputStream.class类型参数
  • 修饰符不能包含static
  • 修饰符必须包含private

小结

至此我们从源码彻底搞清楚了readObject为什么会形成复写,

这不是java设计导致的漏洞,而是本身的设计就是允许readObject复写,甚至详细制定了判定何种参数设定会解析为readObject复写

那么就产生了几个疑问:

  1. 一直以来java反序列化漏洞频出,为什么java要支持readObject复写?

因为类的实例序列化,不会将static变量和transient变量序列化,如果我们需要序列化这些变量就需要复写readObject函数,像下面这样:

(01) 通过writeObject()方法,写入要保存的变量。writeObject的原始定义是在ObjectOutputStream.java中,我们按照如下示例覆盖即可:

1
2
3
4
5
private void writeObject(ObjectOutputStream out) throws IOException{ 
out.defaultWriteObject();// 使定制的writeObject()方法可以利用自动序列化中内置的逻辑。
out.writeInt(ival); // 若要保存“int类型的值”,则使用writeInt()
out.writeObject(obj); // 若要保存“Object对象”,则使用writeObject()
}

(02) 通过readObject()方法,读取之前保存的变量。readObject的原始定义是在ObjectInputStream.java中,我们按照如下示例覆盖即可:

1
2
3
4
5
private void readObject(ObjectInputStream in) throws IOException,ClassNotFoundException{ 
in.defaultReadObject(); // 使定制的readObject()方法可以利用自动序列化中内置的逻辑。
int ival = in.readInt(); // 若要读取“int类型的值”,则使用readInt()
Object obj = in.readObject(); // 若要读取“Object对象”,则使用readObject()
}

  1. 为什么要规定传参,规定private修饰,void返回类型呢?

emmm,一个很微妙的问题,可能有为啥,可能….就是没有为啥,一个规范吧。目前母鸡,也没想去多深究了。

最后,在网上看到:

readObject方法根本就不能被重写,因为原本的readObject方法被申明为final,该类型的方法不能被重写

我们从源码上分析来说,这种说法应该是正确的,我们并没有重写readObject,而是执行了一个自定义的全新函数,但是也不知道用词汇去修正这个形容,我们全文也还是再说readObject复写,自己清楚就好。

参考文章

https://www.smi1e.top/java%e5%8f%8d%e5%ba%8f%e5%88%97%e5%8c%96%e5%9f%ba%e7%a1%80/
解决为何复写函数需要private属性和ObjectInputStream作为参数:
膜大佬
膜程序员