Effective Java 第三版——88. 防护性地编写READOBJECT方法

Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,因此JDK 最好下载 JDK 9以上的版本。java

Effective Java, Third Edition

88. 防护性地编写READOBJECT方法

条目 50 里有一个不可变的日期范围类,它包含一个可变的私有Date属性。 该类经过在其构造方法和访问器中防护性地拷贝Date对象,不遗余力维持其不变性(invariants and immutability)。 代码以下所示:git

// Immutable class that uses defensive copying
public final class Period {

    private final Date start;
    private final Date end;

    /**
     * @param  start the beginning of the period
     * @param  end the end of the period; must not precede start
     * @throws IllegalArgumentException if start is after end
     * @throws NullPointerException if start or end is null
     */
    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; }

    ... // Remainder omitted
}

假设要把这个类可序列化。因为Period对象的物理表示精确地反映了它的逻辑数据内容,因此使用默认的序列化形式是合理的(条目 87)。所以,要使类可序列化,彷佛只需将implements Serializable 添加到类声明中就能够了。可是,若是这样作,该类再也不保证它的关键不变性了。github

问题是readObject方法其实是另外一个公共构造方法,它须要与任何其余构造方法同样的当心警戒。 正如构造方法必须检查其参数的有效性(条目 49)并在适当的地方对参数防护性拷贝(条目 50),readObject方法也要这样作。 若是readObject方法没法执行这两个操做中的任何一个,则攻击者违反类的不变性是相对简单的事情。数组

简而言之,readObject是一个构造方法,它将字节流做为惟一参数。 在正常使用中,字节流是经过序列化正常构造的实例生成的。当readObject展示一个字节流时,问题就出现了,这个字节流是人为构造的,用来生成一个违反类不变性的对象。 这样的字节流可用于建立一个不可能的对象,该对象没法使用普通构造方法建立。安全

假设咱们只是将implements Serializablet添加到Period类声明中。 而后,这个丑陋的程序生成一个Period实例,其结束时间在其开始时间以前。 对byte类型的值进行强制转换,其高阶位被设置,这是因为Java缺少byte字面量,而且错误地决定对byte类型进行签名:测试

public class BogusPeriod {
  // Byte stream couldn't have come from a real Period instance!
  private static final byte[] serializedForm = {
    (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) {
    Period p = (Period) deserialize(serializedForm);
    System.out.println(p);
  }

  // Returns the object with the specified serialized form
  static Object deserialize(byte[] sf) {
    try {
      return new ObjectInputStream(
          new ByteArrayInputStream(sf)).readObject();
    } catch (IOException | ClassNotFoundException e) {
      throw new IllegalArgumentException(e);
    }
  }
}

用于初始化serializedForm的字节数组字面量(literal)是经过序列化正常的Period实例,并手动编辑生成的字节流生成的。 流的细节对于该示例并不重要,可是若是好奇,则在《Java Object Serialization Specification》[序列化,6]中描述了序列化字节流格式。 若是运行此程序,它会打印Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984。只需声明Period类为可序列化,咱们就能够建立一个违反其类不变性的对象。this

要解决此问题,请为Period提供一个readObject方法,该方法调用defaultReadObject,而后检查反序列化对象的有效性。若是有效性检查失败,readObject方法抛出InvalidObjectException异常,阻止反序列化完成:代理

// readObject method with validity checking - insufficient!
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // Check that our invariants are satisfied
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start +" after "+ end);
}

虽然这样能够防止攻击者建立无效的Period实例,但仍然存在潜在的更微妙的问题。 能够经过构造以有效Period实例开头的字节流来建立可变Period实例,而后将额外引用附加到Period实例内部的私有Date属性。 攻击者从ObjectInputStream中读取Period实例,而后读取附加到流的“恶意对象引用”。 这些引用使攻击者能够访问Period对象中私有Date属性引用的对象。 经过改变这些Date实例,攻击者能够改变Period实例。 如下类演示了这种攻击:code

public class MutablePeriod {
    // A period instance
    public final Period period;

    // period's start field, to which we shouldn't have access
    public final Date start;

