读书笔记:Effective Java-第12章 序列化Serialization

11 篇文章 0 订阅
11 篇文章 0 订阅

目录

Item 85: Prefer alternatives to Java serialization

Item 86: Implement Serializable with great caution

Item 87: Consider using a custom serialized form

Item 88: Write readObject methods defensively

Item 89: For instance control, prefer enum type to readResolve

Item 90: Consider serialization proxies instead of serialized instances


object serialization:对象序列化,一个java框架,用来将对象编码为字节流byte stream(序列化serializing),或从字节流编码中重新构建对象(反序列化deserializing)。一旦对象被序列化后,它的编码可以从一台VM被传递到另一台VM上,或者被存储到磁盘上,供后续反序列化时使用。

Item 85: Prefer alternatives to Java serialization

其他方法优先于java序列化

反序列化会调用ObjectInputStream的readObject方法,该方法可以实例化实现了Serializable接口的任何类型类,从而可以执行这些类型中代码(指令片段gadget),这些代码便成为攻击面。

此外,有些短字节流需要长时间进行反序列化,如果对其引发反序列化,可能导致dos攻击。这种字节流成为反序列化炸弹(deserialization bomb),如下:

static byte[] bomb() {
  Set<Object> root = new HashSet<>();
  Set<Object> s1 = root;
  Set<Object> s2 = new HashSet<>();

  for (int i=0; i<100; i++) {
    Set<Object> t1 = new HashSet<>();
    Set<Object> t2 = new HashSet<>();
    t1.add("foo");
    s1.add(t1);
    s1.add(t2);
    s2add(t1);
    s2.add(t2);
    s1 = t1;
    s2 = t2;
  }

  return serialize(root);
}

避免序列化攻击的最佳方法就是永远不要反序列化任何东西。

在编写新系统中没有理由再使用java序列化。有很多其他方式机制可以完成对象和字节序列直接的转化。这些机制称为跨平台的结构化数据表示法(cross-platform structured-data representation)。它们不支持对任意对象图的自动序列化和反序列化,而是支持包含属性/值对的简单结构化数据对象。

最前沿的跨平台结构化数据表示法是JSON和Protocol Buffers(简称protobuf)。

永远不要反序列化不被信任的数据。

无法避免使用序列化时,应该利用java9(也移植到了旧版本)中新增的对象反序列化过滤(object deserialization filtering)功能模块:java.io.ObjectInputFilter。可以定义白名单,也可以定义黑名单,但白名单优于黑名单。

Item 86: Implement Serializable with great caution

谨慎地实现Serializable接口

实现序列化容易,但为降低序列化产生的代价的难度确很大,主要代价有:

(1)最大代价是实现序列化的类一旦发布,就大大降低了“改变这个类的实现”的灵活性。序列化会将类或包私有private的实例域变成导出的API的一部分,违法了“最小限度地访问域”的实践准则。

类版本升级后(即修改了类的内部表示),要注意新旧版本序列化和反序列化结果是否兼容。虽然可以通过使用如ObjectOutputStream.pouFields、ObjectOutputStream.readFields维持原来的序列化形式,但做起来比较困难,也会留下明显的隐患。

序列化使类的演变受到限制,其中一个就和流的唯一标识符(stream unique identifier)有关,通常称它为序列版本UID(serial version UID)。需在序列化的类内显示设置一个名为serialVersionUID的private static final域,否则系统会自动产生,这时类发生演变就会产生不同的UID,兼容性被破坏。

(2)第2个代价是增加了Bug和安全漏洞的可能性。序列化是语言之外的对象创建机制(extralinguistic mechanism),因为反序列化机制是一个隐藏的构造器,采用默认设置,很容易使对象的约束关系遭到破坏。

(3)第3个代价是随着类发行新的版本,相关的测试负担会增加。需要保证新旧版本的序列化-反序列化兼容,测试工作量与“序列化的类数和发行版本号数”的乘积成正比。

实现Serializable接口并不是很容易做出的决定。需要序列化的场景:应用于通过序列化来实现对象传输和持久化的框架中、作为序列化类的组件、值类(如BigInteger)。不应该实现序列化的场景:代表活动实体的类,如Thread pool。

为了继承而设计的类和接口应尽可能少实现继承(反例Throwable、Component类)。

带有实例域的类实现了Serializable接口,要防止终结器攻击(finalizer attack)(可通过覆盖类的finalize方法并声明为final预防)。

内部类不应该实现接Serializable接口,静态成员类可以实现。

Item 87: Consider using a custom serialized form

考虑使用自定义的序列化形式

如果事先没有认真考虑默认的序列化形式是否合适,则不要贸然使用。

如果一个对象的物理表示法(physical representation,理解为物理层上实际实现的情况)等同于它的逻辑内容(理解为逻辑上期待或具备的功能实体,在抽象层的概念),则可能适合使用默认的序列化形式。

