Java反序列化

Java学习

Java反序列化

在Java中,序列化过程分为两大部分:序列化和反序列化。

  • 序列化:将对象的状态转换为可存储或传输的格式的过程。例如,将对象转换为字节流或文本格式(如 JSON、XML 等)。这样可以将对象保存到文件、数据库或者通过网络传输。
  • 反序列化:将序列化后的数据恢复为对象的过程。也就是说,将字节流或文本格式的数据重新转换为内存中的对象。

这两部分共同构成了序列化过程,确保对象可以被持久化存储或远程传输,并在需要时恢复原始的对象状态。

如何实现

在Java中实现对象反序列化非常简单,实现java.io.Serializable(内部序列化)java.io.Externalizable(外部序列化)接口即可被序列化。下面有几个点需要说明:

Serializable 接口

源代码如下:

1
2
public interface Serializable {
}

一个对象想要被序列化,那么它的类就要实现此接口或者它的子接口。

这个对象的所有属性(包括private属性和其引用的对象)都可以被序列化和反序列化来保存、传递。不想序列化的字段可以使用transient修饰

由于 Serializable 对象完全以它存储的二进制位为基础来构造,因此并不会调用任何构造函数,因此Serializable类无需默认构造函数,但是当Serializable类的父类没有实现Serializable接口时,反序列化过程会调用父类的默认构造函数,因此该父类必需有默认构造函数,否则会抛异常。

使用transient关键字阻止序列化虽然简单方便,但被它修饰的属性被完全隔离在序列化机制之外,导致了在反序列化时无法获取该属性的值,而通过在需要序列化的对象的Java类里加入writeObject()方法与readObject()方法可以控制如何序列化各属性,甚至完全不序列化某些属性或者加密序列化某些属性。

在这里还需要了解一个点,那就是static修饰的字段是绑定在类上的,而不是对象上。static优先于对象存在,所以static修饰的字段不会被序列化。

Externalizable 接口

对于这个接口的使用可以参考最后面的参考文章。

源代码如下:

1
2
3
4
5
6
7
public interface Externalizable extends java.io.Serializable {
    
    void writeExternal(ObjectOutput out) throws IOException;

    
    void readExternal(ObjectInput in) throws IOException,ClassNotFoundException;
}

它是Serializable接口的子类,这个接口里面定义了两个抽象的方法,用户需要重载writeExternal()和readExternal()方法,用来决定如何序列化和反序列化。

因为序列化和反序列化方法需要自己实现,因此可以指定序列化哪些属性,而transient在这里无效。

对Externalizable对象反序列化时,会先调用类的无参构造方法,这是有别于默认反序列方式的。如果把类的不带参数的构造方法删除,或者把该构造方法的访问权限设置为private、默认或protected级别,会抛出java.io.InvalidException: no valid constructor异常,因此Externalizable对象必须有默认构造函数,而且必需是public的。

serialVersionUID字段

这个字段可以在序列化过程中控制序列化的版本。一般格式就是下面这个:

image-20250122132619032

一个对象数据,在反序列化过程中,如果序列化串中的serialVersionUID与当前对象值不同,则反序列化失败,会报错,否则成功。

如果serialVersionUID没有显式生成,系统就会自动生成一个。属性的变化都会导致自动生成的serialVersionUID发生变化。如果序列化和反序列化的serialVersionUID不同,则会报序列化版本不同的错误。

如果我们保持了serialVersionUID的一致,则在反序列化时,对于新增的字段会填入默认值null(int的默认值0),对于减少的字段则直接忽略。

其他类

image-20250122132629067

如上图,现在我们来了解一下ObjectInputStream和ObjectOutputStream。

ObjectOutputStream

这个类与序列化相关

部分源码如下:

1
2
public class ObjectOutputStream extends OutputStream implements ObjectOutput, ObjectStreamConstants
{}

java.io.ObjectOutputStream继承自 OutputStream 类,因此可以将序列化后的字节序列写入到文件、网络等输出流中。

现在来看这个类的构造方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public ObjectOutputStream(OutputStream out) throws IOException {
        verifySubclass();
        bout = new BlockDataOutputStream(out);
        handles = new HandleTable(10, (float) 3.00);
        subs = new ReplaceTable(10, (float) 3.00);
        enableOverride = false;
        writeStreamHeader();
        bout.setBlockDataMode(true);
        if (extendedDebugInfo) {
            debugInfoStack = new DebugTraceInfoStack();
        } else {
            debugInfoStack = null;
        }
    }

该构造方法接收一个 OutputStream 对象作为参数,并且在实例化时将变量enableOverride设置为false。

例如:

