[Effective Java]第十一章 序列化

来源:互联网 发布:mac os yosemite 下载 编辑:程序博客网 时间:2024/05/29 17:44

第十一章 序列化

74、 谨慎地实现Serializable接口

实现Serializable接口而付出的最大代价是,一旦一个类被发布,就大降低了“改变这个类的实现”的灵活性。如采用默认的序列化方式时(仅实现Serializable),且没有在一个名为serialVersionUID的私有静态final的long域显示地指定该标识号,则如果类改变了,则会导致不兼容(没有显式指定该标识号,系统就会自动地根据这个类来调用一个复杂的运算过程,从而运行时产生该标示号,这个自动的产生的值会受到类名称、它所实现的接口的名称,以及所有共有的和受保护的成员的名称的影响)。

实现Serializable的第二个代价是,它增加了出现Bug和安全漏洞的可能性。反序列化过程不是调用原有的构造器,所以你很容易忘记要确保:反序列化过程必须也要保证所有“由真正的构造器建立起来约束关系”,并且不允许攻击者访问正在构造过程上的对象的内部信息,依靠默认的反序列化机制,很容易对象的约束关系遭到破坏,以及遭受非法访问(第76条)。

实现Serializable的第三个代价是,随着类发行新的版本,相关的测试负担也增加了。要检查是否可以在“新版本中序列化一个实例,然后在旧版本中反序列化“。

实现Serializable接口并不是一个很轻松就可以做出的决定。如果一个类将要加入到某个框架中,并且该框架依赖于序列化来实现对象传输或者序列化,对于这个类来说,实现Serializable接口就非常有必要,若前者也实现了Serializable接口,它就会更易于被后者使用

根据经验,如Data和BigInteger这样的的值类应该实现Serializable,大多数的集合类也是应该如此。代表活动实体的类,如线程池,一般不应该实现Serializable。

为了继承而设计的类,应该尽可能少地去实现Serializable接口,用户的接口也应该尽可能少地继承Serializable接口。如果违反了这条规则,扩展这个类或者实现这个接口的程序员就会背上沉重的负担。然后在有些情况下是合适的,如,这个类或接口主要是为了参与到某个框架中,而该框架要求所有参与者都实现Serializable接口,则实现Serializable是有意义的。

如果你实现了一个带有实例域的类,它是可序列化和可扩展的,你就应该担心这样一条告诫:如果类有一些约束条件,当类的实例域被初始化成它们的默认值(整数类型为0,boolean类型为false,对象引用类型为null)时,就会违背这条约束,这时候你就必须给这个类添加这个readObjectNoData方法:

private void readObjectNoData() throws InvalidObjectException{    throw new InvalidObjectException("Stream data required");}

对于为继承而设计的不可序列化的类,你应该考虑提供一个无参构造器,因为他的子类可能是可序列化的,一旦子类实现Serializable接口,则在反序列化时会调用调用父类的无参构造器。最好在所有约束关系都已经建立的情况下再创建对象。下面是一个父类不可序列化,而子类可序列化的建议做法(这通常不需要付出特别的努力,因为许多为了继承而设计的类都不具有状态,但是情况并不总是这样,如下例所示):

