漫谈JAVA序列化

Posted 小小本科生

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了漫谈JAVA序列化相关的知识,希望对你有一定的参考价值。

一、使用场景

对象序列化用来将对象编码成字节流(序列化),并从字节流编码中重新构建对象(反序列化)。一旦对象被序列化后,它的编码就可以从一台正在运行的虚拟机上被传输到另一台虚拟机上,或者被存储到磁盘上,以供后续反序列化使用。实际场景:在网络上传输的对象必须要序列化,比如RMI(远程方法调用);或需要将内存中的对象存储在文件中。
本文适合细细品尝,猴急想学用法的小伙伴请直接跳到第三章

二、序列化的危害(劝退)

其实看到能看到这篇文章你已经误入歧途了。早在Java中新增序列化这项功能时就被认为是有风险的,而且在使用过程中弊大于利。如果你在开发一个新的系统,请在阅读本章之后自行学习其他跨平台结构化数据表示法来替代Java的序列化,比如JSON(基于文本的序列化方法,人类可以阅读)或Protobuf(基于二进制,计算机阅读;当然你想读的话,它也提供了文本表示法)。如果你正在维护一个使用了序列化的系统或者不得不使用Java序列化,那么请在遵循本文建议的情况下谨慎使用。
下面具体讲一下Java序列化的危害。

  1. 一旦一个类实现了Serializable接口,就大大降低了改变这个类的灵活性。
    假如你现在做的功能是客户端收到服务端序列化后的对象后将其反序列化,然后展现给用户。第一版代码运行得很好,突然有一天你想为序列化对象添加一个新的属性或者新的功能,那么客户端就无法再反序列化这个对象了。如果只有一个客户端在使用,好,将客户端的对象信息和反序列化逻辑也改了;可是如果你发布的是一个SDK或者一个被广泛使用的工具类,有成千上万的用户在使用你暴露出的API,当你发布一个修改了序列化对象信息的新版本后所有用到你接口的地方都将无法反序列化(后文会将如何避免这种不兼容)。所以你在发布一个需要被序列化的类时,一定要花时间设计它,让它尽可能地可扩展。
  2. 将不被信任的流进行反序列化可能导致远程代码执行、拒绝服务以及一系列其他攻击
    假如你实现的功能是服务端收到客户端序列化的对象后将其反序列化,问题也是一样(这不是废话嘛)。但是会引起更加严重的问题。假设这样一个场景,服务端需要反序列化的类中有一个属性,其类型是HashSet。此时,恶意用户为你精心设计了一个字节流,他执行了下面的方法来包装这个HashSet。这是一个100层嵌套的HashSet,每一层的Set中都包含两个元素(第一层只有一个),一个字符串元素"shutdown"和一个Set元素。服务端在反序列化时需要计算每个元素的散列码,这就会导致hashCode()函数被调用 2 100 2^100 2100次,这很容易就造成了一次拒绝服务攻击。当然还有针对序列化的其他攻击方式,这里就不列举了。
Set<Object> safeSet = new HashSet();
Set<Object> customerSet = safeSet;
public static byte[] unSafeFunction() 
	for(int i = 0;i < 100;i++) 
		Set<Object> tmp = new HashSet();
		tmp.put("shutdown");
		customerSet.put(tmp);
		customerSet= tmp;
	
	return serialize(safeSet);

因此,永远不要反序列化不被信任的数据。Java官方安全编码指导方针中提出:“对不信任数据的反序列化,从本质上来说是危险的,应该予以避免。”
3. 反序列化会使对象的约束关系受到破坏
反序列化时并不是通过构造函数来实例化对象,而是直接通过流信息重构出来的(通过readObject()方法)。如果这样讲大家没什么值观感受的化,我直接上代码。有一个表示成年人的类Adult,如果年龄小于18岁,实例化时将会抛出IllegalArgumentException。但是恶意用户却可以构造出年龄小于18岁的Adult对象流给你,反序列化后你就得到了小于18岁的成年人。再次申明,反序列化时并没有调用构造函数,并没有走构造函数中的逻辑。关于如何避免这个问题,会在后文提及。

