シリアライズ

Effective Java 第 3 版の個人的メモ

  1. 項目 85 Java のシリアライズよりも代替手段を選ぶ
  2. 項目 86 Serializable を細心の注意を払って実装する
  3. 項目 87 カスタムシリアライズ形式の使用を検討する
  4. 項目 88 防御的に readObject メソッドを書く
  5. 項目 89 インスタンス制御に対しては、readResolve より enum 型を選ぶ
  6. 項目 90 シリアライズされたインスタンスの代わりに、シリアライズ・プロキシを検討する

1. 項目 85 Java のシリアライズよりも代替手段を選ぶ

1.1. 結論

  • シリアライズは使うべきでない
    • シリアライズは脆弱でリモートコード実行(RCE)やDoS攻撃などの攻撃の弱点となってしまうから
  • 代替手段としてはJSONやProtobufなどのような他の手段を使うべし
    • データフォーマットが定まっていてシリアライズよりましだから?
      • XML bombもある。Jsonハイジャックもある。
      • ユーザコードを叩くかどうかの違い?
  • シリアライズが避けられない場合でも、信頼できないデータはデシリアライズするべきでない
    • 信頼できないデータは攻撃対象となっている可能性があるから
  • シリアライズが避けられないかつ信頼できないデータをデシリアライズしなければならない場合は、フィルタリングをするべし

1.2. シリアライズの脆弱性

1.3. デシリアライズ時のフィルタリング

1.4. 余談

  • シリアライズの扱いずらさ
    • 送信元と送信先が同じオブジェクトを持たなければならない
  • どういう局面でシリアライズを使うでしょうか
    • セッション
    • システム内通信
      • EJB(Enterprise Java Bean)

2. 項目 86 Serializable を細心の注意を払って実装する

2.1. 結論

  • Serializableインターフェースは軽く考えて実装するべきではない
    • 以下の3つのコストがかかってしまうから
      • リリース後のクラスの実装の変更に対する柔軟性の低下
      • バグやセキュリティホールの可能性の増大
      • テストの負荷の増大
  • 継承するために設計されたクラスとインタフェースではSerializableを実装・拡張するべきでない
    • サブクラスの実装に手間がかかるから

2.2. Serializable実装に伴う3つのコスト

2.2.1. リリース後のクラスの実装の変更に対する柔軟性の低下

  • Serializableを実装したクラスを一旦リリースしてしまうと、その実装を変更することは容易ではない
    • クラスをシリアライズ可能にすると、そのシリアライズ形式がクラスの公開APIの一部となり、シリアライズ形式を永久にサポートし続ける必要があるから
      • デフォルトのシリアライズ形式の場合は、 privateフィールドも公開されてしまう
    • serialVersionUIDを明示的に指定しない場合は、コンパイラはクラス名やクラスが実装しているインタフェース名、publicとprotectedのメンバからserialVersionUIDを生成されるするので、メソッドを一つ追加しただけでserialVersionUIDが変更され、互換性が失われるから。
      • javaバージョンによって生成されるserialVersionUIDが違う可能性あり。だから指定しましょう

2.2.2. バグやセキュリティホールの可能性の増大

  • バグの可能性が増大する理由は、デシリアライズ時のオブジェクトは通常のコンストラクタを使わずに生成されるため、コンストラクタで保証される不変式が保証されないから。
  • セキュリティホールについては、項目85参照

2.2.3. テストの負荷の増大

  • 新たなリリースのインスタンスをシリアライズし、古いリリースのデシリアライズ処理でインスタンスが復元できるを確認する必要がある。 また逆に、古いリリースのインスタンスをシリアライズし、新しいリリースのデシリアライズ処理で復元できるかも確認する必要がある
  • これらのテストは、新旧のインスタンス間でシリアライズ/デシリアライズできるかというバイナリ互換性に加えて、 動作が意図しているものかどうかというセマンティクス互換性も検査する必要がある
    • 互換の一覧
      • ソース互換
        • ソースをコンパイルできる
      • バイナリ互換
        • バイナリを実行できる
      • 動作の互換性(≒セマンティクス互換性)
        • 動作が同じ

2.2.4. 継承するために設計されたクラス・インターフェースに対するSerializable実装

  • 継承するために設計されたクラスとインタフェースではSerializableを実装・拡張するべきでない
    • サブクラスの実装に手間がかかるから
  • ただし上記には例外があり、「例えば、全ての参加者がSerializableを実装しなければならない何らかのフレームワークに参加するためにクラスやインターフェースが主に存在している場合」は破ってもいい
    • 例えば、ThrowableはSerializableを実装しているので、リモートメソッド呼び出し(RMI)からの例外を、クライアントに渡せる
    • Component、HttpServletもSerializableを実装している

