88. 保护性的编写 readObject 方法

      假设你决定要把这个类成为可序列化的。因为 Period 对象的物理表示法正好反映了它的逻辑数据内容,所以,使用默认的序列化形式是合理的(详见 87 条)。因此,为了使这个类成为可序列化的,似乎你所需要做的也就是在类的声明中增加 implements Serializable 字样。然而,如果你真的这么做,那么这个类就不保证它的关键约束了。

      问题在于 readObject 方法实际上相当于另外一个公有的构造器,它要求同其他构造器一样警惕所有的注意事项。构造器必须检查其参数的有效性(详见 49 条),并且在必要的时候对参数进行保护性拷贝(详见 50 条),同样的,readObject 方法也需要这样做。如果 readObject 方法无法做到这两者之一,对于攻击者来说要违反这个类的约束条件就相对容易很多。

      不严格的说, readObject 方法是一个「用字节流作为唯一参数」的构造器。在正常使用的情况下,对一个正常构造的实例进行序列化可以产生字节流。但是,当面对一个人工仿造的字节流时, readObject 产生的对象会违反它所属类的约束条件,这时问题就产生了。这种字节流可以用来创建一个不可能的对象(impossible object),这时利用普通构造器无法创建的。

      假设我们仅仅在 Period 类的声明加上了 implements Serializable 字样。那么这个丑陋的程序代码将会产生一个 Period 实例,他的结束时间比起始时间还早。对于高位 byte 值进行强制类型转换是 Java 缺少 byte 并且做出 byte 类型签名的不幸决定的后果:

    1. public class BogusPeriod {
    2. // Byte stream couldn't have come from a real Period instance!
    3. private static final byte[] serializedForm = {
    4. (byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
    5. 0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
    6. 0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
    7. 0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
    8. 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
    9. 0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
    10. 0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
    11. 0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
    12. 0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
    13. (byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
    14. 0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
    15. 0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
    16. 0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
    17. 0x00, 0x78
    18. };
    19. public static void main(String[] args) {
    20. Period p = (Period) deserialize(serializedForm);
    21. System.out.println(p);
    22. }
    23. // Returns the object with the specified serialized form
    24. static Object deserialize(byte[] sf) {
    25. try {
    26. return new ObjectInputStream(
    27. }
    28. catch (IOException | ClassNotFoundException e) {
    29. throw new IllegalArgumentException(e);
    30. }
    31. }

      为了修整这个问题,可以为 Period 提供一个 readObject 方法,该方法首先调用 defaultReadObject,然后检查被反序列化之后的对象有效性。如果有效性检查失败,readObject 方法就会抛出一个 InvalidObjectException 异常,这使得反序列化过程不能成功的完成:

      尽管这样的修成避免了攻击者创建无效的 Period 实例,但是这里依旧隐藏着一个更为微妙的问题。通过伪造字节流,要想创建可变的 Period 实例仍是有可能的,做法是:字节流以一个有效的 Period 实例开头,然后附加上两个额外的引用,指向 Period 实例中两个私有的 Date 字段。攻击者从 ObjectInputStream 读取 Period 实例,然后读取附加在其后面的「恶意编制的对线引用」。这些对象引用使得攻击者能够访问到 Period 对象内部的私有 Date 字段所引用的对象。通过改变这些 Date 实例,攻击者可以改变 Period 实例。如下的类演示了这种攻击方式:  

    1. public class MutablePeriod {
    2. // A period instance
    3. public final Period period;
    4. // period's start field, to which we shouldn't have access
    5. public final Date start;
    6. // period's end field, to which we shouldn't have access
    7. public final Date end;
    8. public MutablePeriod() {
    9. try {
    10. ByteArrayOutputStream bos =
    11. new ByteArrayOutputStream();
    12. ObjectOutputStream out =
    13. new ObjectOutputStream(bos);
    14. // Serialize a valid Period instance
    15. out.writeObject(new Period(new Date(), new Date()));
    16. /*
    17. * Append rogue "previous object refs" for internal
    18. * Date fields in Period. For details, see "Java
    19. * Object Serialization Specification," Section 6.4.
    20. byte[] ref = { 0x71, 0, 0x7e, 0, 5 };
    21. // Ref #5
    22. // The start field
    23. ref[4] = 4;
    24. // Ref # 4
    25. bos.write(ref);
    26. // The end field
    27. // Deserialize Period and "stolen" Date references
    28. ObjectInputStream in = new ObjectInputStream(
    29. new ByteArrayInputStream(bos.toByteArray()));
    30. period = (Period) in.readObject();
    31. start = (Date) in.readObject();
    32. end = (Date) in.readObject();
    33. }
    34. catch (IOException | ClassNotFoundException e) {
    35. throw new AssertionError(e);
    36. }
    37. }
    38. }

      要查看正在进行的攻击,请运行以下程序:

      在我本地机器上运行这个程序产生的输出结果如下:

    1. Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
    2. Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969

      问题的根源在于,PeriodreadObject 方法并没有完成足够的保护性拷贝。 当一个对象被反序列化的时候,对于客户端不应该拥有的对象引用,如果那个字段包含了这样的对象引用,就必须做保护性拷贝,这是非常重要的。 因此,对于每个可序列化的不可变类,如果它包含了私有的可变字段,那么在它的 readObject 方法中,必须要对这些字段进行保护性拷贝。下面的这些 readObject 方法可以确保 Period 类的约束条件不会遭到破坏,以保持它的不可变性:

      注意,保护性拷贝是在有效性检查之前进行的。我们没有使用 Dateclone 方法来执行保护性拷贝机制。这两个细节对于保护 Period 类免受攻击是必要的(详见 50 条)。同时也注意到,对于 final 字段,保护性字段是不可能的。为了使用 readObject 方法,我们必须要将 start 和 end 字段声明成为非 final 的。很遗憾的是,这还算是相对比较好的做法。有了这新的 readObject 方法,并且取消了 start 和 end 的 final 修饰符之后,MutablePeriod 类将不再有效。此时,上面的攻击程序会产生如下输出:

    1. Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017
    2. Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017

      有一个简单的「石蕊」测试,可以用来确定默认的 readObject 方法是否可以被接受。测试方法:增加一个公有的构造器,其参数对应于该对象中每个非 transient 的字段,并且无论参数的值是什么,都是不进行检查就可以保存到相应的字段中。对于这样的做法,你是否会感到很舒适?如果你对这个问题的回答是否定的,就必须提供一个显式的 readObject 方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝。另一种方法是,可以使用序列化代理模式(serialization proxy pattern),详见第 90 条。强烈建议使用这个模式,因为它分担了安全反序列化的部门工作。

      对于非 final 的可序列化的类,在 readObject 方法和构造器之间还有其他类似的地方。与构造器一样,readObject 方法不可以调用可被覆盖的方法,无论是直接调用还是间接调用都不可以(详见 19 条)。如果违反了这条规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。这个程序很可能会失败[Bloch05, Puzzle 91]。

    • 类中的对象引用字段必须保持为私有属性,要保护性的拷贝这些字段中的每个对象。不可变类中的可变组件就属于这一类别
    • 对于任何约束条件,如果检查失败就抛出一个 异常。这些检查动作应该跟在所有的保护性拷贝之后。
    • 无论是直接方法还是间接方法,都不要调用类中任何可被覆盖的方法。