Effective java 总结11 - 序列化
序列化:对象 -> 字节流
反序列化:字节流 -> 对象
第85条 其他方法优先于java序列化
序列化的根本问题在于:攻击面过于庞大,无法防护,将不信任的流进行反序列化,可能导致RCE,DOS攻击
RCE: remote code execution 远程代码执行
DOS:denial of service 拒绝服务
避免序列化攻击的最佳方法是永远不要反序列化任何东西
在编写新系统中没有理由再使用java序列化
永远不要反序列化不被信任的数据,如果必要,使用对象的反序列化过滤
替换为: 跨平台结构化数据表示法
JSON:java, 基于文本,可以阅读,非常高效
Protocol Buffers:C++,基于二进制
第86条 谨慎实现Serializable接口
使一个类的实例可以被序列化,只需要在类的声明中加入 implements Serializable 即可
实现Serializable接口付出的代价
-
一个类一旦被发布,大大降低了这个类被改变的灵活性,因为如果接受了默认的序列化形式,这个类中私有的和包级私有的实例域都将变成导出API的一部分
- 序列化使类的演变受到限制,限制与流的唯一标识符有关,通常为静态私有final的序列版本UID(SerialVersionUID),如果没有显示指定,则系统会对类的结构运用一个加密散列函数自动产生标识号,这个自动产生的号码会受类名称、接口名称、所有公有、受保护的成员的名称影响
-
增加了出现BUG和安全漏洞的可能性, 反序列化机制都是一个“隐藏的构造器”
-
随着类发行新的版本,相关的测试负担增加
实现Serializable接口并不是一个很轻松就可以做出的决定
-
应该实现Serializable的类:BigInteger、 Instant等值类
-
不应该实现Serializable的类: 代表活动实体的类(线程池 thread pool)等
为了继承而设计的类应该尽可能少去实现Serializable接口,例外:
- Throwable, RMI异常可以从服务端传到客户端
- Component, GUI可以被发送,保存,复制
其他
- 内部类不应该实现Serializable接口
- 静态成员类可以实现Serializable接口
第87条 考虑使用自定义的序列化形式
没有事先认真考虑默认的序列化形式是否合适,则不要贸然接受
如果一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式
public class Name implements Serializable{
private final String lastName;
private final String firstName;
private final String middleName;
}
// 通常必须提供一个readObject方法以保证约束关系和安全性(保证lastName、等非NULL)
如果一个对象的物理表示法与它的逻辑内容有实质区别时,使用默认的序列化有很多缺点
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;
}
}
- 使这个类的导出API永远束缚在该类的内部表示法上
- 会消耗过多的空间、时间
- 有可能引起栈溢出
- 对于散列表而言,接受默认的序列化形式将会构成严重的BUG
修订优化版本
public final class StringList implements Serializable{
private transient int size = 0;
private transient Entry head = null;
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();
for(int i = 0; i < numElements; i++){
add( (String)s.readObject() );
}
}
}
// transient 修饰符表明这个实例域将从一个默认序列化形式中省略掉
-
writeObject : 序列化; readObject:反序列化
-
调用writeObject 、readObject得到的序列化形式允许在以后的发行版本中增加非瞬时的实例域,且保持向前、向后的兼容性
-
defaultWriteObject()被调用的时候,每一个未被transient标记的实例域都会被序列化
-
自定义的序列化形式中,大多数的or全部实例域都应被transient标记
-
默认的序列化形式中,多个域被transient标记,则将被初始化为默认值,若不能接受默认值,则必须提供readObject方法,调用defaultReadObject,将这些域恢复为可用接受的值,或者延迟初始化
无论哪种形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步
private synchronized void writeObject(ObjectOutputStream s) throws IOException{
s.defaultWriteObject();
}
无论哪种形式,都要为自己编写的每个可序列化的类声明一个显示的序列版本UID
private static final long serialVersionUID = randomLongValue;
// serialVersionUID 的值是什么并不重要,主要标记当前类的版本
// 不要修改序列版本UID,否则将会破坏类现有的已被序列化实例的兼容性
第88条 保护性地编写readObject方法
public final class Period{
private final Date start;
private final Date end;
public Period(Date start, Date end){
if(start.compareTo(end) > 0) throw new...; // 约束 结束时间 > 开始时间
this.start = start;
this.end = end;
}
public Date start(){
return new Date(start.getTime()); //保护性clone
}
public Date end(){
return end;
return new Date(end.getTime()); //保护性clone
}
}
增加 implements Serializable
做成可序列化,存在的一些问题:
readObject 方法实际上相当于公有的构造器,必须检查其参数的有效性,且必要的时候对参数进行保护性拷贝
// 序列化一个违反Period约束的字节流
public class BogusPeriod{
private static final byte[] serializedForm = {0x50, 0x65, ...};
public static void main(String[] args){
Period p = (Period) deserialize(serializedForm);
sout(p);
}
static Object deserialize(byte[] bf){
try{
return new ObjectInputStream(new ByteArrayInputStream(bf)).readObject();
}catch(...){...}
}
}
// 伪造字节流,创建可变的Period实例: 以一个有效的Period实例开头,附带两个额外引用,指向Period中两个私有的Date域,攻击这两个“恶意编制”的对象引用
public class MutablePeriod{
public final Period period;
public final Date start;
public final Date end;
public MutablePeriod(){
try{
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
out.writeObject(new Period(new Date(), new Date()));
byte[] ref = {0x71, 0, 0x7e, 0, 5};
bos.write(ref);
ref[4] = 4;
bos.write(ref);
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
period = (Period)in.readObject();
start = (Period)in.readObject();
end = (Period)in.readObject();
}catch(){}
}
}
// attack
public static void main(String[] args){
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
pEnd.setYear(78);
sout(p);
pEnd.setYear(49);
sout(p);
}
// 当一个对象被反序列化的时候,对于客户端不应该拥有的对象引用,如果哪个域包含了这样的对象引用,就必须要做保护性拷贝
检查其参数的有效性,且对参数进行保护性拷贝
private readObject(ObjectInputStream s){
s.defaultReadObjec();
// 保护性拷贝
start = new Date(start.getTime()); // 需要去掉Period中 start,end域的final
end = new Date(end.getTime());
// 有效性检查
if(start.compareTo(end) > 0) {
throw ...;
}
}
编写健壮的readObject()方法:
- 对于对象引用必须保持为私有的类,要保护性拷贝这些域中的每个对象
- 对于有约束的对象,进行约束检查
- 无论是直接还是间接方式,不要调用类中任何可被覆盖的方法
第89条 对于实例控制,枚举类型优于readResolve
// 单例
public class Elvis{
public static final Elvis INSTANCE = new Elvis();
private Elvis(){}
private String[] fs = {"aaa", "bbb"};
// private transient String[] fs = {"aaa", "bbb"}; // 应该加上 transient
......
}
任何一个readObject方法,不管显式还是默认的,都会返回一个新建的实例,这个实例不同于类初始化时创建的实例,包括单例
readResolve特性允许用readObject创建的实例代替另一个实例
// Elvis类中添加
private Object readResolve{
return INSTANCE;
}
// 忽略被反序列化的对象,只返回该类初始化时创建的特殊的实例
如果依赖readResolve进行实例控制,带有对象引用的所有实例域都应该被声明成 transient, 或者所有的实例域都为基本类型,否则会被攻击盗用(内部域的盗用攻击)
如果将一个可序列化的实例受控的类编写成枚举,java可以绝对保证除了声明的常量外,没有其他实例
// ENUM
public enum Elvis{
INSTANCE;
private String[] fs = {"aaa", "bbb"};
public void printfs(){
sout(Arrays.toString(fs));
}
}
readResolve的可访问性
readResolve私有 – final类
readResolve包级私有 – 同一个包中的子类
readResolve受保护的or公共的 – 所有没有覆盖它的子类
第90条 考虑用序列化代理代替序列化实例
序列化代理模式:
1、为可序列化的类设计一个私有的静态嵌套类 – 序列化代理
2、嵌套类有一个单独的构造器,参数类型为外围类 – 只从参数中复制数据
3、无需进行一致性检查和保护性拷贝
4、外围类和序列化代理类必须声明实现Serializable接口
public final class Period{
private final Date start;
private final Date end;
public Period(Date start, Date end){
if(start.compareTo(end) > 0) throw new...; // 约束 结束时间 > 开始时间
this.start = start;
this.end = end;
}
public Date start(){
return new Date(start.getTime()); //保护性clone
}
public Date end(){
return end;
return new Date(end.getTime()); //保护性clone
}
// 私有静态嵌套类
private static class SerializationProxy implements Serializable{
private final Date start;
private final Date end;
SerializationProxy(Period p){
start = p.start;
end = p.end;
}
private static final long serialVersionUID = 12313413412341;
// 返回逻辑上相当的外围类实例, 在反序列化时将序列化的代理转变回外围类实例
private Object readResolve(){
return new Period(start, end); // use public constructor
}
}
// 将外围类的实例转换为序列化代理
private Object writeReplace(){
return new SerializationProxy(this);
}
// 防御伪造序列化攻击
private void readObject(ObjectInputStream os) throws InvalidObjectException{
throw new InvalidOjectException("Proxy required");
}
}
writeReplace: 通过序列化代理,这个方法可以被逐字复制到任何类中
readObject:防御伪造序列化攻击(攻击者有可能伪造,企图违反该类的约束条件)
readResolve:不必单独确保被反序列化的实例一定要遵守类的约束条件
序列化代理模式优点
-
可以阻止伪字节流的攻击以及内部域的调用攻击
-
允许period类的域为final(确保Period类真正不可变的关键)
-
不必显示执行有效性检查
-
允许反序列化实例有着与原实例不同的类
// EnumSet静态工厂返回两种EnumSet实例:枚举类型<=64:RegularEnumSet, 枚举类型>64:JumboEmumSet private static class SerializationProxy <E extends Enum<E>> implements Serializable{ private final Class<E> elementType; 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 = 43123813913889890L; }
序列化代理模式的局限
- 不能与可以被客户端扩展的类相兼容
- 不能与对象图中包含循环的某些类相兼容?
- 开销增加
当发现需要在一个不能被客户端扩展的类上写readObject或者writeObject方法时,应该考虑使用序列化代理模式