1. バイナリシリアライゼーションのセキュリティ
Java におけるシリアライゼーションは、単にオブジェクトのフィールドを保存するだけではありません。インターフェース Serializable を実装していれば、どんな内容のオブジェクトでも「復元」できてしまいます。一見便利ですが、信頼できないソース(たとえばネットワークや、攻撃者に差し替えられた可能性のあるファイル)から受け取ったデータをデシリアライズすると、攻撃の被害に遭うリスクがあります。
どのように動作するのか?
デシリアライズ中、Java はクラスのコンストラクタを呼び出さずにバイトストリームからオブジェクトを生成します。クラスに readObject のような特別なメソッドが実装されていれば、自動的に呼び出されます。攻撃者は、デシリアライズ時に脆弱なコードが実行されるよう、細工したバイトストリームを「構築」できます。
例:「gadget chain」攻撃
たとえば、デシリアライズ時に外部コマンドを起動する(ファイルを読む、シェルを呼び出す)クラスがあるとします。アプリケーションが特定の型のオブジェクトをデシリアライズすることを攻撃者が知っていれば、悪意あるコードの実行につながる特別なバイトストリームを差し込めます。こうした攻撃は「ガジェットの連鎖(gadget chain)」として構築され、最終的に危険な操作に到達します。
なぜそれほど重大なのか?
デシリアライズは、データストリームから実体のあるオブジェクトを復元する過程であり、その際に任意のコードが実行され得ます。データが信頼できないソースから来る場合、リモートコード実行(RCE)への扉が開かれてしまいます。大手企業は安全でないデシリアライズを避ける勧告を公表しており、現代のエンタープライズ開発では、バイナリシリアライゼーションがセキュリティポリシーで禁止されていることも珍しくありません。
どう守るか?
- 信頼できないソースからのオブジェクトを決してデシリアライズしない。
- ホワイトリスト方式を使い、デシリアライズできる型を明示的に限定する。
- 外部連携にはテキスト形式を優先する:JSON、XML。
- シリアライゼーションが不可避なら、安全なデシリアライズ設定を備えたライブラリを使う(例:型を制限した Jackson)。
- 安全性に自信がない限り、readObject、readResolve などの独自シリアライゼーションメソッドの使用を控える。
2. クラスのバージョン互換性
Java のバイナリシリアライゼーションはクラス構造に強く結びついています。バージョン 1.0 でオブジェクトをシリアライズし、その後クラスを更新(フィールドの追加/削除)すると、「古い」オブジェクトを新しいバージョンへデシリアライズする際にエラーやデータ損失が起こり得ます。
Java は互換性をどのように判断するか?
そのために使われる特別なフィールドが serialVersionUID です。これはクラスのバージョン識別子です。シリアライズ済みオブジェクトの serialVersionUID と現在のクラスの値が異なる場合、InvalidClassException が送出され、デシリアライズは行われません。
import java.io.Serializable;
public class User implements Serializable {
private static final long serialVersionUID = 1L; // バージョンを明示的に指定
private String name;
private int age;
}
もしクラス構造を変更(たとえば email フィールドを追加)しても serialVersionUID を変更しなければ、Java はクラスが互換であると見なし、古いオブジェクトのデシリアライズを試みます。serialVersionUID を明示しなかった場合、JVM は構造に基づいて自動生成するため、あらゆる変更が非互換につながります。
バージョンが一致/不一致の場合に何が起こるか?
識別子が一致しない場合はデシリアライズされず、InvalidClassException になります。一致する場合は、フィールドは名前と型で対応付けられ、新しいフィールドはデフォルト値(null、0)を受け取り、削除されたフィールドは無視されます。型や名前を変更した場合、エラーやデータの不正な解釈が発生し得ます。
実践的なアドバイス。 シリアライズ可能なクラスでは常に明示的な serialVersionUID を指定してください。非互換な変更(重要なフィールドの削除/型変更)を行うときだけ値を更新します。新しいフィールドの追加では識別子はそのままで構いません—JVM が古いオブジェクトを正しく処理します。
表:クラスを変更した場合にどうなるか
| クラスの変更 | デシリアライズ時の結果 |
|---|---|
| 新しいフィールドを追加 | デフォルト値(0、null)になる |
| フィールドを削除 | 古いデータの読み込み時に無視される |
| フィールドの型を変更 | 例外が発生する、または不正なデータになる |
| フィールド名を変更 | 古いフィールドは無視され、新しいフィールドはデフォルト値 |
| serialVersionUID を変更 | InvalidClassException になる |
3. 標準シリアライゼーションの制約
すべてのオブジェクトがシリアライズできるわけではない
transient と static 修飾子が付いたフィールドはシリアライズされません。static はオブジェクトではなくクラスに属するため、transient は明示的にシリアライズを禁止しているためです。
定義上シリアライズできないオブジェクトもあります:Thread、DB 接続、ソケット、Scanner など。クラスにそのような型のフィールドがあり、それが transient でなければ、NotSerializableException になります。
import java.io.Serializable;
import java.util.Scanner;
public class Session implements Serializable {
private transient Scanner scanner; // シリアライズされない!
private String login;
}
パフォーマンスと拡張性の問題
巨大なオブジェクトグラフのシリアライゼーションは、遅く、メモリを多く消費する場合があります。
バイナリ形式は他プラットフォームや他言語との連携には向きません—理解できるのは基本的に Java だけです。
特に深い継承階層や循環参照がある場合、何がシリアライズされるのかを制御するのは難しくなります。
古いデータの維持の問題
バイナリのスナップショットを長期保存するのはリスクです。1~2 年もすればクラス構造が変わり、古いファイルが読み込めなくなります。
実際の話:「3 年前にユーザーキャッシュをシリアライズして保存し、アプリを更新したらもう読み込めなくなった。こんにちは、失われたデータ!」
4. ベストプラクティス:落とし穴にはまらないために
- バイナリシリアライゼーションは、プロセスの両側を自分たちで制御できる内部用途に限って使用する。
- 外部連携や重要データの長期保存には、バイナリシリアライゼーションを使用しない。
- シリアライズ可能なクラスでは常に serialVersionUID を明示する。
- シリアライズすべきでないフィールドには transient を付ける。
- 外部システムとのやり取りにはテキスト形式とモダンなライブラリを使う:JSON、XML、Jackson、Gson、JAXB。
- 互換性のためにバージョニングを採用する:オブジェクトのバージョンをクラス内に保持し、デシリアライズ時に処理を調整する。
- キャッシュ目的のシリアライゼーションであれば、互換性維持に固執しない:キャッシュは再計算した方が簡単。
- シリアライズ可能なオブジェクトに機密データ(パスワード、キー)を保存しない—シリアライゼーションはデータを暗号化しません。
5. バイナリシリアライゼーションでよくある誤り
誤り1:信頼できないソースのデータをデシリアライズする。 最も危険なのは、ネットワーク、ユーザー入力、差し替えられたファイルなど「外部」から来たオブジェクトを受け入れてデシリアライズすることです。RCE に至る脆弱性への直行ルートです。
誤り2:serialVersionUID を更新せずにクラス構造を暗黙に変更する。 識別子を明示しないと、JVM は自動生成します。構造の変更(フィールド順の変更すら)で非互換となり、古いオブジェクトを読み込めなくなります。
誤り3:シリアライズ不可のフィールドを持つオブジェクトをシリアライズしようとする。 クラスに Serializable を実装しない型のフィールドがあり、しかもそれが transient でない場合、シリアライゼーションは例外で終了します。
誤り4:シリアライズ可能なオブジェクトに一時的または機密性の高いデータを保存する。 トークン、パスワード、リソースの一時ディスクリプタなどが、意図せずファイルに含まれてしまうことがあります。
誤り5:長期保存やバージョン間のやり取りにバイナリシリアライゼーションを使う。 クラスを最初に更新した時点で、データ破損や互換性の問題が発生するリスクが高くなります。
誤り6:static や transient フィールドがデシリアライズで「復元」されると期待する。 これらのフィールドはシリアライズされません。読み込み後はデフォルト値になります。
GO TO FULL VERSION