java.io.Serializable 実装クラスに対してどのようなテストを行うべきなのかを考えるシリーズの2回目です。
前回の『Serializable実装クラスのテストについて考える』 - 「ケース1:デシリアライズ時のインスタンス制御についてテストする」では、シングルトンなどのインスタンス制御を行っているクラスに対するテストについて取り上げました。
今回は「ケース2:不正なバイト配列からのデシリアライズをテストする」と題して、シリアライズされたバイト配列が不正に改竄されている場合に備えたテストについて考えたい思います。
おさらい:オブジェクトのシリアライズ形式
さて、次の Odd クラスは奇数だけを受け付けることを意図した int のラッパーです。
package mypkg;
import java.io.Serializable;
/**
* 奇数だけを受け付ける int の不変ラッパーです。
*/
public final class Odd implements Serializable {
private static final long serialVersionUID = 1L;
private final int n;
public Odd(int n) {
if (n < 1 || n % 2 == 0) {
throw new IllegalArgumentException("n must be an odd.");
}
this.n = n;
}
public int get() {
return n;
}
}
まずは、このクラスのオブジェクトがどのような形式にシリアライズされるのかを見てみましょう。
new Odd(7);
で生成したオブジェクトをシリアライズすると、次のバイト配列が得られます。(これは OddDemo#demo1 を動かして得たものです。興味のある方は本稿末尾に掲載したソースコードをご覧ください。)
ac ed 00 05 73 72 00 09 6d 79 70 6b 67 2e 4f 64 64 00 00 00 00 00 00 00 01 02 00 01 49 00 01 6e 78 70 00 00 00 07
このバイト配列を読み解いてみます。
桁 | バイト配列 | 内容 |
---|---|---|
01-04 | ac ed 00 05 |
固定の定数値です。 |
05-06 | 73 72 |
これがオブジェクトでありクラスの定義が続くことを表す定数値です。 |
07-08 | 00 09 |
クラス名が 9 文字であることを表します。 |
09-17 | 6d 79 70 6b 67 2e 4f 64 64 |
クラス名 "mypkg.Odd" を表します。 |
18-25 | 00 00 00 00 00 00 00 01 |
serialVersionUID==1L を表します。 |
26 | 02 |
シリアライズ機構によりこのオブジェクトに割り当てられた番号です。 |
27 | 00 |
クラスの性質を表すフラグ値です。 |
28 | 01 |
1つのフィールドを持つことを表します。 |
29 | 49 |
int 型のフィールドであることを表します。 |
30-31 | 00 01 |
フィールド名が 1 文字であることを表します。 |
32 | 6e |
フィールド名 "n" を表します。 |
33 | 78 |
クラス定義情報の終わりを表す定数値です。 |
34 | 70 |
スーパークラス由来のシリアライズ対象フィールドが無いことを表します。 |
35-38 | 00 00 00 07 |
フィールド n の値 7 を表します。 |
シリアライズ形式の詳細は、Oracle: Java開発者ガイド の中の こちらのページ で確認することができます。
人間が比較的簡単に読める形式でシリアライズされることをご理解いただけたと思います。それ故に、バイト配列の改竄も、比較的簡単にできてしまうのです。
実演:バイト配列を不正に改竄することによる間違ったオブジェクトの生成
では実際にバイト配列を不正に改竄し、誤ったオブジェクトが生成され得ることを確認してみましょう。
public class OddDemo {
// ・・・
@Test
public void demo2() throws IOException, ClassNotFoundException {
Odd odd = new Odd(7);
byte[] bytes = TestUtil.serialize(odd);
bytes[37] = 0x02; // ★★バイト配列を不正に改竄する!★★
odd = (Odd) TestUtil.deserialize(bytes);
System.out.println("odd.get() == " + odd.get());
}
}
このコードを実行すると、odd.get() == 2
と出力されます。
奇数だけしか持ちえないはずのオブジェクトが、偶数値を持ってしまったわけです。
Odd クラスが奇数だけを受け付けることを意図し、それを API ドキュメントで謳っている以上、このような事態を防ぐのは Odd クラスの責任です。防御するためのコードを実装しなければなりません。
これは、Odd クラスに readObject(ObjectInputStream) メソッドを追加することにより実現できます。
public final class Odd implements Serializable {
// (略)
private void readObject(ObjectInputStream stream)
throws ClassNotFoundException, IOException {
stream.defaultReadObject();
if (n < 1 || n % 2 == 0) {
throw new InvalidObjectException("illegal value. n == " + n);
}
}
}
readObject メソッドを実装しておくと、デシリアライズの過程で自動的に呼び出されます。メソッド内部では、バイト配列から復元されたフィールド n の値を検査し、不正な値の場合は例外をスローするようにしました。これにより、再び demo2 を実行すると、例外がスローされて失敗します。不正な内容の Odd オブジェクトが世に出回ることはもはやありません。
ケース2:不正なバイト配列からのデシリアライズをテストする
シリアライズ/デシリアライズのために実装したソースコードの品質をソースの机上確認だけで担保することは難しいです。やはり実際に動かしてテストするべきでしょう。
追加した readObject が意図した通りに動くかを、不正に改竄したバイト配列を実際に与えることでテストしてみます。
public class OddTest {
@Test
public void test1() throws IOException, ClassNotFoundException {
// 改竄を行わない正常ケース(7 -> 7)
byte[] bytes = TestUtil.serialize(new Odd(7));
Odd odd = (Odd) TestUtil.deserialize(bytes);
assertThat(odd.get(), is(7));
}
@Test
public void test2() throws IOException, ClassNotFoundException {
// 受け入れられる内容に改竄を加えたケース(7 -> 1)
byte[] bytes = TestUtil.serialize(new Odd(7));
bytes[37] = 0x01;
Odd odd = (Odd) TestUtil.deserialize(bytes);
assertThat(odd.get(), is(1));
}
@Test
public void test3() throws IOException, ClassNotFoundException {
// 不正な内容に改竄を加えたケース:偶数値(7 -> 2)
byte[] bytes = TestUtil.serialize(new Odd(7));
bytes[37] = 0x02;
assertThat(of(() -> TestUtil.deserialize(bytes)),
raise(InvalidObjectException.class, "illegal value. n == 2"));
}
@Test
public void test4() throws IOException, ClassNotFoundException {
// 不正な内容に改竄を加えたケース:マイナス値(7 -> -1)
byte[] bytes = TestUtil.serialize(new Odd(7));
bytes[34] = (byte) 0xff;
bytes[35] = (byte) 0xff;
bytes[36] = (byte) 0xff;
bytes[37] = (byte) 0xff;
assertThat(of(() -> TestUtil.deserialize(bytes)),
raise(InvalidObjectException.class, "illegal value. n == -1"));
}
}
上記のテストはいずれも成功します。
Odd#readObject(ObjectInputStream) が狙い通りの役割を果たしていることを確認できました。
※ なお、test3 と test4 で行っている例外発生検証では、以前の投稿『JUnit4での例外テストを楽チンにする!』で紹介した GitHub: ライブラリ を利用しています。
まとめ
- シリアライズされたオブジェクトのバイト配列を読み解いて改竄することは、比較的簡単です。
- public なメソッドがパラメータ検査を行うことにより不正なパラメータ値から身を守るのとまったく同じように、デシリアライズ時に不正に改竄されたバイト配列から身を守るのは、Serializable 実装クラス自身の責任です。
- Oracle: Javaオブジェクト直列化仕様 では、各クラスで防御機構を実装するための readObject などの仕組みが用意されています。
- 実装を行った以上、実際に動かすことによりテストを行うべきでしょう。本稿で例示したように、不正に改竄したバイト列を実際に与えるテストを行うのが良いと考えます。
今回はややマニアックな内容だったかもしれません。
次稿では基本に立ち返り、「ケース3:そのクラス、本当に Serializable ですか?- 最低限、シリアライズ/デシリアライズしてみる」について取り上げます。
おまけ:バイト配列の改竄に備えているクラスの例
Odd クラスよりももう少し複雑な例として、以前の投稿『リバーシで遊んで覚える Java 8.』で紹介した GitHub: ソース の中から、2つのクラスを取り上げさせていただきます。
- Moveクラス : このクラスはオセロの手を表し、(Color, Point) のペアを保持する不変クラスです。決して Color == null にならないように、Odd と同様に readObject を利用して防御しています。
- StrictBoardクラス : このクラスはルールを忠実に守るオセロ盤です。ルールに反する状態を決してとらないよう、シリアライズプロキシパターンを利用して不正なバイト配列から身を守っています。
参考
最後に、本稿で用いたソースコードを掲載します。
java のバージョンは java 8 です。
package mypkg;
import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;
public final class Odd implements Serializable {
private static final long serialVersionUID = 1L;
private final int n;
public Odd(int n) {
if (n < 1 || n % 2 == 0) {
throw new IllegalArgumentException("n must be an odd.");
}
this.n = n;
}
public int get() {
return n;
}
private void readObject(ObjectInputStream stream)
throws ClassNotFoundException, IOException {
stream.defaultReadObject();
if (n < 1 || n % 2 == 0) {
throw new InvalidObjectException("illegal value. n == " + n);
}
}
}
package mypkg;
import java.io.IOException;
import org.junit.Test;
public class OddDemo {
@Test
public void demo1() throws IOException {
Odd odd = new Odd(7);
byte[] bytes = TestUtil.serialize(odd);
System.out.println(TestUtil.toHexString(bytes));
}
@Test
public void demo2() throws IOException, ClassNotFoundException {
Odd odd = new Odd(7);
byte[] bytes = TestUtil.serialize(odd);
bytes[37] = 0x02; // ★★バイト配列を不正に改竄する!★★
odd = (Odd) TestUtil.deserialize(bytes);
System.out.println("odd.get() == " + odd.get());
}
}
package mypkg;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static xyz.hotchpotch.jutaime.throwable.RaiseMatchers.*;
import static xyz.hotchpotch.jutaime.throwable.Testee.*;
import java.io.IOException;
import java.io.InvalidObjectException;
import org.junit.Test;
public class OddTest {
@Test
public void test1() throws IOException, ClassNotFoundException {
// 改竄を行わない正常ケース(7 -> 7)
byte[] bytes = TestUtil.serialize(new Odd(7));
Odd odd = (Odd) TestUtil.deserialize(bytes);
assertThat(odd.get(), is(7));
}
@Test
public void test2() throws IOException, ClassNotFoundException {
// 受け入れられる内容に改竄を加えたケース(7 -> 1)
byte[] bytes = TestUtil.serialize(new Odd(7));
bytes[37] = 0x01;
Odd odd = (Odd) TestUtil.deserialize(bytes);
assertThat(odd.get(), is(1));
}
@Test
public void test3() throws IOException, ClassNotFoundException {
// 不正な内容に改竄を加えたケース:偶数値(7 -> 2)
byte[] bytes = TestUtil.serialize(new Odd(7));
bytes[37] = 0x02;
assertThat(of(() -> TestUtil.deserialize(bytes)),
raise(InvalidObjectException.class, "illegal value. n == 2"));
}
@Test
public void test4() throws IOException, ClassNotFoundException {
// 不正な内容に改竄を加えたケース:マイナス値(7 -> -1)
byte[] bytes = TestUtil.serialize(new Odd(7));
bytes[34] = (byte) 0xff;
bytes[35] = (byte) 0xff;
bytes[36] = (byte) 0xff;
bytes[37] = (byte) 0xff;
assertThat(of(() -> TestUtil.deserialize(bytes)),
raise(InvalidObjectException.class, "illegal value. n == -1"));
}
}
package mypkg;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Objects;
public class TestUtil {
public static byte[] serialize(Object obj) throws IOException {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
return bos.toByteArray();
}
}
public static Object deserialize(byte[] bytes)
throws ClassNotFoundException, IOException {
Objects.requireNonNull(bytes);
try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis)) {
return ois.readObject();
}
}
public static String toHexString(byte[] bytes) {
Objects.requireNonNull(bytes);
StringBuilder str = new StringBuilder();
for (byte b : bytes) {
str.append(String.format("%02x ", b));
}
return str.toString();
}
private TestUtil() {
}
}
※xyz.hotchpotch.jutaime.throwable
パッケージのソースについては こちら(GitHub) をご参照ください。