//不可序列化public abstract class AbstractFoo {    private int x, y; // 状态    // 枚举字段用于跟踪初始化于哪种状态:NEW-新建,INITIALIZING-正在初始化,INITIALIZED-初始化完成    private enum State {       NEW, INITIALIZING, INITIALIZED    };    /*     * 初始化到哪种状态了     * 注意,init是一个原子引用。在遇到特定的情况下,确保对象的完整性是很重要的。如果没有这样的防范,     * 万一有个线程要在某个实例上调用initialize,而另一个线程又要企图使用这个实例,第二个线程就有     * 可能看到这个实例处于不一致的状态。这种模式利用compareAndSet方法来操作枚举的大孩子引用。     */    private final AtomicReference<State> init = new AtomicReference<State>(State.NEW);    //该域是第一版中的    //  private boolean initialized = false;//第一版    public AbstractFoo(int x, int y) {       initialize(x, y);    }    //此构造和下面的方法让子类的readObject方法来初始化我们的状态    protected AbstractFoo() {//受保护的构造器    }    protected final void initialize(int x, int y) {       //compareAndSet(V expect, V update):如果当前值 == 预期值expect,       //则以原子方式将该值设置为给定的更新值update。如果还未初始化时,将初始状态设为:INITIALIZING       if (!init.compareAndSet(State.NEW, State.INITIALIZING))           //if (initialized)//第一版           throw new IllegalStateException("Already initialized");       this.x = x;       this.y = y;       // 构造完后设置成完成状态       init.set(State.INITIALIZED);       //  initialized=true;//第一版    }    //这些方法提供了访问内部状态,因此可以    //通过子类的writeObject方法来手动序列化。    protected final int getX() {       checkInit();       return x;    }    protected final int getY() {        checkInit();       return y;    }    // 所有共有的与保护的实例方法都必须调用    private void checkInit() {       //  if(!initialized)//第一版       if (init.get() != State.INITIALIZED)           throw new IllegalStateException("Uninitialized");    }    // 其余的略}//继承不可序列化父类,但已自己实现Serializable接口public class Foo extends AbstractFoo implements Serializable {    private void readObject(ObjectInputStream s) throws IOException,           ClassNotFoundException {       s.defaultReadObject();       // 手工反序列化和初始化父类的状态       int x = s.readInt();       int y = s.readInt();       initialize(x, y);    }    private void writeObject(ObjectOutputStream s) throws IOException {       s.defaultWriteObject();       // 手工序列化父类的状态       s.writeInt(getX());       s.writeInt(getY());    }    // Constructor does not use the fancy mechanism    public Foo(int x, int y) {       super(x, y);    }    private static final long serialVersionUID = 1856835860954L;}

内部类不应该实现Serializable接口,它们使用编译器产生的合成域来保存指向外围实例的引用,以保存来自外围作用域的局部变量的值。“这些域如何对应到类定义中”并没有明确的规定,就好像匿名类和局部类的名称一样,它们都是由编译器临时产生的,我们不能引用它们。因此,内部类默认序列化形式是定义不清楚的。然而,静态成员类却可以实现Serializable接口。

总之,实现Serializable接口不是容易的事。实现Serializable接口是个严肃的承诺,必须认真对待。如果类是为了继承使用的,则一定要提供一个默认构造器,以防止父类序列化。

75、 考虑使用自定义的序列化形式

如果没有先认真考虑默认的序列化形式是否合适,则不要贸然接受。一般来讲只有当你自行设计的自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化形式。

考虑一个对象为根的对象图,相对于它的物理表示法而言,该对象的默认序列化形式是一种比较有效的编码形式。换句话说,默认的序列化形式描述了该对象内部所包含的数据,以及每一个可以从这个对象到达的其他对象的内部数据。它也描述了所有这些对象被链接起来后的拓扑结构。
对于一个对象来说,理想的序列化应该只包含该对象所表示的逻辑数据,而逻辑数据与物理表示法应该是各自独立的。

如果一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。例如,对于下面仅仅表示人名的类,默认的序列化形式就是合理的:

public class Name implements Serializable {    /**    * Last name. Must be non-null.    * @serial    */    private final String lastName;    /**    * First name. Must be non-null.    * @serial    */    private final String firstName;    /**    * Middle name, or null if there is none.    * @serial    */    private final String middleName;    ... // Remainder omitted}

上面的数据全是逻辑内部,又是物理内容,所以适合于默认序列化方式。

即使你确定默认的序列化形式是合适的,通常你还必须提供一个readObject方法以保证约束关系和安全性。对于Name这个类而言,readObject方法必须确保lastName和firstName是非null的,第76与78条详细地讨论这个问题。

注,虽然lastName、firstName和middleInitial域是私有的,但它们依然有相应的文档注释。这是因为,这些私有域定义了一个公有的API,即这个类的序列化形式,并且该公有的API必须建立文档。@sreial标签告诉Javadoc工具,把这些文档信息放在有关序列化的特定文档页中。

下面与Name不同,它是一个极端例子,该类表示了一个字符串列表:

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}