3. 項目 87 カスタムシリアライズ形式の使用を検討する

3.1. 結論

Javaにおけるシリアライズの実装方法は2通りある。

  • デフォルトシリアライズ形式
  • カスタムシリアライズ形式

基本的に、カスタムを選ぶこと(=後述する、#readObject, #writeObject を実装すること)。

3.2. デフォルトシリアライズ形式とは

単に Implements Serializable しただけの状態。これで、Javaは内部表現をそのままバイナリにする(なにもしないと全部のプロパティが対象になる)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

import lombok.ToString;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@ToString
public class Person implements Serializable {
    /**
     * 名前。非 null。
     * @serial
     */
    private String name;
    /**
     * 年齢。0以上。
     * @serial
     */
    private int age;

    public Person(String name, int age) {

        log.info("constructor is called");

        if (name == null) {
            throw new IllegalArgumentException("name = null");
        }
        if (age < 0) {
            throw new IllegalArgumentException("age: " + age + " < 0");
        }

        this.name = name;
        this.age = age;
    }

    public static void main(String[] args) throws Exception {

        log.info("start serialize !!!");

        var bas = new ByteArrayOutputStream();
        var oos = new ObjectOutputStream(bas);

        var targetParson = new Person("kuramotoki", 36);

        oos.writeObject(targetParson);
        oos.flush();
        oos.close();

        log.info("serialized value=[{}]", bas.toString());

        log.info("start deserialize !!!");

        var ois = new ObjectInputStream(new ByteArrayInputStream(bas.toByteArray()));
        Person serializedPerson = (Person)ois.readObject();
        ois.close();

        log.info("serialized person=[{}]", serializedPerson);
    }
}
1
2
3
4
5
6
7
8
// 得られる出力
18:25:09.951 [main] INFO Person - start serialize !!!
18:25:09.964 [main] INFO Person - constructor is called
18:25:10.051 [main] INFO Person - serialized value=[�� sr Person&����� I ageL namet Ljava/lang/String;xp   $t 
kuramotoki] // バイナリには、クラスの情報とその値がベタで入っている
18:25:10.053 [main] INFO Person - start deserialize !!!
// ここでコンストラクタが呼ばれそうだけど呼ばれない
18:25:10.059 [main] INFO Person - serialized person=[Person(name=kuramotoki, age=36)] // ちゃんと復元できている
  • @serial タグ
    • このタグを使うと、javadocで明記される模様。
  • readObject メソッド
    • シリアライズ機構を通るときは、実装すること(前述の例では実装していない)。
    • デシリアライズ時は、オブジェクトはコンストラクタを使わずに生成されるので、状態に条件が必要ならば、readObjectで実現する必要がある。

3.3. カスタムシリアライズ形式 とは

implements Serializable と、 readObject メソッド と writeObject メソッドを実装した状態。デフォルトで問題ないと思うかもしれないが、オブジェクトの構造次第では不適切になる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public final class StringList implements Serializable {
    private int size = 0;
    private Entry head = null;

    private static class Entry {
        String data;
        Entry previous;
        Entry next;
    }
}

このクラスは実装を晒している。

  • 論理的には「文字列のリスト」
  • 実装は「文字列の双方向リンクリスト」

デフォルトシリアライズを用いると「文字列の双方リンクリスト」としてシリアライズされる。 結果、複数のデメリットが生まれる。

  • 物理表現(実装)が公開 API となり、永久にサポートする必要がある
    • シリアライズが将来も有効とするには、内部構造を変えられない
  • スタックオーバーフローを起こす可能性がある
    • 再帰的な構造になっているので、そのままダンプするとエライことになる。リングバッファとかも不適切。
  • 多くの空間を消費する可能性がある / 多くの時間を消費する可能性がある
    • ↑を理解すると自明

以下は、デフォルトで問題ありそうな実装を試し打ち。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public final class StringList implements Serializable {

    private int size = 0;
    private Entry head = null;

    public StringList(Entry head) {
        this.head = head;
    }

    private static class Entry implements Serializable {
        String data;
        Entry previous;
        Entry next;
    }

    public static void main(String[] args) throws Exception {

        var head = new Entry();
        var mid1 = new Entry();
        var mid2 = new Entry();
        var tail = new Entry();

        head.previous = null;
        head.next = mid1;
        head.data = "head";

        mid1.previous = head;
        mid1.data = "mid1";
        mid1.next = mid2;

        mid2.previous = mid1;
        mid2.data = "mid2";
        mid2.next = tail;

        tail.previous = mid2;
        tail.data = "tail";
        tail.next = head;

        log.info("start serialize !!!");

        var bas = new ByteArrayOutputStream();
        var oos = new ObjectOutputStream(bas);
        oos.writeObject(new StringList(head));
        oos.flush();
        oos.close();

        log.info(bas.toString());

    }
}

問題は発生しなかったが、無駄なデータになっていそう。

1
2
3
18:57:52.870 [main] INFO StringList - start serialize !!!
18:57:52.941 [main] INFO StringList - �� sr 
.h I sizeL headt LStringList$Entry;xp    sr StringList$Entry����I L datat Ljava/lang/String;L nextq ~ Lpreviousq ~ xpt headsq ~ t mid1sq ~ t mid2sq ~ t tailq ~ q ~     q ~ q ~ p

以下は改良版。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public final class StringList2 implements Serializable {
    private transient int size = 0;
    private transient Entry head = null;

    // No longer Serializable
    private static class Entry {
        String data;
        Entry previous;
        Entry next;
    }

    // listに文字列を加える
    public final void add(String s) {

    }

    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
        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());
        }
    }
}

