起因是想重新了解下反序列化,在看HashMap的readObject时,看到了defaultReadObject(),想到在重写readOject()中基本都遇到过,于是想了解下这个函数的具体作用以及实现原理。

readObject

先看下执行原生readOject()都发生了什么

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SerializeTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Animals animals = new Animals("111", "222", "333");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(animals);
byte[] bytes = bos.toByteArray();

ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis);
Animals o = (Animals)ois.readObject();
System.out.println(o.toString());
}
}

Animals

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Animals implements Serializable {
public String dog;
public String cat;
public transient String pet;

public Animals(String dog, String cat, String pet) {
this.dog = dog;
this.cat = cat;
this.pet = pet;
}

@Override
public String toString() {
return "Animals{" +
"dog='" + dog + '\'' +
", cat='" + cat + '\'' +
", pet='" + pet + '\'' +
'}';
}

}

流程分析

在执行readOject()后会执行readOject0,上半部分主要是快数据模式的判断,详细点说就是:在序列化和反序列化时,会采用块模式进行存储提高效率

image-20230910223737867

而这里就是判断是否为该模式,是的话则检查当前块中是否还有剩余的数据,如果有的话则说明(有剩余未处理的数据),会抛出异常;如若没问题,最后就是将该模式设为false关闭掉。

这个块数据模式的设置就在ObjectInputStream,其中bin的值就是该模式下的序列化数据

1
2
3
4
5
6
7
8
9
public ObjectInputStream(InputStream in) throws IOException {
verifySubclass();
bin = new BlockDataInputStream(in);
handles = new HandleTable(10);
vlist = new ValidationList();
enableOverride = false;
readStreamHeader();
bin.setBlockDataMode(true);
}

之后tc = bin.peekByte()) == TC_RESET 进行读取一个字节的数据来进行判断,经过一级级调用最后到了read()此时pos为4,

peekByte方法,不是读取当前的字节,而是后一个字节。但是readByte方法则是读取当前所在的字节,不是后一个。这两个方法还是不一样的!

所以取出的数据即:115赋值给tc(前边的-84,-19,0,5是序列化数据开头的标识信息)

image-20230910224632288

switch语句找到对应115的case语句,处理对象数据,跟进下readOrdinaryObject

1
2
case TC_OBJECT:
return checkResolve(readOrdinaryObject(unshared));

首先判断读取一个字节数据进行判断(刚有提到readByte是读取当前字节,所以及时pos刚才自增了单读出来的还是115),接着到了readClassDesc()读取类描述符。

image-20230910230103059

这次调用的是peekByte(),所以读取下一个字节114,进入TO_CLASSDESC处理类对象 (现在等于是在TC_OBJECT 对象数据中处理TO_CLASSDESC类数据)

image-20230910230556731

进入readNonProxyDesc(),主要分为两部readClassDescriptor()resolveClass()

image-20230910230950256

先看readClassDescriptor(),里边又执行了readNonProxy(),主要是拿到类中的两个字段——>cat、dog

image-20230910231235708

取出后回到readNonProxyDesc(),将readClassDescriptor()获取到的类及其字段返回给readDesc属性,通过resolveClass()默认类加载器加载了readDesc指定名称的类即Animals,并触发类的初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException
{
String name = desc.getName();
try {
return Class.forName(name, false, latestUserDefinedLoader());
} catch (ClassNotFoundException ex) {
Class<?> cl = primClasses.get(name);
if (cl != null) {
return cl;
} else {
throw ex;
}
}
}

接着回到readOrdinaryObject(),将刚才加载的类赋值给desc,并判断其是否可以进行反序列化,接着通过forClass拿到目标class对象

image-20230910231704288

下边执行isInstantiable()判断其是否可以进行实例化,之后实例化了desc,这时obj就成为了真正的Animals对象。(这里有个单例问题后边再说)

Animals实例创建好之后,还需要对其中的每个字段进行填充,相关操作体现在readSerialData方法中

image-20230910232845616

readSerialData方法有两种情况:

这个方法首先会对通过hasReadObjectMethod()对传入的对应描述符对象进行判断,看其是否存在readObjectMethod属性,存在就通过反射调用

image-20230910233216207

而我们在Animals类中并没有重写readOject(),所以走到了下边else中的defaultReadFields()

其中通过反序列化读取到各字段的值,并反射对其进行填充,此刻obj对象实例,已经有了对应的字段和值

image-20230910233647638

单例问题

走出readSerialData()后,就到了下边的hasReadResolveMethod(),判断是否有ReadResolve方法

image-20230910233905584

而这个方法其实就是为了解决单例问题的,在前边有这么一段代码,当调用newInstance()进行实例化后,此时JVM会为其创建一个新的实例对象,这种做法会破坏单例模式。

1
2
3
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {

所以可以用readResolve()解决这个问题

1
2
3
private Object readResolve() {
return new Animals();
}

当然这个对当前反序列化没有影响,所以了解下即可,可参考:单例、序列化和readResolve()方法 - 知乎 (zhihu.com)

重写readObejct

在上边提到readSerialData()有两种情况,分别是调用readObject()defaultReadFields()填充字段,所以这里看下重写readOject()后会怎样

在Animals类中加上:

1
2
3
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {

}

前边都是一样的所以就不看了,直接到readSerialData()里,由于这次已经有了readOject()方法,所以在hasReadObjectMethod()判断结束后,进入了if语句,反射调用了重写的readObject()

image-20230910234840705

由于我们没有在readObejct()中,并没有写readUTF()或其他实例化语句,所以这里并没有对属性进行填充,就导致了:

image-20230910235433620

这时就引出了defaultReadObject()

defaultReadObject

defaultReadObject其实在重写的readOject方法中很多都有见过

根据注解,作用主要是:从该流中读取当前类的非静态和非瞬态字段。这只能从正在反序列化的类的readObject方法调用。

image-20230908135654928

其实就是对各个字段值进行初始化,defaultReadObject() 主要执行以下操作:

  1. 根据默认的规则从输入流中读取字节流,将其转换为对象的字段值。
  2. 将读取到的字段值分配给相应的字段。

流程分析

readObject()改成

1
2
3
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
}

还是到之前反射调用readOject()的部分,这时执行了defaultReadObject()

image-20230910235717113

之后执行到了defaultReadFields(),在上边字段填充中提到过,剩下过程就跟之前一样了

image-20230910235817757

此时执行后的结果:

image-20230910235953222

Others

在CTF中,有通过对Resolve()重写,进行二次反序列化的利用,如:javaDeserializeLabs — labs5、AliyunCTF — ezbean

除此外还见过一次通过resolveClass设置拦截点的题,做个简单记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyownObjectInputStream extends ObjectInputStream{
private ArrayList Blacklist=new ArrayList();
public MyownObjectInputStream(InputStream in) throws IOException {
super(in);
this.Blacklist.add(Hashtable.class.getName());
this.Blacklist.add(HashSet.class.getName());
this.Blacklist.add(JdbcRowSetImpl.class.getName());
this.Blacklist.add(TreeMap.class.getName());
this.Blacklist.add(HotSwappableTargetSource.class.getName());
this.Blacklist.add(XString.class.getName());
this.Blacklist.add(BadAttributeValueExpException.class.getName());
this.Blacklist.add(TemplatesImpl.class.getName());
this.Blacklist.add(ToStringBean.class.getName());
}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
if (this.Blacklist.contains(desc.getName())) {
throw new InvalidClassException("dont do this");
} else {
return super.resolveClass(desc);
}

}