从逻辑上讲,它表示一个字符序列,但从物理上看,它把字符串序列表示成了双向链表。如果你采用默认的序列化,它会将链表中的所有项都序列化,以及这些项之间的所有双向链接。

当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下缺点:
1、 它使这个类的导出API永远地束缚在该类的内部表示法上。上面例子中私有的StringList.Entry类变成了公有API的一部分。如果将来版本中内部表示法变化了,StringList仍将需接受链表形式的输入,并产生链表的输出。这个类永远也摆脱不掉维护链表项所需要的代码,即使不再使用链作为内部数据结构了,也仍需要这些代码。因为原来已序列化的二进对象在默认恢复过程中与当前版本类不兼容而导致反序列化失败,比如当前版本中少了某个域。
2、 它会消耗过多的空间与时间。上面的链表中的项的next、previouse是链表的实现实节,不用关心链接的物理信息,在恢复时是可以构造这些关系,不需要将它们一起序列化,将它们写入磁盘中,或者在网络上发送都将非常慢。
3、 它会消耗过多的时间。序列化逻辑并不了解对象图的拓扑关系,所以它必须经过一个昂贵的图遍历过程。
4、 它会引起栈溢出。默认的序列化过程要对对象图进行一次递归遍历,如果对象图层次很深的话很易容就会引起栈的溢出。

对于上面的StringList,我们只需先包含链表中字符串的数目,然后紧跟着这些字符串即可。这样就构成了StringList所表示的逻辑数据,与它的物理表示细节脱离:

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;    }    // 将指定的字符串接连到字符串列表中    public final void add(String s) {       // 实现省略    }    /**     * 序列化当前 {@code StringList} 实例.     *     * @serialData 列表数目 (字符串列表所包含的字符串个数) 是导出的     *             ({@code int}), 紧接着是所有的元素 (each     *             a {@code String}), 并且按照适当的顺序.     */    private void writeObject(ObjectOutputStream s) throws IOException {       s.defaultWriteObject();//默认序列化,将非静态与非transient序列化       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());    }    private static final long serialVersionUID = 93248094385L;    // 其他略}

如果所有实例域都是瞬时的,从技术上来说可以不调用defaultWriteObject与defaultReadObject两个方法,但不推荐这样做,因为后面可能有新的非瞬时实例时,还能保持前后兼容。

默认序列化对上面StringList是适合,序列化与反序列化StringList实例都会产生原始对象的忠实拷贝,约束关系没有被破坏。但如果对象约束关系依赖于特定于实现的细节,情况不一样了,如HashMap,它会将Key哈希地址转换成哈希表中的对应桶号,如果使用默认的序列化方式,则恢复时出现严重Bug,因为恢复出的对象的地址已发生变化会导致元素新的哈希码发生变化,则真实的桶号也会发生变化,所以恢复出的原哈希表已不适用。所以为了防止约束关系的破坏,HashMap需要手动的序列化,API也正是这样做的。

transient指导原则:如果一些域不需要序列化,即冗余域,则标示为transient;如果域的值依赖于JVM的运行,则标示为transient;在决定将一个域做成transient之前,请一定要确认它是一个逻辑状态;如果你正在使用自定义的序列化形式,大多数或者所有的域都应该标示为transient。