ここでは、いくつか新しい要素がある。

  • transient
    • これが指定されていると シリアライズ対象外 となる。
    • 初期値はその型のデフォルトになる。それだとまずければ readObject で設定する。
  • defaultWriteObject/defaultReadObject
    • とりあえず叩く。transient 指定されたフィールド以外のフィールドはすべてシリアライズされる。
    • 今の時点で transient なフィールドしかなかったとしても、将来はわからないので、いいから叩く。 将来、transient 付きのフィールドが入ったときのため。

4. 項目 88 防御的に readObject メソッドを書く

4.1. 結論

何が入ってきても大丈夫なように、readObjectは防御的プログラミングをすべし。

  • 以前の、防御的コピーのテクニックなどが使える。
  • readObjectは、第二のコンストラクタと言える。
  • 引数のバイトストリームは悪意あるものかもしれないし、実際にシリアライズされたインスタンスである保証もない。

4.2. なぜ防御的な必要があるか

項目87でみたように、デシリアライズはバイト配列をもとにオブジェクトを再現する。 ということは、「不正なバイナリを食わせられる」とか「改ざんされる」といったことが起き得る。

なので、以下に従うこと。

  • オブジェクト参照を持つフィールドはprivateにし、防御的コピーを取る。
    • immutableなクラスのmutableなコンポーネントは必須
  • 不変条件をチェックし、破られた場合はInvalidObjectExceptionをなげる。
  • デシリアライズされた後に、オブジェクトグラフ全体にバリデーションをかける必要があるならば、ObjectInputValidationインターフェースを使う。
  • オーバーライドされうるメソッドは直接的にも間接的にも呼ばない。
    • 悪意あるコードが呼ばれる可能性を防ぐ。

5. 項目 89 インスタンス制御に対しては、readResolve より enum 型を選ぶ

5.1. 結論

  • インスタンス制御(シングルトン等)を強制したい場合は、readResolveよりもenum型を選びましょう。
    • readResolveが使えるということは、別のインスタンスを生成可能ということだから。
  • インスタンス制御されたクラス(実行時にXMLファイルから読み込みインスタンスを生成する場合など)を使用したくて、シリアライズもしたい場合は、readResolveメソッドを提供して、全てのフィールドにtransientを付けましょう。
    • 悪意のある攻撃者に付け入る隙を与えないようにするため。(項目87 参照。)

5.2. サンプル

Serializableを継承したEnum型で、readResolveでも対応しているサンプル。

  • 「非 transient なプロパティ」を含んでいるので不完全。
    • 手作りバイトストリームで攻撃して、ディシリアライズすれば別インスタンスの生成が可能。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 非 transient プロパティを含む不完全なシングルトン実装
public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();

    private Elvis() {
    }
 
    // 非 transient なプロパティ
    private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"};

    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }

    // 偽のインスタンスを作らせないために、INSTANCEを返却
    private Object readResolve() {
        return INSTANCE;
    }
}