即使确定了使用默认的序列化形式是合适的,通常还是必须提供readObject方法,以保证约束关系和安全性。

以下示例中,逻辑意义上,StringList类表示一个字符串序列;物理实现上,它把该序列表示成双向列表。采用默认序列化形式后,会将链表中的所有项、以及项之间的所有双向链表都进行序列化,非常消耗资源(时间和空间)。

// 物理表示法和逻辑数据内容区别很大的示例
// 错误做法:使用默认序列化形式
// 逻辑意义上,它表示一个字符串序列;物理意义上,它把该序列表示成双向列表
public final class StringList implements Serializable {
    private int size = 0;
    private Entry head = null;
    
    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }
}

当一个对象的物理表示法和逻辑数据内容有实质性区别时,使用默认序列化形式有以下4个缺点:

使类导出的API永远地束缚在该类的内部表示法上、消耗过多的空间、消耗过多的时间、可能引起栈溢出(stack overflow)。

上面的StringList的正确做法是使用自定义序列化形式,如下:

// 正确做法:使用自定义的序列化形式
public final class StringList implements Serializable {
    private transient int size = 0;  // 增加修饰符transient
    private transient Entry head = null;  // 增加修饰符transient
    
    // 不用序列化
    private static class Entry {
        String data;
        Entry next;
        Entry previous;
    }

    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);

        // 按正确的顺序将所有元素写入
        for (Entry e = head; e != null; e = e.next) {
            s.writeObject(e.data);
        }
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();

        // 读取所有元素,并插入list
        for (int i = 0; i < numElements; i++) {
            add((String) s.readObject());
        }
    }

    public final void add(String s) {
        // 插入数组代码
    }
}

代码说明:

(1)修饰符transient表示这个实例域将从默认序列化形式中省略掉。

(2)writeObject()和readObject()的首要任务是调用相应的默认方法defaultWriteObject()和defaultReadObject()。如果所有实例域都带有transient,从技术角度而言,写不写这两个默认方法都一样,但序列化规范依然要求要写,以确保后续兼容性(后续很可能需要调用,如果之前没调用,后面调用了,就无法兼容)。

当对象的约束关系依赖于具体的实现细节时,采用默认序列化形式还可能导致约束关系被破坏,造成严重bug,如散列表(hash table)。

决定将一个域做出非transient之前,得确信它的值时对象逻辑状态的一部分。使用自定义序列化形式,大部分实例域都应该标记transient。

对于不采用默认值初始化的实例域(如对象引用默认null、数值基本域默认0、boolean默认false),需提供readObject()方法,将这些域恢复为可接受的值。或者这些域被延迟到第一次使用的时候才被初始化。

如果在读取整个对象状态的任何其他方法上强加同步,则必须在对象序列化上强加同步。比如一个线程安全的对象通过同步每个方法实现线程安全,需同步序列化方法,类似如下:

private synchronized void writeObject( ObjectOutputStream s) throws IOException{
    s.defaultWriteObject();
}

如果把同步放在writeObject方法里面,必须确保其与其他动作有相同的锁排雷顺序,否则面临资源排列死锁(resource-ordering deadlock)的危险。

需要为每个可序列化的类声明一个显式的序列版本UID,如果让系统自动生成,不经有兼容隐患,还会产生高开销,其声明形式如下:

private static final long serialVersionUID = randomLongValue;

 序列版本UID的取值可以随意取,可以借助该类上的serialver工具,也可以凭空写,且不要求唯一性(Java, SerialVersionUID该如何声明 - 知乎)。如果需要为没有声明序列版本UID的类添加声明,则需要找出之前系统自动生成的UID值,可在旧版本的类上运行serialver工具获取。

不要修改序列版本UID,否则会破坏已被序列化的类实例的兼容性。

Item 88: Write readObject methods defensively

保护性地编写readObject方法

readObject方法相当于另一个公有的构造器,因此也须遵守相应类的构造器的约束,如检查参数有效性、在必要时刻对参数进行保护性拷贝,否则存在被攻击风险。

反序列化时客户端不应该有(自身?)对象引用,如果哪个域包含了这种引用,必须做保护性拷贝。

readObject里的保护性拷贝应该在参数校验之前。

对于fianl域,保护性拷贝时不可能的。

还可采用序列化代理模式(serialization proxy pattern)解决上面的反序列化安全问题。

readObject方法里不能调用被覆盖的方法,无论直接还是间接都不行。否则,被覆盖的方法将在子类的状态被反序列化前执行,程序很可能报错。

总结编写readObject方法的建议:

(1)对于对象引用域必须保持为私有的类,要保护性地拷贝这些域中的每个对象。不可变类的可变组件属于这一范畴。

(2)检查所有约束条件,如检查失败,则抛出InvalidObjectException。检查动作应在保护性拷贝之后进行。

(3)如果整个对象图反序列化后必须进行验证,应该使用ObjectInputValidation接口。