1
2
3
4
//这里会创建一个FileOutputStream流以写入数据到File对象所代表的文件
FileOutputStream fos = new FileOutputStream("file.txt");
//
ObjectOutputStream oos = new ObjectOutputStream(fos);

这里序列化想要利用就要用到ObjectOutputStream这个类的writeObject方法,writeObject()方法源码如下:

image-20241226164402403

前面在实例化时将enableOverride设置为false,所以这里真正起作用的是writeObject0()方法。

ObjectInputStream

这个类和反序列化相关,它可以读取 ObjectOutputStream 写入的字节流,并将其反序列化为相应的对象。

部分构造函数源码如下:

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);
    }

核心方法是readObject(),源码如下:

 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
public final Object readObject()
        throws IOException, ClassNotFoundException
    {
        if (enableOverride) {
            return readObjectOverride();
        }

        // if nested read, passHandle contains handle of enclosing object
        int outerHandle = passHandle;
        try {
            Object obj = readObject0(false);
            handles.markDependency(outerHandle, passHandle);
            ClassNotFoundException ex = handles.lookupException(passHandle);
            if (ex != null) {
                throw ex;
            }
            if (depth == 0) {
                vlist.doCallbacks();
            }
            return obj;
        } finally {
            passHandle = outerHandle;
            if (closed && depth == 0) {
                clear();
            }
        }
    }

可以看到最后返回的是反序列化后的对象。

序列化

测试代码如下:

Test.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package org.example;
import java.io.Serializable;

class Test implements Serializable{
    private String name = "fupanc" ;
    protected char height = 'A' ;
    public transient String sex = "boy";
    private static int age = 1111 ;
    public void setName(String name){
        this.name=name;
    }
    public String getName(){
        return this.name;
    }
    public String getSex(){
        return this.sex;
    }
    public int getAge(){
        return this.age;
    }
}

Main.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package org.example;

import java.io.ObjectOutputStream;
import java.io.FileOutputStream;

class Main{
    public static void main(String[] args) throws Exception {
        Test p = new Test();
        p.setName("haha");
        ObjectOutputStream x = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        x.writeObject(p);
        x.close();
    }
}

成功生成ser.ser文件,十六进制打开看一下,如下:

image-20250122132646248

可以看出这里只序列化了height和name,而sex和age并没有被序列化。所以在这里就可以知道正如前面说的,使用transient和static修饰的变量不会被序列化

反序列化

反序列化对象时有如下限制:

  • 被反序列化的类必须存在。
  • serialVersionUID值必须一致。

同样使用上面的Test.java。这里就只给Main.java,反序列化代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package org.example;

import java.io.FileInputStream;
import java.io.ObjectInputStream;

class Main {
    public static void main(String[] args) throws Exception {
        ObjectInputStream p = new ObjectInputStream(new FileInputStream("ser.ser"));
        Test ctf = (Test)p.readObject();//这里由于返回类型不同,需要强制转换
        System.out.println("反序列化后的name:"+ctf.getName());
        System.out.println("反序列化后的sex:"+ctf.getSex());
        System.out.println("反序列化后的age:"+ctf.getAge());
    }
}

输出结果如下:

1
2
3
反序列化后的name:haha
反序列化后的sex:null
反序列化后的age:1111

解读一下结果:

  • sex为null,就是因为我在Test类用transient修饰,所以在序列化时并不会将sex字段序列化,所以这里并没有值。
  • age为1111,这就与static有关了,这是因为static为全局变量,在JVM中所有实例都会共享该字段。

对比一下,刚好可以再说明一个点

Test.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package org.example;
import java.io.Serializable;

class Test implements Serializable{
    private static String name = "fupanc" ;    //这里添加static
    public String sex = "boy";    //这里去除transient
    private static int age = 1111 ;
    public void setName(String name){
        this.name=name;
    }
    public String getName(){
        return this.name;
    }
    public String getSex(){
        return this.sex;
    }
    public int getAge(){
        return this.age;
    }
}

Main.java (省去序列化过程,注意这里是将序列化和反序列化分开进行的)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package org.example;

import java.io.ObjectOutputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.FileInputStream;

class Main{
    public static void main(String[] args){
        try{
        //反序列化
        ObjectInputStream y = new ObjectInputStream(new FileInputStream("ser.ser"));
        Test ctf = (Test)y.readObject();
        System.out.println("反序列化后的name:"+ctf.getName());
        System.out.println("反序列化后的sex:"+ctf.getSex());
        System.out.println("反序列化后的age:"+ctf.getAge());           
        } catch(Exception e){
            e.printStackTrace();
        }
    }
}

输出为:

1
2
3
反序列化后的name:fupanc
反序列化后的sex:boy
反序列化后的age:1111