上記のクラスでは不完全なので、以下のように対応しましょう。 JVMによって、宣言されたもの以外のインスタンスはあり得ないということが保証される。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// enumシングルトン - 好ましい方法
public enum Elvis {
    INSTANCE;
    private String[] favoriteSongs =
            {"Hound Dog", "Heartbreak Hotel"};

    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

// Enum型でもシリアライズ/デシリアライズは可能です。
public class Main {
	public static void main(String[] args) {

		byte[] data = null;

		try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
				ObjectOutputStream oos = new ObjectOutputStream(baos);) {
			oos.writeObject(Elvis.INSTANCE);

			data = baos.toByteArray();
			log.info("byte data = {}", data);
		} catch (IOException e) {
			e.printStackTrace();
		}
		try (
				ByteArrayInputStream bais = new ByteArrayInputStream(data);
				ObjectInputStream ois = new ObjectInputStream(bais)) {

			Elvis elvis = (Elvis) ois.readObject();

			log.info("elvis data = {}", elvis.returnFavorites());

		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}
}

5.3. 余談

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	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) {
		// Initializes ElvisStealer.impersonator and returns
		// the real Elvis (which is Elvis.INSTANCE)
		Elvis elvis = (Elvis) deserialize(serializedForm);
		Elvis impersonator = ElvisStealer.impersonator;

		elvis.printFavorites();
		impersonator.printFavorites();
	}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
### デフォルトパッケージ「jp.tokyo.takuto_no.effective_java.chapter11.bad」ではない場合
Exception in thread "main" java.lang.IllegalArgumentException: java.lang.ClassNotFoundException: Elvis
	at jp.tokyo.takuto_no.effective_java.chapter11.bad.ElvisImpersonator.deserialize(ElvisImpersonator.java:40)
	at jp.tokyo.takuto_no.effective_java.chapter11.bad.ElvisImpersonator.main(ElvisImpersonator.java:26)
Caused by: java.lang.ClassNotFoundException: Elvis
	at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:349)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	at java.lang.Class.forName0(Native Method)
	at java.lang.Class.forName(Class.java:348)
	at java.io.ObjectInputStream.resolveClass(ObjectInputStream.java:686)
	at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1868)
	at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1751)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2042)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1573)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:431)
	at jp.tokyo.takuto_no.effective_java.chapter11.bad.ElvisImpersonator.deserialize(ElvisImpersonator.java:38)
	... 1 more
### デフォルトパッケージの場合
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]

5.4. ポイント

  • インスタンス制御(シングルトン等)を強制したい場合は、Serializableなどは使わず、単にenum型を選びましょう。
  • 実行時にしかどのようなインスタンスなのかわからない場合は、項目88を参考に。
    • readResolveメソッドの提供
    • 各フィールドはtransient(シリアライズ対象外に。)で。

6. 項目 90 シリアライズされたインスタンスの代わりに、シリアライズ・プロキシを検討する

6.1. 結論

  • Serializableを使わなければいけない時は、シリアライズ・プロキシパターンを使いましょう。
    • Serializableを実装すると、バグとセキュリティの問題の可能性が増大するが、シリアライズ・プロキシパターンを使うと、リスクが大幅に減るため。

6.2. シリアライズ・プロキシパターンのサンプル

このパターンを使用すると、防御的コピーによる方法(項目50)と同様に、以下を防げる。

  • 偽りのバイトストリームによる攻撃
  • 内部フィールドの窃盗攻撃

このパターンを利用すれば、どのフィールドが不正なシリアライズ攻撃で信用できなくなるとかを考える必要がなくなる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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;
	}

	// Periodに対するシリアライズ・プロキシ
	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;

		// シリアライズ・プロキシパターンのためのreadResolve
		private Object readResolve() {
			System.out.println("readResolve");
			return new Period(start, end); // public のコンストラクタを使用
		}
	}

	// シリアライズ・プロキシパターンのためのwriteReplace
	private Object writeReplace() {
		System.out.println("writeReplace");
		return new SerializationProxy(this);
	}

	// シリアライズ・プロキシパターンのためのreadObject
	private void readObject(ObjectInputStream stream)
			throws InvalidObjectException {
		throw new InvalidObjectException("Proxy required");
	}
}
1
2
3
4
5
6
Period(Date start, Date end)
writeReplace
16:34:30.242 [main] INFO jp.tokyo.takuto_no.effective_java.chapter11.serialize_proxy.Main - byte data = [(省略)]
readResolve
Period(Date start, Date end)  # ← [ポイント:ディシリアライズ時にコンストラクタが呼ばれる。]
16:34:30.368 [main] INFO jp.tokyo.takuto_no.effective_java.chapter11.serialize_proxy.Main - period data = Sat Mar 07 16:34:30 JST 2020 - Sat Mar 07 16:34:30 JST 2020

補足:PeriodSerializationProxyバイトデータSerializationProxyPeriodとなる。Periodの前にSerializationProxyを介し、new Period()が実行されるため、攻撃を防ぐ余地が生まれる。

6.3. ポイント

Serializableの実装は、様々な問題があり、極力しようは避けるべきであるが、使う際はシリアライズ・プロキシパターンの利用を検討しましょう。

  • serialVersionUIDの管理
  • 偽りのバイトストリームによる攻撃
  • 内部フィールド窃盗攻撃