public class Adult implements Serializable 
    private String name;
    private int age;
    private Date birthday;
    public Adult(String name, int age, Date birthday) 
        if(age < 18) 
            throw new IllegalArgumentException("未成年人!!!");
        
        this.name = name;
        this.age = age;
        birthday = new Date(birthday.getTime());
    
    public Date getBirthday() 
        return new Date(birthday.getTime());
    

这里再提一嘴,我们在为birthday属性复制时采用了保护性拷贝,当传入的Date对象改变时,我们的birthday属性依然不会改变;而且没有提供set方法,也就是说一旦birthday传入后就是不可变的了。但是对象反序列化时通过一定的手段却可以让birthday属性变得可变。扯远了,有兴趣的小伙伴自行了解吧。

三、序列化的使用方式

  1. 实现Serializable接口或Externalizable接口
    这里先介绍Serializable。如下,仅仅实现Serializable接口,什么都不用做就可以实现Adult对象的序列化。当然这里使用的是默认的序列化方式
public class Adult implements Serializable 
    private String name;
    private int age;
    private Date birthday;
    public Adult(String name, int age, Date birthday) 
        this.name = name;
        this.age = age;
        this.birthday = new Date(birthday.getTime());
    

  1. 进阶,自定义序列化方式【writeObject()、readObject()】
    再回到刚刚的问题,我们需要保证反序列化的Adult对象年龄必须大于等于18岁。这时候就要重写readObject()方法和writeObject方法。其中readObject方法定义对象序列化时的操作,writeObject()方法定义对象反序列化时的操作。
public class Adult implements Serializable 
    private String name;
    private int age;
    private Date birthday;
    public Adult(String name, int age, Date birthday) 
        this.name = name;
        this.age = age;
        if(this.age < 18) 
            throw new IllegalArgumentException("未成年人!!!");
        
        this.birthday = new Date(birthday.getTime());
    

    /**
     * 重写此方法,并在此方法中定义序列化逻辑
     * 可以在此方法中做一些加密工作或其他操作
     * @param out
     * @throws IOException
     */
    private void writeObject(ObjectOutputStream out) throws IOException 
        out.writeObject(name.toUpperCase());
        out.writeInt(age + 15);
        out.writeObject(birthday);
    

    /**
     * 重写此方法,并在此方法中定义反序列化逻辑
     * 可以在此方法中做一些解密工作或定义一些约束关系
     * @param in
     * @throws IOException
     * @throws ClassNotFoundException
     */
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException 
        // 反序列化取对象属性时,一定要序列化存对象属性时的顺序保持一致
        this.name = (String) in.readObject();
        this.age = in.readInt() - 14;
        if(this.age < 18) 
            throw new IllegalArgumentException("未成年人!!!");
        
        this.birthday = (Date) in.readObject();
    

    @Override
    public String toString() 
        return "Adult" +
                "name='" + name + '\\'' +
                ", age=" + age +
                ", birthday=" + birthday +
                '';
    

    public static void main(String[] args) throws IOException, ClassNotFoundException 
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\\\\adult.txt"));
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:\\\\adult.txt"));
        Adult adult = new Adult("Mr john", 24, new Date());
        System.out.println(adult);
        oos.writeObject(adult);
        Adult adult1 = (Adult) ois.readObject();
        System.out.println(adult1);
    

通过在writeObject()方法中限制age属性不能小于18,如果恶意用户再构造非法字节流传入,解析的时候就会直接抛出异常。此外我们还可以在writeObject()方法中定义一些加密方法,然后在readObject()方法中定义一些解密算法,让数据传输变得更加安全。

  1. 为序列化类添加serialVersionUID
    自从学会了自定义序列化和反序列化方法,系统用了一段时间感觉都没出问题,心里美极了。突然有一天产品说需要加一个gender字段来标识Adult对象的性别,这也太简单了,反手就是增加一个boolean字段,空间占用少,等着被夸吧。
