85. 其他序列化优于Java序列化
Java序列化机制很危险,在新编写的系统中,没有理由再使用Java序列化,应该使用其他序列化机制,例如跨平台的JSON序列化;尤其不要反序列化任何不信任的数据,因为容易遭受序列化攻击。
原因在于,反序列化不被信任的数据,容易受到序列化攻击,例如远程代码执行(RCE),拒绝服务(DoS)等等。研究人员可以基于Java序列化机制开发出很多巧妙的攻击片段。例如:
public class BombSerializable {
public static void main(String[] args) throws IOException, ClassNotFoundException {
serialize();
System.out.println("serialized");
deserialize();
}
// Deserialization bomb - deserializing this stream takes forever
static void serialize() throws IOException {
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"); // Make t1 unequal to t2
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("bomb.txt"));
oos.writeObject(root);
}
static Object deserialize() throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("bomb.txt"));
Object o = ois.readObject();
return o;
}
}
上述代码在反序列的时候会卡住,原因在于,调用流的读取/写入方法之后,会自动调用序列化对象的readObject, wirteObject
等方法。但HashSet重写了readObject,wirteObject
,如下代码,它会根据集合的数量从流中调用相应次数的写入/读取方法,递归调用。原来是没有问题的,因为Java序列化发现当前对象已经序列化过,它不会再深入递归。但关键在于,readObject
方法调用了map.put->hash->hashCode
,该方法会递归遍历所有元素。这样一来,hashCode的方法就会被调用超过2^100次方次,造成反序列化爆炸。
以上的例子,指出了Java序列化的风险本质——序列化之后调用的方法(例如readObject,wirteObject
)由序列化对象提供,尤其是反序列化的时候,这些超出了调用者(程序)的控制。
86. 明智地实现Serializable
Serializable是一个标记接口,看起来只要实现它就能享受序列化机制,非常简单,毫不费力,但实际上需要付出长期开销非常高。以下列出相关建议并解释原因。
最大的代价是,一旦类被发布,大大降低了“改变这个类的实现”的灵活性
相当于类所有的私有和包私有的实例域都会变成导出API的一部分——你必须保证这些域一直符合序列化要求,这就不符合“最低限度访问原则”。
而且一旦类内部改变,自动生成的序列化UID就会改变,新版本可能无法反序列化旧版本的产物。总之,一旦实现类序列化接口,就意味着未来的实现受到众多限制。
第二个代价是,增加了出现BUG和安全漏洞的可能性。
Java序列化绕开了构造器,同时也绕开了构造器可能设定的类约束。
第三个代价是,测试成本。
如果要保证新版本序列化的实例,旧版本可以反序列化(应用兼容性要求),那么就必须对所有旧版本的类进行测试,开销 = 序列化类的数量 x 版本数量。
是否实现Serializable,需要谨慎地决定
正如上面及上一条目提及,首先Java的序列化机制本身就是有缺陷的,实现Serializable意味着,以后所有版本中,相关的类都必须强制遵循约定(很可能要一直使用危险的Java序列化机制)。所以看起来是一个“免费”的标记接口,其实“免费的才是最贵的”。
同理,为继承而设计的类也尽可能不要使用Serializable序列化,因为这会给未来的子类、所有实现带来沉重的负担。
内部类不该使用Serializable
内部类指非静态内部类,它们持有对外部类的引用,目前内部类的序列化形式没有清楚的定义。但静态内部类可以,因为静态内部类不持有外部引用。
87. 考虑使用自定义序列化替换默认序列化
物理表示法与逻辑内容
只有一个对象的序列化物理表示法 = 逻辑内容,才可以使用默认序列化
怎么理解呢?书中有一个精彩的案例如下。这是一个用于保存字符串集合的双向链表,如果直接使用默认序列化,则物理上(物理存储)会直接保留双向链表的形式,实际上只需要保存字符串集合,以及集合的size即可。
// Awful candidate for default serialized form
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;
}
... // Remainder omitted
}
当物理表示法不等于逻辑内容的时候,使用默认序列化,会增加很多没有必要的开销。
具体而言,有如下缺点;
- 这个类的导出API会永远束缚在内部表示法上。例如在案例中,明明是在内部维护一个私有的链表,但由于使用了默认序列化形式,导致即使后面将字符串集合内部存储换为列表,你还是得维护这个链表,因为之前的序列化导出API就是链表。
- 空间开销。
- 性能开销。序列化并不知道内部的拓扑结构,他只能递归地进行昂贵的图遍历,并且容易引起stack over flow
- 破坏拓扑结构,默认序列化方式完全不能用。在上述案例中,虽然慢,但是拓扑还是正确的,如果是像散列表这种对象,那么默认的序列化会破坏散列表的整个拓扑结构。
这时候可以考虑自定义序列化形式,只需要序列化字符串和size即可。
// StringList with a reasonable custom serialized form
public final class StringList implements Serializable {
private transient int size = 0;
private transient Entry head = null;
// No longer Serializable!
private static class Entry {
String data;
Entry next;
Entry previous;
}
// Appends the specified string to the list
public final void add(String s) {
... }
/**
* Serialize this {@code StringList} instance.
*
* @serialData The size of the list (the number of strings * it contains) is emitted ({@code int}), followed by all of * its elements (each a {@code String}), in the proper
* sequence.
*/
private void writeObject(ObjectOutputStream s)
throws IOException {
s.defaultWriteObject();
s.writeInt(size);
// Write out all elements in the proper order.
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();
// Read in all elements and insert them in list
for (int i = 0; i < numElements; i++)
add((String) s.readObject());
}
..