Effective Java一书一直在强调,你的类实现Serializable接口前,一定要进行价值评估,是否值得这么做,尤其你的类是一个需要进行大量继承扩展的类。因为实现了Serializable接口,就会大大的增加出错和出现安全漏洞的可能性。蓄意攻击者可以通过伪造修改字节码的方式使你的代码出现安全漏洞!从一个角度看, readObject方法是一个参数为“字节流”的构造函数,因此篡改了字节流,通过readObject就可能得到一个状态错误的对象。
通过重写readObject, writeObject 两个方法无法解决这种问题,Effective Java中提供了一个非常优雅的对象序列化方式--序列化代理模式。通过这种方式可以解决上述各种序列化问题,今天就介绍一下序列化代理模式,直接上代码吧:
package cn.test; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InvalidObjectException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.util.Date; 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) { if(null == start || null == end || start.after(end)){ throw new IllegalArgumentException("请传入正确的时间区间!"); } this.start = start; this.end = end; } public Date start(){ return new Date(start.getTime()); } public Date end(){ return new Date(end.getTime()); } @Override public String toString(){ return "起始时间:" + start + " , 结束时间:" + end; } /** * 序列化外围类时,虚拟机会转掉这个方法,最后其实是序列化了一个内部的代理类对象! * @return */ private Object writeReplace(){ System.out.println("进入writeReplace()方法!"); return new SerializabtionProxy(this); } /** * 如果攻击者伪造了一个字节码文件,然后来反序列化也无法成功,因为外围类的readObject方法直接抛异常! * @param ois * @throws InvalidObjectException */ 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; } /** * 反序列化这个类时,虚拟机会调用这个方法,最后返回的对象是一个Period对象!这里同样调用了Period的构造函数, * 会进行构造函数的一些校验! */ private Object readResolve(){ System.out.println("进入readResolve()方法,将返回Period对象!"); // 这里进行保护性拷贝! return new Period(new Date(start.getTime()), new Date(end.getTime())); } } /** * 测试方法 * @throws IOException * @throws FileNotFoundException * @throws ClassNotFoundException */ public static void main(String[] args) throws FileNotFoundException, IOException, ClassNotFoundException { Period period = new Period(new Date(), new Date()); System.out.println("序列化前:" + period); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File("D:/obj.data"))); oos.writeObject(period); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("D:/obj.data"))); Period newPeriod = (Period)ois.readObject(); System.out.println("序列化后:" + newPeriod); } }
测试输出结果为:
序列化前:起始时间:Mon Jul 30 21:06:42 CST 2012 , 结束时间:Mon Jul 30 21:06:42 CST 2012 进入writeReplace()方法! 进入readResolve()方法,将返回Period对象! 序列化后:起始时间:Mon Jul 30 21:06:42 CST 2012 , 结束时间:Mon Jul 30 21:06:42 CST 2012
整个过程没有任何问题。在实际的序列化与反序列化中,外围类都没有参与,参与的都是私有的内部类。
那为什么这种方式能够阻止蓄意修改字节码的攻击行为呢?我们看下面这个蓄意破坏序列化字节码的例子:
Period类:
package com.test; import java.io.Serializable; import java.util.Date; public final class Period implements Serializable{ private static final long serialVersionUID = 1L; private final Date start; private final Date end; public Period(Date start, Date end) { if(null == start || null == end || start.after(end)){ throw new IllegalArgumentException("请传入正确的时间区间!"); } this.start = start; this.end = end; } public Date start(){ return new Date(start.getTime()); } public Date end(){ return new Date(end.getTime()); } @Override public String toString(){ return "起始时间:" + start + " , 结束时间:" + end; } }
这个Period类本身保证了结束时间肯定在开始时间之后,我们看看下面这个类,他轻易构造出了一个结束时间在开始时间之前的Period类对象:
package com.test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.Date; public class MutablePeriod { public final Date start; public final Date end; public final Period period; public MutablePeriod() throws FileNotFoundException, IOException, ClassNotFoundException{ ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(/*new FileOutputStream(new File("D:/obj.data"))*/bos); oos.writeObject(new Period(new Date(), new Date())); byte[] ref = {0x71, 0 , 0x7e, 0, 5}; bos.write(ref); ref[4] = 4; bos.write(ref); ObjectInputStream ois = new ObjectInputStream(/*new FileInputStream(new File("D:/obj.data"))*/new ByteArrayInputStream(bos.toByteArray())); period = (Period)ois.readObject(); start = (Date)ois.readObject(); end = (Date)ois.readObject(); } public static void main(String[] args) throws FileNotFoundException, IOException, ClassNotFoundException { MutablePeriod mp = new MutablePeriod(); Period p = mp.period; Date start = mp.start; Date end = mp.end; end.setYear(78); System.out.println(p); } }
将新构造出来的Period输出后,结果为:
起始时间:Mon Jul 30 21:37:46 CST 2012 , 结束时间:Sun Jul 30 21:37:46 CST 1978
结束时间在开始时间之前,如果这个Period传递给依赖于其他方法,而这些方法本质上依赖于Period的原始特性,则能产生更严重的后果!这种情况出现的根本问题在于,Period类的readObject方法没有做保护性拷贝,如果我们重写readObject方法,对Period类引用类型的成员变量做保护性拷贝,也能避免上述问题,但这样做,Period类中相应的引用型成员变量就不能声名为final类型的了。解决这个问题最优雅的方法就是序列化代理的使用了。序列化与反序列化都是操作的代理类对象,反序列化时,代理类对象的readResolve方法调用Period的构造函数构造一个全新的Period对象,并且对于引用类型,进行保护性拷贝!这样Period中的引用类型成员变量依然是final的。