序列化——考虑用序列化代理代替序列化实例

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

序列化代理模式

决定实现序列化会增加出错和安全问题的可能性因为它导致实例要利用语言之外的机制来创建——而不是普通的构造器。当然也有一种方法可以减少这些风险序列化代理模式

比如之前的 Period 类使用序列化代理模式来处理序列化问题

import cn.hutool.core.date.DateUtil;
import java.io.Serializable;
import java.util.Date;
 
public final class Period implements Serializable {
    private static final long serialVersionUID = 1L;
 
    private final Date start;
    private final Date finish;
 
    public Date getStart() {
        return start;
    }
 
    public Date getFinish() {
        return finish;
    }
 
    public Period(Date start, Date finish) {
        if (start == null) {
            throw new NullPointerException("start 不能为null");
        }
        if (finish == null) {
            throw new NullPointerException("finish 不能为null");
        }
        this.start =  new Date(start.getTime());
        this.finish = new Date(finish.getTime());
        if (start.after(finish)) {
            throw new IllegalArgumentException("start 不能在 finish 之后");
        }
    }
 
    @Override
    public String toString() {
        return DateUtil.format(start,"yyyy-MM-dd HH:mm:ss") + "~" + DateUtil.format(finish,"yyyy-MM-dd HH:mm:ss");
    }
}
import cn.hutool.core.date.DateUtil;
import java.io.*;
import java.util.Date;
 
public class Client {
    public static void main(String[] args) throws Exception {
        /**
         *  修改字节行数     177-180    110、47、73、36
         *  日期 d1         2020-11-03 11:22:33 变成 2019-11-03 11:22:33
         *
         *  修改字节行数     160-163   -124、59、-79、111
         *  日期 d2         2021-11-03 12:13:15 变成 2022-11-03 12:13:15
         */
        Date d1 = DateUtil.parse("2020-11-03 11:22:33");
        Date d2 = DateUtil.parse("2021-11-03 12:13:15");
 
        Period p = new Period(d1, d2);
        ser(p);
 
        byte[] bytes = readBytes(new File("C:\\Users\\admin\\Desktop\\file_upload\\car.ser"));

        bytes[160] = 110;
        bytes[161] = 47;
        bytes[162] = 73;
        bytes[163] = 36;

//        bytes[179] = 110;
//        bytes[180] = 47;
//        bytes[181] = 73;
//        bytes[182] = 36;
 
        p = deSer(bytes);
        System.out.println(p);
    }
 
     static byte[] readBytes(File file) throws IOException {
        FileInputStream in = null;
        try {
            in =new FileInputStream(file);
            //当文件没有结束时每次读取一个字节显示
            byte[] data=new byte[in.available()];
            in.read(data);
            in.close();
            return data;
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            in.close(); //关闭流
        }
        return null;
    }
 
    static void ser(Period p) throws IOException {
        FileOutputStream fos = new FileOutputStream("C:\\Users\\admin\\Desktop\\file_upload\\car.ser");
        ObjectOutputStream os = new ObjectOutputStream(fos);
        os.writeObject(p);
        os.close();
    }
 
    static Period deSer(byte[] bytes) throws IOException, ClassNotFoundException {
        ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes));
        Period p = (Period) in.readObject();
        in.close();
        return p;
    }
}

反序列化修改字节码从而修改了实例的数据下面我们改造一下Period 类添加如下代码

/**
     * 序列化的对象将是作为writeReplace方法返回值的对象
     * 而且序列化过程的依据是实际被序列化对象的序列化实现。
     */
    private Object writeReplace() {
        return new SerializationProxy(this);
    }

    private static class SerializationProxy implements Serializable {
        private static final long serialVersionUID = 1L;
        private final Date start;
        private final Date finish;

        SerializationProxy(Period p) {
            start = p.start;
            finish = p.finish;
        }

    }

如果一个序列化类中含有Object writeReplace()方法那么实际序列化的对象将是作为writeReplace方法返回值的对象而且序列化过程的依据是实际被序列化对象的序列化实现。

以上代码中就是序列化系统会产生一个 SerializationProxy实例代替 Period 类的实例。有了这个方法序列化系统不会产生 Period 实例但攻击者可能伪造企图违反该类的约束条件finish > start为了防御这种攻击只要在 Period 添加 readObject 方法

