Effective Java 3rd [Chapter 12] - シリアライズ
シリアライズ
Effective Java 第 3 版の個人的メモ
- 項目 85 Java のシリアライズよりも代替手段を選ぶ
- 項目 86 Serializable を細心の注意を払って実装する
- 項目 87 カスタムシリアライズ形式の使用を検討する
- 項目 88 防御的に readObject メソッドを書く
- 項目 89 インスタンス制御に対しては、readResolve より enum 型を選ぶ
- 項目 90 シリアライズされたインスタンスの代わりに、シリアライズ・プロキシを検討する
1. 項目 85 Java のシリアライズよりも代替手段を選ぶ
1.1. 結論
- シリアライズは使うべきでない
- シリアライズは脆弱でリモートコード実行(RCE)やDoS攻撃などの攻撃の弱点となってしまうから
- 代替手段としてはJSONやProtobufなどのような他の手段を使うべし
- データフォーマットが定まっていてシリアライズよりましだから?
- XML bombもある。Jsonハイジャックもある。
- ユーザコードを叩くかどうかの違い?
- データフォーマットが定まっていてシリアライズよりましだから?
- シリアライズが避けられない場合でも、信頼できないデータはデシリアライズするべきでない
- 信頼できないデータは攻撃対象となっている可能性があるから
- シリアライズが避けられないかつ信頼できないデータをデシリアライズしなければならない場合は、フィルタリングをするべし
1.2. シリアライズの脆弱性
- シリアライズは脆弱である
- ガジェット、ガジェットチェーンによって任意のネイティブコードが実行できてしまうことがある
- Javaのシリアライズ可能な型を持つメソッドをガジェット、ガジェットの組み合わせをガジェットチェーンとよぶ
- 実際にガジェットチェーンを用いて任意のネイティブコードが実行する実証実験された
- デシリアライゼーションボムによってDoS攻撃ができてしまうことがある
- デシリアライゼーションボムは、インスタンスのディシリアライズをする場合に、そのフィールドや要素のハッシュコードを計算する必要があることを悪用して、例えば深い入れ子構造になっているHashSetインスタンスなどを作成してデシリアライズさせることで、莫大な計算時間を掛けさせる攻撃手法
1.3. デシリアライズ時のフィルタリング
- デシリアライズ時のフィルタリングにはObjectInputFilter (Java SE 11 & JDK 11 )を使うべし
- ホワイトリスト
- Java 9から導入されたが、6,7,8にもバックポートされている
1.4. 余談
- シリアライズの扱いずらさ
- 送信元と送信先が同じオブジェクトを持たなければならない
- どういう局面でシリアライズを使うでしょうか
- セッション
- システム内通信
- EJB(Enterprise Java Bean)
- EJBとは?という方はこちら
- EJB(Enterprise Java Bean)
2. 項目 86 Serializable を細心の注意を払って実装する
2.1. 結論
- Serializableインターフェースは軽く考えて実装するべきではない
- 以下の3つのコストがかかってしまうから
- リリース後のクラスの実装の変更に対する柔軟性の低下
- バグやセキュリティホールの可能性の増大
- テストの負荷の増大
- 以下の3つのコストがかかってしまうから
- 継承するために設計されたクラスとインタフェースではSerializableを実装・拡張するべきでない
- サブクラスの実装に手間がかかるから
2.2. Serializable実装に伴う3つのコスト
2.2.1. リリース後のクラスの実装の変更に対する柔軟性の低下
- Serializableを実装したクラスを一旦リリースしてしまうと、その実装を変更することは容易ではない
- クラスをシリアライズ可能にすると、そのシリアライズ形式がクラスの公開APIの一部となり、シリアライズ形式を永久にサポートし続ける必要があるから
- デフォルトのシリアライズ形式の場合は、 privateフィールドも公開されてしまう
- serialVersionUIDを明示的に指定しない場合は、コンパイラはクラス名やクラスが実装しているインタフェース名、publicとprotectedのメンバからserialVersionUIDを生成されるするので、メソッドを一つ追加しただけでserialVersionUIDが変更され、互換性が失われるから。
- javaバージョンによって生成されるserialVersionUIDが違う可能性あり。だから指定しましょう
- クラスをシリアライズ可能にすると、そのシリアライズ形式がクラスの公開APIの一部となり、シリアライズ形式を永久にサポートし続ける必要があるから
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は内部表現をそのままバイナリにする(なにもしないと全部のプロパティが対象になる)。
|
|
|
|
@serial
タグ- このタグを使うと、javadocで明記される模様。
readObject
メソッド- シリアライズ機構を通るときは、実装すること(前述の例では実装していない)。
- デシリアライズ時は、オブジェクトはコンストラクタを使わずに生成されるので、状態に条件が必要ならば、readObjectで実現する必要がある。
3.3. カスタムシリアライズ形式 とは
implements Serializable
と、 readObject
メソッド と writeObject
メソッドを実装した状態。デフォルトで問題ないと思うかもしれないが、オブジェクトの構造次第では不適切になる。
|
|
このクラスは実装を晒している。
- 論理的には「文字列のリスト」
- 実装は「文字列の双方向リンクリスト」
デフォルトシリアライズを用いると「文字列の双方リンクリスト」としてシリアライズされる。 結果、複数のデメリットが生まれる。
- 物理表現(実装)が公開 API となり、永久にサポートする必要がある
- シリアライズが将来も有効とするには、内部構造を変えられない
- スタックオーバーフローを起こす可能性がある
- 再帰的な構造になっているので、そのままダンプするとエライことになる。リングバッファとかも不適切。
- 多くの空間を消費する可能性がある / 多くの時間を消費する可能性がある
- ↑を理解すると自明
以下は、デフォルトで問題ありそうな実装を試し打ち。
|
|
問題は発生しなかったが、無駄なデータになっていそう。
|
|
以下は改良版。
|
|
ここでは、いくつか新しい要素がある。
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 なプロパティ」を含んでいるので不完全。
- 手作りバイトストリームで攻撃して、ディシリアライズすれば別インスタンスの生成が可能。
|
|
上記のクラスでは不完全なので、以下のように対応しましょう。 JVMによって、宣言されたもの以外のインスタンスはあり得ないということが保証される。
Enum
型の場合、Javaの仕様でフィールド値はシリアライズの対象外となる。- 参考:Javaオブジェクト直列化仕様: 1 - システム・アーキテクチャ
- 1.12 Enum定数の直列化
enum定数の直列化された形式を構成するのは、その名前のみです。定数のフィールド値は形式内に存在しません。
- 1.12 Enum定数の直列化
- 参考:Javaオブジェクト直列化仕様: 1 - システム・アーキテクチャ
|
|
5.3. 余談
- 以下の悪意あるバイトストリームの攻撃を、
openjdk version "13.0.2" 2020-01-14
で実行してみた。 もし以下のエラーが出ている方は、デフォルトパッケージにクラスを格納すると、ちゃんと?バイトストリーム攻撃が成功すると思います。- 最近のJavaでは、このような攻撃に対応していて、エラーになるようになっているの?
|
|
|
|
5.4. ポイント
- インスタンス制御(シングルトン等)を強制したい場合は、
Serializable
などは使わず、単にenum
型を選びましょう。 - 実行時にしかどのようなインスタンスなのかわからない場合は、項目88を参考に。
readResolve
メソッドの提供- 各フィールドは
transient
(シリアライズ対象外に。)で。
6. 項目 90 シリアライズされたインスタンスの代わりに、シリアライズ・プロキシを検討する
6.1. 結論
Serializable
を使わなければいけない時は、シリアライズ・プロキシパターンを使いましょう。Serializable
を実装すると、バグとセキュリティの問題の可能性が増大するが、シリアライズ・プロキシパターンを使うと、リスクが大幅に減るため。
6.2. シリアライズ・プロキシパターンのサンプル
このパターンを使用すると、防御的コピーによる方法(項目50)と同様に、以下を防げる。
- 偽りのバイトストリームによる攻撃
- 内部フィールドの窃盗攻撃
このパターンを利用すれば、どのフィールドが不正なシリアライズ攻撃で信用できなくなるとかを考える必要がなくなる。
|
|
|
|
補足:
Period
→SerializationProxy
→バイトデータ
→SerializationProxy
→Period
となる。Period
の前にSerializationProxy
を介し、new Period()
が実行されるため、攻撃を防ぐ余地が生まれる。
6.3. ポイント
Serializable
の実装は、様々な問題があり、極力しようは避けるべきであるが、使う際はシリアライズ・プロキシパターンの利用を検討しましょう。
serialVersionUID
の管理- 偽りのバイトストリームによる攻撃
- 内部フィールド窃盗攻撃