// true:男;false:女
private boolean gender;

当我们尝试解析adult.txt时却出问题了

    public static void main(String[] args) throws IOException, ClassNotFoundException 
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:\\\\adult.txt"));
        Adult adult1 = (Adult) ois.readObject();
        System.out.println(adult1);
    


仔细一看原来是serialVersionUID不同。什么是serialVersionUID?如果我们没有显示指定serialVersionUID,系统就会对这个类的结构运用一种加密的散列函数,在运行时自动产生一个标识号。这个表示号会受到类的名称,它实现的接口名称,以及它的共有的和受保护的属性名称影响。也就是说,当我们增加了gender属性后,目前的adult类的serialVersionUID和adult.txt中对象的serialVersionUID不同了,因此就反序列化失败了。可是我需要维护Adult这个类啊,我怎么知道它之前的serialVersionUID是什么。仔细看控制台报错,救会发现人家已经告诉我们了

Exception in thread "main" java.io.InvalidClassException: com.bupt.consumer.maintest.Adult; local class incompatible: stream classdesc serialVersionUID = -8645812916550949669, local class serialVersionUID = 1904311333594998812

什么?你需要防患于未然,每次发布新的序列化类时都要加上serialVersionUID,好,满足你。
这里提供三种办法生成serialVersionUID:第一就是让IDEA帮我们生成,file->settings->Inspections->serializable class without serialVersionUID打勾即可。

将Adult类恢复为未添加gender字段之前的状态,然后在类名上alt+enter即可看到提示

第二就是使用serialver工具生成,用法为:serialver [-classpath类路径] [-show] [类名称…]。第三是自己随便定义一个serialVersionUID(不推荐)。无论如何,为需要序列化的类添加serialVersionUID是一个很好且必须的编程习惯,这不仅能解决一些兼容问题,也会降低系统每次计算serialVersionUID时产生的开销。

  1. 实现兼容的良好编程习惯【defaultWriteObject()、defaultReadObject()】
    序列化规范要求我们在writeObject()方法中首先调用defaultWriteObject(),在readObject()方法中首先调用defaultReadObject()。这样的序列化形式允许在以后的发行版中增加非瞬时(非transient,将在后面介绍)的实例域,并且还能保持向前或者向后兼容性。如果某一个实例将在未来的版本中被序列化,然后在前一个版本中被反序列化,那么,后增加的域将被忽略掉。如果旧版本的readObject()方法中没有调用defaultReadObject(),反序列化过程将失败,并引发StreamCorruptedException异常。
    private void writeObject(ObjectOutputStream out) throws IOException 
        out.defaultWriteObject();
        out.writeObject(name.toUpperCase());
        out.writeInt(age + 15);
        out.writeObject(birthday);
        out.writeBoolean(gender);
    

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException 
        in.defaultReadObject();
        this.name = (String) in.readObject();
        this.age = in.readInt() - 14;
        if(this.age < 18) 
            throw new IllegalArgumentException("未成年人!!!");
        
        this.birthday = (Date) in.readObject();
        in.readBoolean();
    
  1. 序列化对象的非基本类型属性也必须是可序列化的
    什么意思?直接上代码。现在我为Adult增加了一个Address属性,Address的定义如下(类上面几个注解的意思是需要Lombok为我生成getter、setter、无参构造和全参构造,如果你没用过注解,可以手写代码,我这里懒得写了)。
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Address 
    private String country;
    private String city;

为避免一些基础薄弱的小伙伴看到这里已经不知道Adult类改成什么样子了,这里把全部的代码都贴上。