(4)readObject方法内不要直接或间接调用类的任何可覆盖方法。

Item 89: For instance control, prefer enum type to readResolve

对于实例控制,枚举类型优先于readResolve

对单列模式(如下)直接反序列化,会创建新的实例对象:

public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... };
    ...
}

应该加入readResolve方法,该方法会在反序列后被调用执行,返回的对象引用将替换反序列化创建的新对象:

public Object readResolve() {
    return INSTANCE;
}

 该方法忽略反序列化的对象,只返回该类初始化时创建的那个特殊的Elvis实例。

如果采用readResolve进行实例控制,须将所有对象引用类型的实例域声明为transient。否则容易在readResolve执行之前“偷走”实例域的引用,进行攻击,以下是攻击示例代码:

// 对象引用域没有加transient
public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... };

    private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"}; // 没有加transient
    public Object readResolve() {
        return INSTANCE;
    }
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
    ...
}

// "盗窃者”攻击类
public class ElvisStealer implements Serializable {
    private static final long serialVersionUID = 0;
    static Elvis impersonator;
    private Elvis payload;

    private Object readResolve() {
        impersonator = payload; // 保存
        return new String[] {"A Fool Such as I"};
    }
}

// 攻击模拟类
public class ElvisImpersonator {
    public static main(String[] args) {
        byte[] serializedForm = ...; // 读取Elvis对象的序列化字节流
        Elvis elvis = (Elvis) deserialize(serializedForm );
        Elvis impersonator = ElvisStealer.impersonator;
        elvis.printFavorites();  // 打印结果:[Hound Dog, Heartbreak Hotel]
        impersonator.printFavorites(); // 打印结果:[A Fool Such as I]
    }

    static Object deserialize(byte[] sf){
        ...
    }
}

除了readResolve外,还有更好的实例控制方法,就是用enum替换需要序列化的类,java保证忽略声明的常量外不会有其他实例,除非攻击者滥用特权方法(如AccessibleObject.setAccessible)。将上面的Elvis改成enum形式:

public enum Elvis {
    INSTANCE;
    private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"}; 
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

 当类存在编译时类型未知的实例域时,不能采用enum法,还得采用readResolve法。

readResolve的可访问性(accessibility)非常重要。如果把readResolve方法放到fianl类内,她应该是private的;否则必须认真考虑它的可访问性:

  • 如果设为private,则不能用于子类;
  • 如果设为package-private,则只能应用于相同包内的子类;
  • 如果设为protected或public,则可应用到所有未覆盖它的子类,此时若子类未覆盖,则会抛出ClassCastException异常。

Item 90: Consider serialization proxies instead of serialized instances

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

为减少序列化导致的bug和安全隐患,还可采用序列化代理模式(serialization proxy pattern)。它不需要一致性检查和保护性拷贝措施。

序列化代理模式是一个private static嵌套类,简明地表示外部类实例的逻辑状态。外围类和代理类都必须声明实现Serializable接口。

示例代码(或参考EnumSet类的实现):

public final class Period implements Serializable {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        this.start = start;
        this.end = end;
        if (this.start.compareTo(this.end) > 0) {
            throw new IllegalArgumentException(start + " after " + end);
        }
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    public String toString() {
        return start + " - " + end;
    }


    // 序列化代理类
    private static class SerializationProxy implements Serializable {
        private static final long serialVersionUID = 1L; // 任何数字均可
        private final Date start;
        private final Date end;

        SerializationProxy(Period p) {
            this.start = p.start;
            this.end = p.end;
        }

        // readResolve方法在反序列时将代理类实例转换为外部类实例
        private Object readResolve() {
            return new Period(start, end);
        }
    }


    // writeReplace方法代理类代替外部类进行序列化。
    // 任何序列化代理方式的外部类均可直接拷贝以下writeReplace方法的实现
    private Object writeReplace(ObjectInputStream s) {
        return new SerializationProxy(this);
    }

    // 防止攻击者利用readObject方法伪造外部类,将方法定义为如下
    private void readObject(ObjectInputStream s) throws InvalidObjectException {
        throw new InvalidObjectException("Proxy required");
    }
}

序列化代理模式的优势:

  • 阻止伪字节流的攻击(bogus byte-stream attack)和内部域的盗用攻击(the internal field theft attack)。
  • 允许域为fianl,从而构造不可变类。
  • 不必显式执行有效性检查。
  • 实现方式简洁。

序列化代理模式的弊端:

  • 不能与可以被客户端扩展的类相兼容。
  • 不能与对象图中包含循环的类相兼容。因为这种情况下,从代理的readResolve方法内调用外部类对象的方法时,会抛出ClassCastException异常(因为还没有外部类对象)。
  • 开销更大。作者实验表明,比保护性拷贝的措施增加了14%的开销。

总结,当在不能被客户端扩展的类上编写readObject或writeObject时,优先考虑序列化代理模式。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值