    // period's end field, to which we shouldn't have access
    public final Date end;

    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos =
                new ByteArrayOutputStream();
            ObjectOutputStream out =
                new ObjectOutputStream(bos);
            // Serialize a valid Period instance
            out.writeObject(new Period(new Date(), new Date()));

            /*
             * Append rogue "previous object refs" for internal
             * Date fields in Period. For details, see "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

            // Deserialize Period and "stolen" Date references
            ObjectInputStream in = new ObjectInputStream(
                new ByteArrayInputStream(bos.toByteArray()));
            period = (Period) in.readObject();
            start  = (Date)   in.readObject();
            end    = (Date)   in.readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new AssertionError(e);
        }
    }
}

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

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);

    // Bring back the 60s!
    pEnd.setYear(69);
    System.out.println(p);
}

在个人语言环境中,运行此程序会产生如下输出:

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

虽然建立了Period实例且保持了其不变性,但能够随意修改其内部组件。 一旦拥有可变的Period实例,攻击者可能会经过将实例传递给依赖于Period的安全性不变性的类来形成巨大的伤害。 这并不是如此牵强:有些类就是依赖于String的不变性来保证安全性的。

问题的根源是Period类的readObject方法没有作足够的防护性拷贝。 对象反序列化时,防护性地拷贝包含客户端不能拥有的对象引用的属性,是相当重要的。 所以,每一个包含私有可变组件的可序列化不可变类,必须在其readObject方法中防护性地拷贝这些组件。 如下readObject方法足以确保Period的不变性并保持其不变性:

// readObject method with defensive copying and validity checking
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // Defensively copy our mutable components
    start = new Date(start.getTime());
    end   = new Date(end.getTime());

    // Check that our invariants are satisfied
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start +" after "+ end);
}

请注意,防护性拷贝在有效性检查以前执行,而且咱们没有使用Date的clone方法来执行防护性拷贝。 须要这两个细节来保护Period免受攻击(条目 50)。 另请注意,final属性没法进行防护性拷贝。 要使用readObject方法,咱们必须使start和end属性不能是final类型的。 这是不幸的,但它是这两个中较好的一个作法。 使用新的readObject方法并从startend属性中删除final修饰符后,MutablePeriod类再也不无效。 上面的攻击程序如今生成以下输出:

Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017
Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017

下面是一个简单的石蕊测试(litmus test),用于肯定类的默认readObject方法是否可接受:你是否愿意添加一个公共构造方法,该构造方法把对象中每一个非瞬时状态的属性值做为参数,并在没有任何验证的状况下,将值保存在属性中?若是没有,则必须提供readObject方法,而且它必须执行构造方法所需的全部有效性检查和防护性拷贝。或者,可使用序列化代理模式(serialization proxy pattern))(条目 90)。强烈推荐使用这种模式,由于它在安全反序列化方面花费了大量精力。

readObject方法和构造方法还有一个类似之处,它们适用于非final可序列化类。 与构造方法同样,readObject方法不能直接或间接调用可重写的方法(条目 19)。 若是违反此规则而且重写了相关方法,则重写方法会在子类状态被反序列化以前运行。 程序可能会致使失败[Bloch05,Puzzle 91]。

总而言之,不管什么时候编写readObject方法,都要采用这样一种思惟方式,即正在编写一个公共构造方法,该构造方法必须生成一个有效的实例,而无论给定的是什么字节流。不要假设字节流必定表示实际的序列化实例。虽然本条目中的示例涉及使用默认序列化形式的类,可是所引起的全部问题都一样适用于具备自定义序列化形式的类。下面是编写readObject方法的指导原则:

  • 对于具备必须保持私有的对象引用属性的类,防护性地拷贝该属性中的每一个对象。不可变类的可变组件属于这一类别。

  • 检查任何不变性,若是检查失败,则抛出InvalidObjectException异常。 检查应再任何防护性拷贝以后。

  • 若是必须在反序列化后验证整个对象图(object graph),那么使用ObjectInputValidation接口(在本书中没有讨论)。

  • 不要直接或间接调用类中任何可重写的方法。

相关文章
相关标签/搜索