Effective Java 第三版—— 88. 防禦性地編寫READOBJECT方法
Tips
書中的原始碼地址: https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些程式碼裡方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。

Effective Java, Third Edition
88. 防禦性地編寫READOBJECT方法
條目 50 裡有一個不可變的日期範圍類,它包含一個可變的私有Date屬性。 該類通過在其構造方法和訪問器中防禦性地拷貝Date物件,竭盡全力維持其不變性(invariants and immutability)。 程式碼如下所示:
// Immutable class that uses defensive copying public final class Period { private final Date start; private final Date end; /** * @paramstart the beginning of the period * @paramend 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 新增到類宣告中就可以了。但是,如果這樣做,該類不再保證它的關鍵不變性了。
問題是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
類為可序列化,我們就可以建立一個違反其類不變性的物件。
要解決此問題,請為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例項。 以下類演示了這種攻擊:
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); } } }
要檢視正在進行的攻擊,請執行以下程式:
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方法並從 start
和 end
屬性中刪除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介面(在本書中沒有討論)。
-
不要直接或間接呼叫類中任何可重寫的方法。