目录
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时,优先考虑序列化代理模式。