シリアライズは、たとえば、クラスの不変条件を逸脱する目的などに悪用できる。また、オブジェクトの復元はオブジェクトの構築と同等の操作である。そのため、オブジェクトの構築において強制されるべき不変条件はオブジェクトの復元においても強制されなければならない。しかし、デフォルトのシリアライズ形式はクラスの不変条件を強制する機能を持っていない。そのため、不変条件を持つクラスには、デフォルトのシリアライズ形式を使ってはならない。
オブジェクトの復元過程では、コンストラクタを呼び出さずにクラスのインスタンスが構築される。そのため、コンストラクタによる入力検査は迂回されてしまう。さらに、transient 宣言や static 宣言されたフィールドは、シリアライズの対象とされないため、元の値は復元されない。以上のことから、transient 宣言や static 宣言されたフィールドを持っているクラス、コンストラクタで値の検証を行っているクラスは、同等の検証処理を、オブジェクトを復元するときに行わなければならない。
復元したオブジェクトに対して検証を行うことによって、オブジェクトの状態があらかじめ定められた制限の範囲に収まっていること、および、transient 宣言や static 宣言されたフィールドがデフォルトのセキュアな値を持っていることを確認できる。しかし、final 宣言され定数値を持っているフィールドは、復元された後、デフォルトの値ではなく適切な値が設定される。たとえば、private transient final n = 42 において、復元された後の n の値は 0 ではなく 42 である。このようなケース以外ではすべて、オブジェクトの復元後にはデフォルトの値が設定される。
違反コード (シングルトン)
以下の違反コード例のシングルトンクラスは、デフォルトのシリアライズ形式を使っており、実装上必要となる不変条件を強制できていない[Bloch 2005]。そのため、悪意を持ったコードは、存在すべきでない2つめのインスタンスを生成することができる。ここでは話を単純にするため、クラスにはセンシティブなデータは含まれていないと想定する。
public class NumberData extends Number { // ...Number.doubleValue() のような Number 型に対するメソッドを実装する... private static final NumberData INSTANCE = new NumberData (); public static NumberData getInstance() { return INSTANCE; } private NumberData() { // セキュリティチェックおよび入力値検査を行う } protected int printData() { int data = 1000; // data の値を表示 return data; } } class Malicious { public static void main(String[] args) { NumberData sc = (NumberData) deepCopy(NumberData.getInstance()); // 新たなインスタンスなので false と表示される System.out.println(sc == NumberData.getInstance()); System.out.println("Balance = " + sc.printData()); } // 実際のコードではこのようなメソッドを使うべきではない public static Object deepCopy(Object obj) { try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); new ObjectOutputStream(bos).writeObject(obj); ByteArrayInputStream bin = new ByteArrayInputStream(bos.toByteArray()); return new ObjectInputStream(bin).readObject(); } catch (Exception e) { throw new IllegalArgumentException(e); } } }
適合コード
以下の適合コードでは、enum クラスを使い、自前の readResolve() メソッドを追加して、復元されたインスタンスを、現在実行中の環境に存在するシングルトンオブジェクトへの参照に置き換えている。もっと複雑なケースでは、readResolve() メソッドだけではなく(あるいはその代わりに)、自前の writeObject() や readObject() メソッドも用意する必要があるかもしれない。
public enum NumberEnum { INSTANCE; NumberData number = new NumberData(); // ... protected final Object readResolve() throws NotSerializableException { return INSTANCE; } } public class NumberData extends Number { // ... }
この適合コードでは Number クラスを拡張するのではなく、コンポジションを使っている。シングルトンクラスについて詳しくは「MSC07-J. シングルトンオブジェクトのインスタンスを複数作らない」を参照。
違反コード
以下の違反コード例は自前の readObject() メソッドを使っているが、復元後の入力検査をしていない。システムのデザインでは、チケット番号の最大値を20,000としている。しかし、攻撃者はシリアライズされたデータを操作し、復元時には異なる値を生成させることができる。
public class Lottery implements Serializable { private int ticket = 1; private SecureRandom draw = new SecureRandom(); public Lottery(int ticket) { this.ticket = (int) (Math.abs(ticket % 20000) + 1); } public int getTicket() { return this.ticket; } public int roll() { this.ticket = (int) ((Math.abs(draw.nextInt()) % 20000) + 1); return this.ticket; } public static void main(String[] args) { Lottery l = new Lottery(2); for (int i = 0; i < 10; i++) { l.roll(); System.out.println(l.getTicket()); } } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); } }
適合コード
コンストラクタに実装されている入力値検査は、オブジェクトが復元されるすべての個所にも実装しなくてはならない。以下の適合コードでは、readFields() メソッドと ObjectInputStream.GetField コンストラクタを使ってすべてのフィールドを読み込み、フィールドの値をひとつずつ検証している。構築しているオブジェクトに代入する前に、すべてのフィールドの値を検証しておかなければならない。より複雑な不変条件の検証では、複数のフィールド値を読み込んでそれらの値の組み合わせが一定の条件を満たしているかどうかを検証しなくてはならない場合もあるだろう。
public final class Lottery implements Serializable { // ... private synchronized void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { ObjectInputStream.GetField fields = s.readFields(); int ticket = fields.get("ticket", 0); if (ticket > 20000 || ticket <= 0) { throw new InvalidObjectException("Not in range!"); } // draw の値の検証 this.ticket = ticket; } }
クラスは final 宣言しなければならないことに注意。サブクラス化による finalizer 攻撃の危険がある。finalizer 攻撃については「OBJ11-J. コンストラクタが例外をスローする場合には細心の注意を払う」を参照。クラスを拡張可能としたい場合には、インスタンスが安全に使えるかどうかを示すフラグを用意するやり方がある。フラグの値は、オブジェクトの検証が正しく終わったときにのみセットし、各メソッドは、処理を行う前に必ずフラグの値を確認するのである。
さらに、transient 宣言や static 宣言されたフィールドの値は、readObject() メソッドで明示的に設定しなければならない。
このコードでは、センシティブなデータの保護を十分に行っていない。詳しくは「SER03-J. 暗号化されていないセンシティブなデータをシリアライズしない」を参照せよ。
適合コード (transient)
以下の適合コードでは、すべてのフィールドが transient 宣言されているので、シリアライズされない。readObject() メソッドは roll() メソッドを使ってそれらのフィールドを初期化する。このクラスは final 宣言する必要はない。すべてのフィールドが private 宣言されており、サブクラスからアクセスされることはないからである。
public class Lottery implements Serializable { private transient int ticket = 1; private transient SecureRandom draw = new SecureRandom(); public Lottery(int ticket) { this.ticket = (int) (Math.abs(ticket % 20000) + 1); } public int getTicket() { return this.ticket; } public int roll() { this.ticket = (int) ((Math.abs(draw.nextInt()) % 20000) + 1); return this.ticket; } public static void main(String[] args) { Lottery l = new Lottery(2); for (int i = 0; i < 10; i++) { l.roll(); System.out.println(l.getTicket()); } } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); this.draw = new SecureRandom(); roll(); } }
適合コード (シリアライズ不可能なクラス)
以下の適合コードでは、単純に Lottery クラスで Serializableを実装するのをやめ、シリアライズできないようにしている。
public final class Lottery { // ... }
リスク評価
実装上必要とされる不変条件を持つクラスでデフォルトのシリアライズ形式を使うと、悪意を持ったコードによって、不変条件の違反を引き起こされる危険がある。
ルール | 深刻度 | 可能性 | 修正コスト | 優先度 | レベル |
---|---|---|---|---|---|
SER07-J | 中 | 中 | 高 | P4 | L3 |
関連ガイドライン
MITRE CWE | CWE-502, "Deserialization of Untrusted Data" |
Secure Coding Guidelines for the Java Programming Language, Version 3.0 | Guideline 5-3. View deserialization the same as object construction |
参考文献
[API 2006] | Class Object, Class Hashtable |
[Bloch 2008] | Item 75, Consider using a custom serialized form |
[Greanier 2000] | |
[Harold 1999] | Chapter 11, Object Serialization, Validation |
[Hawtin 2008] | Antipattern 8. Believing deserialisation is unrelated to construction |
翻訳元
これは以下のページを翻訳したものです。
SER07-J. Do not use the default serialized form for implementation-defined invariants (revision 64)