88.防御的にreadObjectメソッドを書くべし
Item50では、ミュータブルなprivate Dateフィールドを持った、イミュータブルな日付のレンジクラスがあった。
そこでは、コンストラクタとアクセサでDateオブジェクトの防御的コピーを用いることでイミュータブルであることを守っていた。
package tryAny.effectiveJava;
import java.io.Serializable;
import java.util.Date;
public final class Period implements Serializable {
private final Date start;
private final Date end;
/**
* @param start
* the beginning of the period
* @param end
* 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(this.start + " after " + this.end);
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
public String toString() {
return start + "-" + end;
}
}
このクラスをSerializableにすることを考える。
物理的な構造と、論理的なデータ内容に齟齬がないため、デフォルトのserialized formを用いる、すなわち、Serializableを実装するだけでよいように思えるが、そのようにすると、不変条件が保たれなくなる。
readObjectメソッドは実質的にpublicなコンストラクタであり、その他のコンストラクタと同様のケアが必要であるという点で問題がある。
コンストラクタでは引数をチェックする必要がある(Item49)のと、必用に際して引数のディフェンシブコピーが要る(Item50)。そのため、readObjectでも同様の考慮がいる。
readObjectでこれらの考慮をし忘れると、攻撃者は不変条件を破ることが可能となる。
悪いバイトストリームを流し込むケース
大雑把に言うと、readObjectはバイトストリームを一つの引数として扱うコンストラクタである。
通常、バイトストリームは普通に構築されたインスタンスをシリアライズすることによって生み出されたものである。
問題となるのは、不変条件を犯すために人為的に構築されたバイトストリームである。そのようなバイトストリームは、通常あり得ないオブジェクトの生成を行ってしまう。
下記がその一例(Periodのendがstartよりも前に来てしまう)であるが、自分の環境だと、
Exception in thread "main" java.lang.IllegalArgumentException: java.lang.ClassNotFoundException: Period
at tryAny.effectiveJava.BogusPeriod.deserialize(BogusPeriod.java:31)
at tryAny.effectiveJava.BogusPeriod.main(BogusPeriod.java:20)
が出てしまう。。
package tryAny.effectiveJava;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.ObjectInputStream;
public class BogusPeriod {
// Byte stream could not have come from real Period instance
private static final byte[] serializedForm = new byte[] { (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
public static Object deserialize(byte[] sf) {
try {
InputStream is = new ByteArrayInputStream(sf);
ObjectInputStream ois = new ObjectInputStream(is);
return ois.readObject();
} catch (Exception e) {
throw new IllegalArgumentException(e.toString());
}
}
}
上のクラスは本来だと、endの日付がstartの日付より前になったものが表示されるはずであり、これはクラスの不変条件を破っている。
この攻撃を防ぐためには、下記のようにreadObjectメソッドでバリデーションチェックをかけてやる必要がある。
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
if (start.compareTo(end) > 0) {
throw new InvalidObjectException(start + "after" + end);
}
}
悪い参照を付け加える
上の攻撃は防げたとしても、Periodの条件を守りながらインスタンスを作成し、末尾にPeriodのprivateフィールドのDateへの参照を付け加えることも可能である。
つまり、攻撃者はObjectInputStreamからPeriodインスタンスを読み込み、そのストリームに付与された「悪いオブジェクト参照」を読み込む。
これらの参照によって、攻撃者は、Periodオブジェクト内のDateインスタンスへの参照を得られ、Periodインスタンスを変更することが可能となる。
以下がその例。
package tryAny.effectiveJava;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Date;
public class MutablePeriod {
public final Period period;
public final Date start;
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
// Period インスタンスの書き込み
out.writeObject(new Period(new Date(), new Date()));
// Period インスタンスの特定のプロパティへの参照を作成
byte[] ref = { 0x71, 0, 0x7e, 0, 5 };
bos.write(ref);
ref[4] = 4;
bos.write(ref);
// 可変な Period インスタンスの生成
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);
}
}
このプログラムを実行すると、以下のように出力される。
Mon Oct 08 18:43:49 JST 2018-Sun Oct 08 18:43:49 JST 1978
Mon Oct 08 18:43:49 JST 2018-Wed Oct 08 18:43:49 JST 1969
Periodインスタンスをそのままにしておくと、内部のコンポーネントを自由に操作されてしまう。
そうなると、Periodのimmutabilityにセキュリティ面で依存しているシステムは、攻撃をされうる。
原因としては、PeriodのreadObjectメソッドにおいて、防御的コピーを取れていなかったことにある。
オブジェクトがデシリアライズされる時、クライアントが保持してはならないオブジェクト参照を含むフィールドについては、防御的コピーを取ることが必須である。
具体的には、以下のようなreadObjectにしておくべき。(このとき、startとendはfinalではなくなる)
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
start = new Date(start.getTime());
end = new Date(end.getTime());
if (start.compareTo(end) > 0) {
throw new InvalidObjectException(start + "after" + end);
}
}
こうすることで、MutablePeriodの実行においても以下のようになり、不正は起こらない。
Mon Oct 08 20:19:56 JST 2018-Mon Oct 08 20:19:56 JST 2018
Mon Oct 08 20:19:56 JST 2018-Mon Oct 08 20:19:56 JST 2018
readObjectがデフォルトのままでよいか否かのリトマス紙:コンストラクタでどうか
transientでないフィールドを引数にとるpublicなコンストラクタを作ること、また、その引数をバリデーションかけずともよいかどうかを問うべき。
もしそれがだめならば、readObjectにおいても防御的コピーを取り、バリデーションチェックをかける必要がある。
代替手段としては、serialization proxy pattern(Item90)を使用するべき。
そのほかのreadObjectとコンストラクタの類似点として、readObjectメソッドの内部で、直接的であれ間接的であれoverrideされうるメソッドを利用してはならない(Item19)。
利用した場合には、オーバーライドされたメソッドが呼び出され、サブクラスの状態がデシリアライズされないまま実行され、エラーとなりうる。
まとめ
readObjectを書く時のガイドラインを以下にまとめる。
- オブジェクト参照を持つフィールドはprivateにし、防御的コピーを取る。immutableなクラスのmutableなコンポーネントはこれに当たる。
- 不変条件をチェックし、破られた場合はInvalidObjectExceptionをなげる。
- デシリアライズされた後に、オブジェクトグラフ全体にバリデーションをかける必要があるならば、ObjectInputValidationインターフェースを使う。(本書では語らない)
- オーバーライドされうるメソッドは直接的にも間接的にも呼ばない。