public class Adult implements Serializable 
    private static final long serialVersionUID = -8645812916550949669L;
    private transient String name;
    private int age;
    private Date birthday;
    private boolean gender;
    private Address address;

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

    public String getName() 
        return name;
    

    public Adult(String name, int age, Date birthday, boolean gender, Address address) 
        this.name = name;
        this.age = age;
        if(this.age < 18) 
            throw new IllegalArgumentException("未成年人!!!");
        
        this.birthday = new Date(birthday.getTime());
        this.gender = gender;
        this.address = address;
    

    /**
     * 重写此方法,并在此方法中定义序列化逻辑
     * 可以在此方法中做一些加密工作或其他操作
     * @param out
     * @throws IOException
     */
    private void writeObject(ObjectOutputStream out) throws IOException 
        out.defaultWriteObject();
        out.writeObject(name.toUpperCase());
        out.writeInt(age + 15);
        out.writeObject(birthday);
        out.writeBoolean(gender);
        out.writeObject(address);
    

    /**
     * 重写此方法,并在此方法中定义反序列化逻辑
     * 可以在此方法中做一些解密工作或定义一些约束关系
     * @param in
     * @throws IOException
     * @throws ClassNotFoundException
     */
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException 
        in.defaultReadObject();
        this.name = (String) in.readObject();
        this.age = in.readInt() - 14;
        if(this.age < 18) 
            throw new IllegalArgumentException("未成年人!!!");
        
        this.birthday = (Date) in.readObject();
        in.readBoolean();
        this.address = (Address) in.readObject();
    

    @Override
    public String toString() 
        return "Adult" +
                "name='" + name + '\\'' +
                ", age=" + age +
                ", birthday=" + birthday +
                ", gender=" + gender +
                ", address=" + address +
                '';
    

    public static void main(String[] args) throws IOException, ClassNotFoundException 
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\\\\adult.txt"));
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:\\\\adult.txt"));
        Adult adult = new Adult("Mr john", 24, new Date(), true, new Address("china", "beijing"));
        oos.writeObject(adult);
        Adult adult1 = (Adult) ois.readObject();
        System.out.println("adult:" + adult1);
    

此时运行代码,将收获一个NotSerializableException

这是因为Address是一个非基本类型(int、long、short等8种基本数据类型)的对象,而它又是Adult的属性,想序列化Adult就得先序列化Address。因此,只要Address实现Serializable接口即可。
什么?放你的狗*!!你说String是基本类型?你说Date是基本类型??
别着急嘛
你看

你看

6. transient:不要序列化我
transient是干嘛用的,不知道,先用一下试试

给name属性增加一个transient标记,然后将readObject()方法和writeObject()方法都注释掉。运行程序

adult:Adultname='null', age=24, birthday=Sat Jul 17 20:39:20 CST 2021, gender=true, address=Address(country=china, city=beijing)

发现没,name属性没了。
transient的作用就是将某个被修饰属性从类的默认序列化形式中去掉
即使不将readObject()方法和writeObject()方法注释掉,但是在这两个方法种不写name字段的序列化和非序列化逻辑,如下

    private void writeObject(ObjectOutputStream out) throws IOException 
        out.defaultWriteObject();
//        out.writeObject(name.toUpperCase());
        out.writeInt(age + 15);
        out.writeObject(birthday);
        out.writeBoolean(gender);
        out.writeObject(address);
    
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException 
        in.defaultReadObject();
//        this.name = (String) in.readObject();
        this.age = in.readInt() - 14;
        if(this.age < 18) 
            throw new IllegalArgumentException("未成年人!!!");
        
        this.birthday = (Date) in.readObject();
        in.readBoolean();
        this.address = (Address以上是关于漫谈JAVA序列化的主要内容,如果未能解决你的问题,请参考以下文章

漫谈JAVA序列化

漫谈JAVA序列化

[Java反序列化]Shiro反序列化学习

[Java反序列化]Shiro反序列化学习

对象序列化漫谈

漏洞分享WebLogic反序列化漏洞(CVE-2018-2628)漫谈