默认序列化方式时,transient的域反序列化时会初始化为默认值:对象null,数值0,布尔false。如果这些值不是你期望的,则需要在readObject方法中重新手动初始化,它首先调用defaultReadObject,然后把这些transient域恢复为可接受的值。另一种方法是,这些域可以被延迟到第一次被使用的时候才真正被初始化。

无论你是否使用默认的序列化形式,如果在读取对象任何状态的地方使用到了同步,则也必须在对象序列化上强制这种同步。如果你使用的是默认序列化方式,也得要这样做:

private synchronized void writeObject(ObjectOutputStream s)       throws IOException {    s.defaultWriteObject();}

不管是哪种方式,都要为可序列化的类显示的提供序列版本唯一标示(serialVersionUID),一是可以避免不兼容,二是提升小小的性能(如果没有提供显式的序列化版本UID,就需要在运行时通过一个高开销的计算过程产生一个序列化版本UID)。

要声明一个序列化版本UID非常简单,只要在你的类中增加下面一行:

private static final long serialVersionUID = randomLongValue;

在编写新的类时,为randomLongValue选择什么值并不重要。通过在该类上运行serialver工具,你就可以得到一个这样的值,但是,如果你凭空编造一个数值,那也是可以的。

如果是默认序列化,又没有指定serialVersionUID时,要想兼容,则新类中要手动指定为以前版本中的serialVersionUID值,要起查看以前版本中类的serialVersionUID,请使用serialver命令。

serialver用法:serialver [-classpath类路径][-show][类名称 …]

如果你想为一个类生成一个新的版本,这个类与现有的类不兼容,那么你需要修改序列化版本UID声明中的值即可。结果,前一版本的实例经序列化之后,再做反序列化时会引发InvalidClassException异常而失败。

总之,当你决定将一个类做成序列化的时候,请仔细考虑应该采用什么样的序列化形式。只有当默认的序列化形式能够合理地描述对象地逻辑状态时,才能使用默认地序列化形式,否则就要设计一个自定义地序列化形式,通过它合理地描述对象地状态。

76、 保护性地编写readObject方法

假设你将第39条中的不可变Period日期范围类做成可序列化的,你可能认为使用默认序列化方式较合适,因为Period的物理表示法正好是逻辑数据内容。但这样会有很大的安全隐患。问题在于,readObject方法实际上相当于另一个公有的构造器,如同其他的构造器一样,它也需要注意同样的所有注意事项:一是构造器必须检查其参数的有效性,二是在必要的时候对参数进行保护性拷贝(见39),同样的,readObject方法也需要这样做。如果readObject方法没有做到这两点,对于攻击都来说就很容易破坏这个类的约束条件了。

伪字节流的攻击法

因为readObject方法是字节流作为参数的,因此我们可以伪造这样的对象流后传递给它进行反序列化。假设现在Period类采用默认的序列化方式,下面这个程序将反序列化产生一个Period实例,而它的结束时间比开始时间要早:

