85. 其他序列化优于Java序列化
原因在于,反序列化不被信任的数据,容易受到序列化攻击,例如远程代码执行(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就会改变,新版本可能无法反序列化旧版本的产物。总之,一旦实现类序列化接口,就意味着未来的实现受到众多限制。
Java序列化绕开了构造器,同时也绕开了构造器可能设定的类约束。
如果要保证新版本序列化的实例,旧版本可以反序列化(应用兼容性要求),那么就必须对所有旧版本的类进行测试,开销 = 序列化类的数量 x 版本数量。
正如上面及上一条目提及,首先Java的序列化机制本身就是有缺陷的,实现Serializable意味着,以后所有版本中,相关的类都必须强制遵循约定(很可能要一直使用危险的Java序列化机制)。所以看起来是一个“免费”的标记接口,其实“免费的才是最贵的”。
同理,为继承而设计的类也尽可能不要使用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
}
当物理表示法不等于逻辑内容的时候,使用默认序列化,会增加很多没有必要的开销。
具体而言,有如下缺点;
这时候可以考虑自定义序列化形式,只需要序列化字符串和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());
}
... // Remainder omitted
}
从上述正确的序列化案例中,我们需要引出很多有正确自定义序列化的最佳实践。
transient最佳实践
案例展示了transient的典型用法。
文档最佳实践
即使是private修饰的字段(非transient),方法(readObject/writeObject),由于序列化成为导出的API,因而都需要文档进行注释。
非transient字段使用@serial标注,readObject/writeObject使用@serialData标注,正如上述案例,注明序列化保存什么内容。
同步安全性
如果类本身是线程安全的,readObject/writeObject也应该保证线程安全,并且注意锁的一致性,避免产生死锁。
序列化版本UID
不管什么形式的序列化,都需要声明一个显式的序列化版本UID,控制序列化兼容性问题。如果不是需要产生新的版本,不要改动序列化版本,否则会破坏类现有已被序列化实例的兼容性。
88. 保护性地编写readObject和readResovle
要理解这一条,首先需要了解一下,常见的两种序列化攻击形式。
两种序列化攻击
伪造数据流。
通过进行设计,你可以伪造一份字节流,破坏类原先的约束。例如,使得起始时间>结束时间,在很多时候,这显然是不合理的。但由于Java序列化机制不需要构造器,绕开了构造器,这种伪造但数据流破坏了类的约束。
来看一个案例:
// 书中给出的案例,mac无法成功运行,但一定能够构造出这种数据流
public class BogusPeriod {
// Byte stream couldn't have come from a real Period instance!
private static final byte[] serializedForm = {
(byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte) 0xf8,
0x2b, 0x4f, 0x46, (byte) 0xc0, (byte) 0xf4, 0x02, 0x00, 0x02,
0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
(byte) 0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte) 0xdf,
0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
0x77, 0x08, 0x00, 0x00, 0x00, (byte) 0xd5, 0x17, 0x69, 0x22,
0x00, 0x78
};
public static void main(String[] args) {
// 反序列化伪数据流,破坏 开始时间 < 结束时间 的约束
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// Returns the object with the specified serialized form
static Object deserialize(byte[] sf) {
try {
return new ObjectInputStream(
new ByteArrayInputStream(sf)).readObject();
} catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
}
用指针“偷走”私有域。
私有域本来是外部无法直接访问的,但可以在序列化类之后,附上精心设计的指针,指向内部私有域。在反序列化之后,偷偷读取这些引用,从而在外部拥有对象内部的私有域指针。
public class MutablePeriod {
// A period instance
public final Period period;
// period's end field, to which we shouldn't have access
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos =
new ByteArrayOutputStream();
ObjectOutputStream out =
new ObjectOutputStream(bos);
// Serialize a valid Period instance
out.writeObject(new Period(new Date(), new Date()));
// 序列化之后,附上精心设计的引用,指向内部私有域
byte[] ref = {0x71, 0, 0x7e, 0, 5};
ref[4] = 4;
bos.write(ref);
// Deserialize Period and "stolen" Date references
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
end = (Date) in.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new AssertionError(e);
}
}
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
System.out.println(p);
mp.end.setYear(-24);
System.out.println(p);
// 输出:
// Thu Apr 28 16:36:41 CST 2022 - Thu Apr 28 16:36:41 CST 2022
// Thu Apr 28 16:36:41 CST 2022 - Fri Apr 28 16:36:41 CST 1876
}
}
// 不可变类
public final class Period implements Serializable {
// 不可变类中的可变私有域
private Date start;
private Date end;
/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start * @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is null
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
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;
}
// readObject method with defensive copying and validity checking
// private void readObject(ObjectInputStream s)
// throws IOException, ClassNotFoundException {
// s.defaultReadObject();
// // Defensively copy our mutable components
// start = new Date(start.getTime());
// end = new Date(end.getTime());
// // Check that our invariants are satisfied
// if (start.compareTo(end) > 0)
// throw new InvalidObjectException(start +" after "+ end);
// }
}
保护性编码
针对上述两个攻击点,保护性编码的两个重要任务:
- 维持构造器约束
- 防御性拷贝
一种方式是通过编写readObject来维持约束,并且进行防御性拷贝,避免初始反序列化对象引用被盗用。注意防御性拷贝——即使通过指针偷走原有反序列化对象的引用,也无法修改最终反序列化对象的私有域。
public final class Period implements Serializable {
private Date start;
private Date end;
/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start * @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is null
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
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;
}
// readObject method with defensive copying and validity checking
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Defensively copy our mutable components
start = new Date(start.getTime());
end = new Date(end.getTime());
// Check that our invariants are satisfied
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start +" after "+ end);
}
}
另一种更加简单的方式,是通过readResolve方法,进行防御性拷贝。这种方式更加简洁,它直接使用了构造函数的约束检查。
public final class Period implements Serializable {
private Date start;
private Date end;
/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start * @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is null
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
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 Object readResolve() {
System.out.println("防御性拷贝");
return new Period(start, end);
}
}
89. 枚举类型优于readResolve
枚举实现单例
JVM无偿提供序列化安全性。
// Enum singleton - the preferred approach
public enum Elvis {
INSTANCE;
private String[] favoriteSongs =
{ "Hound Dog", "Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
}
readResolve方式
// Broken singleton - has nontransient object reference field!
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { }
private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
private Object readResolve() {
return INSTANCE;
}
}
确保该类所有实例域都是瞬时的,否则容易“盗走”内部域引用,书中构造了一个例子,看起来不太合理。
90. 考虑使用序列化代理
序列化代理模式非常强大,实现非常容易,但第一次理解起来会比较复杂——需要对整个Java序列化机制有一定对了解,并且理解88条中讲到的序列化攻击,才能明白序列化代理模式为什么这么设计,以及这些设计有什么用。
public class Period implements Serializable {
private static final long serialVersionUID = 1L;
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());
}
/**
* 序列化外围类时,会提前调用这个方法,使用代理对象替代外围类对象,参数为外围类对象
*/
private Object writeReplace() {
System.out.println("writeReplace()");
return new SerializabtionProxy(this);
}
/**
* 禁止直接反序列化外围类,防止伪造流攻击
*/
private void readObject(ObjectInputStream ois) throws InvalidObjectException {
throw new InvalidObjectException("Proxy required!");
}
/**
* 序列化代理类,他精确表示了其当前外围类对象的状态
*/
private static class SerializabtionProxy implements Serializable {
private static final long serialVersionUID = 1L;
private final Date start;
private final Date end;
SerializabtionProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
/**
* 反序列化之后虚拟机会调用这个方法,返回外围类拷贝对象。
* 保护性拷贝,防止“偷窃”内部域引用攻击
*/
private Object readResolve() {
System.out.println("readResolve()");
// 保护性拷贝,调用构造器保证约束
return new Period(new Date(start.getTime()), new Date(end.getTime()));
}
}
}
你会发现,它其实在序列化的时候悄悄替换了序列化的对象,反序列化时又偷偷换回来。
看起来复杂,其实实现很简单,只要提供
- 静态内部嵌套类Proxy
- 外部类提供writeObject替换为Proxy,readObject禁止反序列化
- Proxy内部提供readResolve,还原为外部类
这种偷偷替换序列化反序列化对象,比较灵活,EnumSet就使用了类似的方式——EnumSet会根据元素多少,新建不同的子类RegularEnumSet (小于64个)、JumboEnumSet (大于64个),想象一下,如果一开始序列化RegularEnumSet,然后在附加几个元素,那么反序列化之后会是什么呢?
答案是JumboEnumSet,因为它就使用了序列化代理,有了上面的知识,就能一下子理解EnumSet的序列化代理实现。
// EnumSet's serialization proxy
private static class SerializationProxy <E extends Enum<E>>
implements Serializable {
// The element type of this enum set.
private final Class<E> elementType;
// The elements contained in this enum set.
private final Enum<?>[] elements;
SerializationProxy(EnumSet<E> set) {
elementType = set.elementType;
elements = set.toArray(new Enum<?>[0]);
}
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;
}