private void readObject(ObjectInputStream stream) throws InvalidObjectException {
    throw new InvalidObjectException("Proxy required");
} 

最后在 SerializationProxy 类中提供一个 readResolve 方法它返回一个逻辑上相当的 Period 实例这个方法的出现导致序列化系统在反序列化时将序列化代理转变回 Period 实例

 private Object readResolve(){
    return new Period(start,  finish);
}

这个 readResolve 方式仅仅是利用它的公有 API 创建外部类的一个实例

这正是这种模式的魅力所在。它极大地消除了序列化机制中语言本身之外的特征因为反序列化实例是利用常规的构造器和静态工厂和方法创建的。这样你就不必再单独确保被反序列化的实例满足指定的约束条件因为反序列化过程不是直接从 byte[] 创建对象而是从常规的构造器或者方法来创建可以认为是已经检查过约束条件。

和保护性拷贝方法一样序列化代理方法可以阻止伪字节流的攻击以及内部域盗用攻击。与前两种方法不同这种方法允许Period 的域为final因此这种方法更有利于 Period 类的不可变性

并且这种方式不需要太费心思不必知道哪些域可能被字节攻击也不需要显式执行约束检查。

利用这种方法时序列化代理模式的功能比保护性拷贝更强大。序列化代理模式允许反序列化实例有着与原始序列化实例不同的类。你可能认为这在实际应用中没有什么作用其实不然。


参考 EnumSet。这个类构造器并没有使用 public只有静态工厂。从调用者的角度它们返回 EnumSet 的实例。实际上它们是返回两种子类之一取决于枚举类型的大小

public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
        Enum<?>[] universe = getUniverse(elementType);
        if (universe == null)
            throw new ClassCastException(elementType + " not an enum");

        if (universe.length <= 64)
            return new RegularEnumSet<>(elementType, universe);
        else
            return new JumboEnumSet<>(elementType, universe);
} 

根据以上代码我们会发现常规序列化的问题一个枚举类型公有60个序列化的时候会使用RegularEnumSet 。后来因为业务扩展增加了5个这时候执行反序列化应该使用 JumboEnumSet常规的序列化方法就有问题了。

EnumSet 的解决办法

private static class SerializationProxy <E extends Enum<E>>
    implements java.io.Serializable
{
    /**
     * The element type of this enum set.
     *
     * @serial
     */
    private final Class<E> elementType;

    /**
     * The elements contained in this enum set.
     *
     * @serial
     */
    private final Enum<?>[] elements;

    SerializationProxy(EnumSet<E> set) {
        elementType = set.elementType;
        elements = set.toArray(ZERO_LENGTH_ENUM_ARRAY);
    }

    // instead of cast to E, we should perhaps use elementType.cast()
    // to avoid injection of forged stream, but it will slow the implementation
    @SuppressWarnings("unchecked")
    private Object readResolve() {
        EnumSet<E> result = EnumSet.noneOf(elementType);
        for (Enum<?> e : elements)
            result.add((E)e);
        return result;
    }

    private static final long serialVersionUID = 362491234563181265L;
}

Object writeReplace() {
    return new SerializationProxy<>(this);
}

// readObject method for the serialization proxy pattern
// See Effective Java, Second Ed., Item 78.
private void readObject(java.io.ObjectInputStream stream)
    throws java.io.InvalidObjectException {
    throw new java.io.InvalidObjectException("Proxy required");
} 

序列化代理模式有两个局限性。它不能与被客户端扩展的类兼容。他也不能与对象图中包含循环的某些类兼容如果你企图从一个对象的序列化代理的 readResovle 方法内部中调用这个对象的方法就会得到一个 ClassCastException父类不能转为未知的子类因为实际类型不符

此时我们只有子类的序列化形式没有实际的 Class。另外序列化代理模式会额外消耗一些性能比如Period 的序列化代理比保护性拷贝额外多消耗15%左右的性能实际情况可能有波动不同的机器不同的系统不同的类都会影响这个性能差异

总而言之如果一个类不能被客户端扩展并且需要处理序列化工作readObjectwriteObject

应该考虑序列化代理模式这种方式如同枚举对于单例的保护一样非常方便和简单。这一点在类型带有重要约束条件的时候尤为重要

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6