public class BogusPeriod {    // 字节流可能不是来自真真的 Period实例,而是原有基础修改过的    private static final byte[] serializedForm = new byte[] { (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) throws Exception {       InputStream is = new ByteArrayInputStream(serializedForm);       ObjectInputStream ois = new ObjectInputStream(is);       System.out.println(ois.readObject());    }}

被用来初始化serializedForm是这样产生的:首先对一个正常的Period实例进行序列化,然后对得到字节流进行手工编辑。你可以在《Java Object Serialization Specification》中查到有关序列化字节流格式的规范信息。如果你运行该程序,会输出:

Sat Jan 02 04:00:00 CST 1999 - Mon Jan 02 04:00:00 CST 1984

只要把Period声明成可序列化的,攻击者就可以伪造这样的流来进行攻击。为了修正这个问题,你可以提供一个readObject方法,该方法首先要调用defaultReadObject,然后检查被反序列化之后的对象的有效性,如果验证不通过,则抛异常,使反序列化失败:

private void readObject(ObjectInputStream s) throws IOException,       ClassNotFoundException {    s.defaultReadObject();//先调用默认恢复    // 再进行状态参数的有效性验证    if (start.compareTo(end) > 0)       throw new InvalidObjectException(start + " after " + end);}

内部私有域盗用攻击法

尽管上面那样修正避免了攻击者创建无效的Period实例,但是,这里仍然隐藏着一个更为微妙的问题。当我们正常序列化完后,然后附加上两个额外的引用,指向Period实例中的两个私有的Date域。攻击者从ObjectInputStream中读取Period实例,然后读取附加在后面的“恶意编制的对象引用”。这些对象引用使用攻击者能够访问到Period对象内部私有Date域所引用的对象。通过改变这些Date实例,来改变Period实例,下面演示了这种攻击:

public class MutablePeriod {    // 一个 period 实例    public final Period period;    // period's 的 start 域, 指向我们不能访问的私有域    public final Date start;    // period's 的 end 域, 指向我们不能访问的私有域    public final Date end;    public MutablePeriod() {       try {           ByteArrayOutputStream bos = new ByteArrayOutputStream();           ObjectOutputStream out = new ObjectOutputStream(bos);           // 序列化正确的Period实例           out.writeObject(new Period(new Date(), new Date()));           /*            * 添加两个恶意的引用让它们指向上面Period序列化字节流中的私有域            * 详细做法请参考 "Java Object Serialization Specification,"            * Section 6.4.            */           byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // Ref #5           bos.write(ref); // The start field           ref[4] = 4; // Ref # 4           bos.write(ref); // The end field           // 在反序列化的过程中,偷取私有域,故能访问到私有域           ObjectInputStream in = new ObjectInputStream(                  new ByteArrayInputStream(bos.toByteArray()));           period = (Period) in.readObject();           // 下面引用序列化对象中的私有域           start = (Date) in.readObject();//偷取私有的start域           end = (Date) in.readObject();//偷取私有的end域       } catch (Exception e) {           throw new AssertionError(e);       }    }    public static void main(String[] args) {       MutablePeriod mp = new MutablePeriod();       Period p = mp.period;       Date pEnd = mp.end;       // Let's turn back the clock       pEnd.setYear(78);       System.out.println(p);    }}

运行得到如下结果:

Mon May 24 13:57:08 CST 2010 - Wed May 24 13:57:08 CST 1978

上面虽然反序列化的过程中没有破坏约束条件,但反序列化完后通过恶意的引用私有内部状态出了问题。这个问题的根源在于Period的readObject方法并没有完成足够的保护性拷贝。因此,对于每个可序列化的不可变类,如果它包含了私有的可变组件,必须对这些组件进行保护性拷贝,下面重新对Period的readObject方法进一步的修正:

private void readObject(ObjectInputStream s) throws IOException,       ClassNotFoundException {    s.defaultReadObject();//先调用默认恢复    // 对可变组件进行保护性拷贝    start = new Date(start.getTime());    end = new Date(end.getTime());    // 进一步检测内部状态参数是否有效    if (start.compareTo(end) > 0)       throw new InvalidObjectException(start + " after " + end);}

注意,保护性拷贝是在有效性检查之前进行的,而且,我们没有使用Date的clone方法来执行保护拷贝,这两个细节对于保护Period不受攻击是必须的(原因请见39)。同时原Period类中的start与end域为final类型是行不通了的,因为如果这样将不能进行拷贝。这是很遗憾的,但这还算相对好的做法,不过我们可以加上volatile关键字加强并发的可见性。经过上面修改后我们再次执行MutablePeriod类,结果正常:

Mon May 24 14:19:26 CST 2010 - Mon May 24 14:19:26 CST 2010

当一个对象被反序列化地时候,对于客户端不应该拥有地对象引用,如果哪个域包含了这样地对象引用,就必须要做保护性拷贝。因此,对于每个可序列化地不可变类,如果它包含了私有地可变组件,那么在它的readObject方法中,必须要对这些组件进行保护性拷贝。

在1.4中,为了阻止恶意的对象引用攻击,同时节省保护性拷贝的开销,在ObjectOutputStream中增加了wirteUnshared和readUnshared方法。但遗憾的是,这些方法都很容易受到复杂的攻击,即本质上与第77条中所述ElvisStealer攻击相似的攻击。所以不要使用这两个方法,虽然它们通常比保护性拷贝更快,但是它们还是不很安全。

readObject其实就相当于公共的构造器,所以对于readObject额外要注意的是,不要在readObject方法中调用可能被覆盖的方法,因为这与在构造函数中调用被覆盖方法是一样错误的。无论是直接调用还是间接调用都不可以。如果违反了这条规则,并且覆盖了该方法,被覆盖地方法将在子类地状态被反序列化之前先运行。程序很可能会失败。

默认的readObject 方法是否可以被接受,我们只需做一个简单的测试:增加一个公有的构造器,其参数对就于该对象每个非transient的域,并且无论参数的值是什么,都是不进行检查就可以保存到相应域中,如果对就这个做法不赞同,就必须提供一个显示的readObject方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝,另一种方法是使用序列化代理模式(见79条)

总之,每当你编写readObject方法的时候,都要这样想:你正在编写一个公有的构造器,无论给它传递什么样的字节流,它都必须产生一个有效的实例。不要假设这个字节流一定代表着一个真正被序列化过的实例。虽然在本条目的例子中,类使用了默认的序列化形式,但是,所有讨论到的有可能发生的问题也同样适用于使用自定义序列化形式的类。下面摘要的形式给出一些指导方针,有助于编写更加健壮的readObject方法:

  • 对于对象引用域必须保持私有的类,要保护性地拷贝这些域中地每个对象。不可变类的可变组件就属于这一类别。
  • 对于任何约束条件,如果检查失败,则抛出一个InvalidObjectException异常。这些检查动作跟在所有的保护性拷贝之后。
  • 如果整个对象图在被反序列化之后必须进行验证,就应该使用ObjectInputValidation接口
  • 无论是直接方式还是间接方式,都不要调用类中任何可被覆盖的方法。

77、 对于实例控制,枚举类型优先于readResolve

public class Elvis {    public static final Elvis INSTANCE = new Elvis();    private Elvis() { ... }    public void leaveTheBuilding() { ... }}

对于这个类实现了Serializable接口的单实例类,只要反序列化就一定会产生一个不同于现VM中实例的新对象,这是肯定的。

readResolve方法允许你用另一个实例去替代readObject方法创建的实例,如果可序列化类中有readResolve的方法,则在readObject调用之后再调用readResolve方法,然后,readResolve方法返回的对象引用将被返回,取代新建的对象,这样readObject新建的对象不再被引用,立即成为垃圾回收的对象。具体做法如下:

private Object readResolve(){    return INSTANCE;}

该方法忽略了被反序列化的对象,只返回该类被初始化时创建的那个特殊的Elvis实例,因此,Elvis实例的序列化形式并不需要包含任何实际的数据(因为真真反序列化得到的实例被readResolve方法给替换成了当前VM中正在运行的原有的单实例,所以单例模式在序列化成字节码流后对反序列化根本没有用,所以不需要将任何域序列化)。单例中的所有的实例域都应该被声明为transient,事实上,如果依赖readResolve进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient的,否则,攻击者们可能使用readResolve方法被运行前,通过一个引用指向反序列化出来的对象,从而系统中会同时存在两个实例。基本原理就是非transient引用域会默认序列化,并且这个域的readResolve方法会在Singleton的readResolve方法前调用。当这个域反序列化时,就可以使用精心制作的该域的“反序列化替代对象”即“盗用者”来代替这个域默认的反序列化过程。以下是具体工作原理:先编写一个“盗用者”类,它既有readResolve方法,又有实例域,实例域指向被序列化的Singleton的实例,在序列化流中,用“盗用者”类的实例代替Singleton的非transient域。现在Singleton序列化流中包含“盗用者”类实例,而“盗用者”类实例则引用Singleton实例。当反序列化时这个“盗用者”类的readResolve会先于Singleton类的readResolve方法运行,因此,当“盗用者”的readResolve方法运行时,它的实例域是可以引用到被部分反序列化的Singleton实例,最后“盗用者”将引用到的Singleton实例赋值给“盗用者”的静态域,供外界引用,最终导致系统同时存在两个实例。具体做法请看下面几个类:

// 有问题的单例 - 有一个非transient域!public class Elvis implements Serializable {    public static final Elvis INSTANCE = new Elvis();    private Elvis() {}    //!! 非transient域。注,这里安全做法是使用transient修饰    private String[] favoriteSongs = { "Hound Dog",           "Heartbreak Hotel" };    public void printFavorites() {       System.out.println(Arrays.toString(favoriteSongs));    }    private Object readResolve() throws ObjectStreamException {       return INSTANCE;    }}//盗用者,用来代替Elvis类中的非transient域favoriteSongs的//反序列化过程中的readResolve方法调用public class ElvisStealer implements Serializable {    static Elvis impersonator;//保存反序列化得到的Singleton实例供外界使用    private Elvis payload;//通过编译字节充让它指向反序列化得到的Singleton实例    private Object readResolve() {       // 将部分反序列化的实例存储到静态域中供外界使用       impersonator = payload;       // 注这里一定要返回数组类型,因为ElvisStealer是用来替代favoriteSongs域的       return new String[] { "A Fool Such as I" };    }    private static final long serialVersionUID = 0;}public class ElvisImpersonator {    // 该字节流中潜伏了盗用者    private static final byte[] serializedForm = new byte[] { (byte) 0xac,           (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45, 0x6c, 0x76,           0x69, 0x73, (byte) 0x84, (byte) 0xe6, (byte) 0x93, 0x33,           (byte) 0xc3, (byte) 0xf4, (byte) 0x8b, 0x32, 0x02, 0x00, 0x01,           0x4c, 0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65,           0x53, 0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61,           0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a,           0x65, 0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45,           0x6c, 0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72,           0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,           0x4c, 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74,           0x00, 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70,           0x71, 0x00, 0x7e, 0x00, 0x02 };    public static void main(String[] args) throws Exception {       InputStream is = new ByteArrayInputStream(serializedForm);       ObjectInputStream ois = new ObjectInputStream(is);       Elvis elvis = (Elvis) ois.readObject();//返回Elvis.INSTANCE       //但这里返回的反序列化过程中创建的实例       Elvis impersonator = ElvisStealer.impersonator;       //下面会输出不同的结果,因为他们是不同的两个实例       elvis.printFavorites();       impersonator.printFavorites();    }}

输出结果:

[Hound Dog, Heartbreak Hotel][A Fool Such as I]

上面只需将favoriteSongs域声明成transient即个解决上面的问题。但最好把这个类做成一个单元素的枚举类型(见第3条)进行修正。自从1.5后使用readResolve就不是个好做法了。下面使用单一的枚举类型来修正:

public enum Elvis {    INSTANCE;    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };    public void printFavorites() {       System.out.println(Arrays.toString(favoriteSongs));    }}

总之,应该尽可能地使用类型来实施实例控制的约束条件。如果做不到(如果受控实例在编译时还不知道时,就不适合使用枚举类型),同时又需要一个既可序列化又是实例受控的类,就必须提供一个readResolver方法,并确保该类的怕有实例域都为基本类型或者是transient的。

78、 考虑用序列化代理代替序列化实例

序列化代理模式可以避免直接对可序列化类进行序列化时的一系列问题,如出错和出现安全问题的可能性,因为它导致实例要利用语言之外的机制来创建,而不是用普通的构造器。

这个代理类很简单,首先它是一个静态的成员类,精确地表示外围类的实例逻辑状态。这个静态的成员类被称作序列化代理。它应该有一个单独的构造器,其参数就是外围类。这个构造器只是从它的参数中复制数据,不需要进行任何一致性检查或者保护性拷贝。外围类及其序列化代理都必须声明实现为Serializable接口。下面使用序列化代理类来替代76中的方案:

//可序列化的不可变类public final class Period implements Serializable {    private final Date start;    private final Date end;    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;    }    /*     * 外围类的序列化代理类     * 该的为静态私有的会很重要,这新避免了外界伪造这个类实例的序列化字节     * 流,因为外界根本就能不能构造该私有成员类的实例,这样就避免了第76条     * 中的第一个 通过伪造序列化字节流进行攻击的安全问题;但此时好像还是可     * 能通 过76条中的第二种攻击方法来进行攻击,这其实则不然,即使在外界修     * 改了 SerializationProxy里的start与end私有域,但readResolve方法是     * 调用的公有构造器,而公有构造器以是拷贝的方式来构造的,所以外面对私     * 有域的修改不会影响到外围对象,所以序列化代理类同时也避免了76条中的     * 第二 种攻击法;对于单例模式的序列化,也可以避开第77中的问题,因为序     * 列化的不是单例类本身,只是它的序列化代替对象,外界拿手的也只是代理     * 对象,不过此时readResolve返回要是最开始初始化的单例对象,还有就是     * 这个序列化代理类也不需要定义单例的所有域。     */    private static class SerializationProxy implements Serializable {       private final Date start;       private final Date end;       SerializationProxy(Period p) {           this.start = p.start;           this.end = p.end;       }       private static final long serialVersionUID = 234098243823485285L;       // 将序列化代理转变回外围类的实例,换句话说,该方法在序列化之前,将外围类实例转变成为它的序列化代理,它返回一个逻辑上相当的外围类实例       private Object readResolve() {           // 使用公有的构造器创建实例           return new Period(start, end);       }    }    // writeReplace 方法将使用序列化代理对象代替自己    private Object writeReplace() {       return new SerializationProxy(this);    }    // 不让外界伪造外围类序列化字节流然后直接通常外围类反序列化,    // 如果外界这样做则抛异常    private void readObject(ObjectInputStream stream)           throws InvalidObjectException {       throw new InvalidObjectException("Proxy required");    }}

上面序列化代理类中的readResolve方法仅仅利用它的公有API创建外围类的一个实例,这正是该模式的魅力之所在。它极大地消除了序列化机制中语言本身之外的特征,因为反序列化实例是利用与任何其他实例相同的构造器、静态工厂和方法而创建的。这样你就不必单独确保被反序列化的实例一定要遵守类的约束条件。如果该类的静态工厂或构造器建立了这些约束条件,并且它的实例方法在维持着这些约束条件,你就可以确信序列化会维护这些约束条件。

序列化代理方法可以阻止伪字节流的攻击与以及内部的盗用攻击,与前面方案(见第76)不同的是,这种方案允许Period的域为final的,为了确保Period类真正不可变是必须的。

序列化代理模式有两个局限性。它不能与可以被客户端扩展的类兼容。它也不能与对象图中包含循环的某些类兼容:如果你企图从一个对象的序列化代理的readResolve方法内部调用这个对象中的方法,就会得到一个ClassCastException异常,因为你还没有这个对象,只有它的序列化代理。

总之,每当你发现自己必须在一个不能被客户端扩展的类上编写readObject或者writeObject方法的时候,就应该考虑使用序列化代理模式,对具有约束条件的对象序列化是否不错的选择,但性能不如保护性拷贝(见76)。

转载自:[Effective Java]第十一章 序列化

0 0
原创粉丝点击