从这个结果可以更加说明static修饰的字段不会被序列化的特性,以及更加清楚了是否使用transient结果的不同。

但是在前一个测试过程中,发现了一个问题,

Test.java还是之前那个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package org.example;
import java.io.Serializable;

class Test implements Serializable{
    private static String name = "fupanc" ;    //这里添加static
    public String sex = "boy";    //这里去除transient
    private static int age = 1111 ;
    public void setName(String name){
        this.name=name;
    }
    public String getName(){
        return this.name;
    }
    public String getSex(){
        return this.sex;
    }
    public int getAge(){
        return this.age;
    }
}

Main.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
package org.example;

import java.io.ObjectOutputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.FileInputStream;

class Main{
    public static void main(String[] args){
        try{
            //序列化
            Test p = new Test();
            p.setName("haha");
            ObjectOutputStream x = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            x.writeObject(p);
            x.close();

            //反序列化
            ObjectInputStream y = new ObjectInputStream(new FileInputStream("ser.ser"));
            Test ctf = (Test)y.readObject();
            System.out.println("反序列化后的name:"+ctf.getName());
            System.out.println("反序列化后的sex:"+ctf.getSex());
            System.out.println("反序列化后的age:"+ctf.getAge());
        } catch(Exception e){
            e.printStackTrace();
        }
    }
}

运行结果:

1
2
3
反序列化后的name:haha
反序列化后的sex:boy
反序列化后的age:1111

注意这里的name的结果为haha,但是之前分开序列化和反序列化时的name的值为fupanc。那么为什么会出现这样的结果呢?解答如下(个人理解):

  • 有想过为什么在反序列化的时候要引入包吗,

  • 同时可以看给出来的ser.ser文件的十六进制表示,其中并没有序列化方法,并且只存在sex(这是因为其他两个变量都被我设置为了static,所以不会被序列化),那么是否想过为什么在序列化的时候没有序列化方法。

image-20250122132657630

个人理解如下,对于序列化,它只序列化对象的的状态,而方法属于类的定义部分,不属于对象的状态部分,所以方法并不是被序列化,所以,如果我们想要再次利用这个Test类,需要引入包,从而使得可以对应到方法来利用。

两种不同结果的利用方法最大的不同在于一个地方,JVM加载进程。

在分开序列化和反序列化时,是分别运行了两次,即进行了两次JVM加载,但是这个static静态加载是存在于“当前”进程的,并且看前面序列化后的文件内容,是不存在static关键字修饰的变量的,static修饰的变量是绑定在对象上的,而是直接存在于内存中的,所以在第二次加载时会直接将内存中存在的fupanc赋值给name。

而序列化和反序列化一起,对比一下,基本就清楚了,同时进行,在序列化之前将static修饰的name改为haha,并加载进了内存中,然后在反序列化时直接在内存中找到了这个值赋值给了name。

综上,造成这个差异的主要有两个点:

  • 序列化的特性
  • “JVN加载特性”

一个知识点

这里需要注意的一个点,我们可以通过在待序列化或反序列化的类中定义readObjectwriteObject方法,来实现自定义的序列化和反序列化操作,当然前提是,被序列化的类必须有此方法,并且方法的修饰符必须是private。代码参考如下:

Test.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package org.example;
import java.io.Serializable;

class Test implements Serializable{
    public String cmd;
    private void readObject(java.io.ObjectInputStream stream) throws Exception{
        stream.defaultReadObject();//调用ObjectInputStream默认反序列化方法
        Runtime.getRuntime().exec(cmd);
    }
}

Main.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package org.example;

import java.io.*;

class Main{
    public static void main(String[] args) throws Exception{
        Test haha = new Test();
        haha.cmd = "calc";

        ObjectOutputStream obj = new ObjectOutputStream(new FileOutputStream("haha.ser"));
        obj.writeObject(haha);
        obj.close();

        ObjectInputStream ceshi = new ObjectInputStream(new FileInputStream("haha.ser"));
        ceshi.readObject();
        ceshi.close();
    }
}

成功弹出计算机

image-20250122132709334

这样就确实自定义了反序列化的方法,序列化同理。

ysoserial工具

ysoserial集合了各种java反序列化的利用链。

利用链也叫"gadget chains",我们通常称为gadget。

直接下载编译好的jar文件就能用:

1
https://github.com/frohoff/ysoserial

使用很简单,如下简单POC:

1
java -jar ysoserial-master.jar CommonsCollections1 "id" 

参考文章:

https://javasec.org/javase/JavaDeserialization/Serialization.html

https://blog.csdn.net/mocas_wang/article/details/107621010

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计