Skip to main content

Javaのシリアライズとデシリアライズ:Javaのデシリアライズ脆弱性を解説

2020年12月18日

0 分で読めます

編集者注 — 2022年7月11日

この記事は、新しいバージョンのJavaの変更点と、デシリアライズによって悪用される脆弱性を反映するために更新しました。また、Javaのデシリアライズ攻撃をライブデモで紹介した最近のカンファレンストークも追加しました。


Javaのシリアライズは、オブジェクトをバイトストリームに変換する仕組みです。一方、Javaのデシリアライズでは、バイトストリームからオブジェクトを再生成できます。Javaのシリアライズ、特にデシリアライズは、長年にわたり数多くのセキュリティ上の問題や脆弱性を生み出してきたことから、「後から後から問題が出てくる贈り物」と呼ばれています。

Javaにおけるシリアライズとは?

Javaではオブジェクトを作成します。オブジェクトはメモリに格納され、使われなくなるとガベージコレクターによって削除されます。オブジェクトを転送したり、たとえばディスクに保存したりネットワーク経由で送信したりするには、バイトストリームに変換する必要があります。そのためには、オブジェクトのクラスがSerializableインターフェースを実装していなければなりません。前述のとおり、シリアライズを使うとオブジェクトの状態をバイトストリームに変換できます。このバイトストリームに実際のコードは含まれません。

Javaにおけるデシリアライズとは?

デシリアライズはシリアライズの正反対です。デシリアライズでは、バイトストリームをもとに、以前シリアライズしたオブジェクトを元の状態で再作成します。ただし、オブジェクトを正常に再作成するには、そのオブジェクトの定義が必要です。

Javaのシリアライズの仕組み

Javaのシリアライズでは、リフレクションを使ってオブジェクトのフィールドから必要なデータをすべて取得します。privateフィールドやfinalフィールドも対象です。フィールドにオブジェクトが含まれている場合、そのオブジェクトも再帰的にシリアライズされます。getterやsetterがあっても、Javaでオブジェクトをシリアライズする際にこれらの関数は使われません。

Javaのデシリアライズの仕組み

バイトストリームをオブジェクトに戻す際、デシリアライズではコンストラクターを使いません。空のオブジェクトを作成し、リフレクションを使ってフィールドにデータを書き込みます。シリアライズと同様に、privateフィールドとfinalフィールドも対象になります。

Javaのデシリアライズ脆弱性とは?

Javaのデシリアライズ脆弱性とは、悪意のあるユーザーが改ざんしたシリアライズ済みオブジェクトをシステムに挿入し、システムやそのデータを侵害しようとしたときに発生するセキュリティ上の脆弱性です。シリアライズ済みオブジェクトのデシリアライズ時に引き起こされる、任意コード実行の脆弱性を想像してみてください。Javaのデシリアライズ脆弱性について詳しく説明するために、まずJavaでのデシリアライズの仕組みを見ていきましょう。

Javaのデシリアライズ脆弱性を解説

Javaのシリアライズ済みオブジェクトは、状態情報を含むバイト配列です。参照先のオブジェクト名とフィールドのデータが含まれています。16進エディターで保存済みのシリアライズ済みオブジェクトを見ると、情報を簡単に確認して改ざんできます。

すでに説明したとおり、Javaのデシリアライズでは、オブジェクトの作成にコンストラクターを使わず、リフレクションでフィールドを読み込みます。そのため、コンストラクターで行われる検証処理は、オブジェクトの再作成時に一切呼び出されません。たとえば、期間を表す場合に開始日が終了日より前かどうかを確認するような処理です。Javaオブジェクトをデシリアライズすると、状態が不正なオブジェクトが作成される可能性があります。

シリアライズ可能なクラスValueObjectのオブジェクトをシリアライズする、Javaのデシリアライズ脆弱性の例を見てみましょう。

public class ValueObject implements Serializable {

   private String value;
   private String sideEffect;

   public ValueObject() {
       this("empty");
   }

   public ValueObject(String value) {
       this.value = value;
       this.sideEffect = java.time.LocalTime.now().toString();
   }
}

ValueObject vo1 = new ValueObject("Hi");
FileOutputStream fileOut = new FileOutputStream("ValueObject.ser");
ObjectOutputStream out = new ObjectOutputStream(fileOut);
out.writeObject(vo1);
out.close();
fileOut.close();

シリアライズされたオブジェクトを含むファイル ValueObject.ser を16進エディターで読み取ると、次のように表示されます。

シリアライズされたJavaオブジェクトのデータとファイルオフセットを示す、緑色の16進ダンプが表示されたターミナルウィンドウ

文字列の値は簡単に改ざんできます。たとえば、16進エディターの出力でHiをHalloに変更してみましょう。

Javaのシリアライズデータを含む緑色の16進ダンプを表示したターミナルウィンドウ。「Hallo」を表すバイトが強調表示されている。
FileInputStream fileIn = new FileInputStream("ValueObject2.ser");
ObjectInputStream in = new ObjectInputStream(fileIn);
ValueObject vo2 = (ValueObject) in.readObject();

変更したバイナリファイルをデシリアライズすると、オブジェクトのvalueが変わっていることがわかります。また、タイムスタンプは変わっていません。これは、コンストラクターが呼び出されなかったことを示しています。アプリケーションがシリアライズ済みオブジェクトを受け入れる場合、その値を改ざんするのは比較的簡単です。シリアライズ済みオブジェクトを改変すると、不正なオブジェクトを作成したり、データの完全性を損なったり、さらに深刻な問題を引き起こしたりするおそれがあります。

任意コード実行、ガジェット、チェーン

オブジェクト内のデータを改ざんするだけでも有害ですが、適切なオブジェクトの組み合わせがデシリアライズされると、コード実行につながる可能性もあります。その前に、ガジェットとチェーンについて説明します。

ガジェット

LawrenceとFrohoffがAppSecCali 2015で発表したMarschalling Pickleで使った意味でのガジェットとは、脆弱なプロセス内にすでに存在する実行可能コードを持つクラスまたは関数です。この既存の実行可能コードは、悪意ある目的に再利用できます。Javaのシリアライズ可能なオブジェクトを見ると、privateのreadObject()メソッドのような特殊なメソッドが、デシリアライズ時にリフレクションによって呼び出されます。

簡略化した次のガジェットを見てみましょう。

public class Gadget implements Serializable {

   private Runnable command;

   public Gadget(Command command) {
       this.command = command;
   }

   private final void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
       in.defaultReadObject();
       command.run();
   }
}

このガジェットクラスは、デフォルトのreadObjectメソッドをオーバーライドしています。そのため、Gadgetクラスのオブジェクトがデシリアライズされるたびに、Runnableオブジェクトのcommandが実行されます。コマンドクラスが次の例のようなものであれば、このシリアライズ済みオブジェクトを簡単に改ざんしてコードを注入できます。

public class Command implements Runnable, Serializable {

   private String command;

   public Command(String command) {
       this.command = command;
   }

   @Override
   public void run() {
       try {
           Runtime.getRuntime().exec(command);
       } catch (IOException e) {
           throw new RuntimeException(e);
       }
   }

また、アプリケーションがシリアライズ済みオブジェクトを受け入れる場合、目的の型にキャストする前にオブジェクトがデシリアライズされる点にも注意してください。つまり、キャストに失敗しても、デシリアライズはすでに完了しており、readObject()メソッドも実行されています。

FileInputStream fileIn = new FileInputStream("Gadget.ser");
ObjectInputStream in = new ObjectInputStream(fileIn);
var obj = (ValueObject)in.readObject();

ガジェットチェーンを使ったデシリアライズ攻撃

典型的なデシリアライズ攻撃では、巧妙に作成されたガジェットのチェーンが使われます。攻撃者は攻撃の起点となるガジェットを探し、任意コード実行につながる複数の実行を連鎖させます。たとえば、次のような流れです。

Gadget -> readObject() -> command.run() -> Runtime.getRuntime().exec()

より実際に近い例として、java.util.HashMapの実装を見てみましょう。このクラスには独自のreadObject()メソッドがあり、各キーのhashcode()関数を呼び出します。

ライブラリ

アプリケーションで利用できるガジェットチェーンは、自分のコードに関係しない場合があることを知っておきましょう。ライブラリやフレームワークから多くのコードを取り込むため、(推移的な)依存関係によって取り込まれるクラスの数が、特定のガジェットチェーンが成立する可能性に影響します。悪意のあるガジェットチェーンの作成は困難で手間もかかりますが、Javaのデシリアライズ脆弱性は現実に存在する危険なセキュリティリスクです。

その代表例が、2021年12月に発見されたLog4Shell脆弱性です。これは本質的には、Javaのネイティブシリアライズフレームワークに起因し得るリモートコード実行の脆弱性でした。古いバージョンのJavaでは、リモートコードをデフォルトで信頼しており、クラスパスに該当クラスがなくても問題ありませんでした。しかし、最新バージョンを含む新しいJavaでも、クラスパス上にあるガジェットチェーンをLog4Shellの脆弱性を利用して悪用でき、リモートコード実行やさらに深刻な被害につながる可能性があります。

Javaのデシリアライズ脆弱性を防ぐには?

Javaのデシリアライズ脆弱性を防ぐ最善の方法は、Javaのシリアライズ自体を避けることです。アプリケーションがシリアライズ済みオブジェクトを受け入れなければ、被害を受けることもありません。

ただし、継承の都合で`serializable`インターフェースを実装する必要がある場合は、次のようにreadObject()をオーバーライドして、実際のデシリアライズを防ぐことができます。

private final void readObject(ObjectInputStream in) throws java.io.IOException {
   throw new java.io.IOException("Deserialized not allowed");
}

アプリケーションでシリアライズ済みオブジェクトを扱う必要がある場合は、デシリアライズの前にObjectInputStreamを検査する方法があります。Apache Commons IOライブラリが役立ちます。このライブラリが提供するValidatedObjectInputStreamでは、デシリアライズを許可するオブジェクトを明示的に指定できます。これにより、想定外の型がデシリアライズされるのを防げます。

FileInputStream fileIn = new FileInputStream("Gadget.ser");
ValidatingObjectInputStream in = new ValidatingObjectInputStream(fileIn);
in.accept(ValueObject.class);
var obj = (ValueObject)in.readObject();

Javaに組み込まれているシリアライズフィルターを使う方法もあります。この機能はJEP 290で実装され、Java 9(2018年3月)でリリースされました。クラスをブロックまたは許可するフィルターを設定できます。パターンに基づいてフィルターを作成し、デシリアライズ可能なクラスを制限できます。

ObjectInputStream in = new ObjectInputStream(fileIn);
ObjectInputFilter filesOnlyFilter = ObjectInputFilter.Config.createFilter("io.snyk.package.Object;!*");
in.setObjectInputFilter(filesOnlyFilter);

Java 17(2021年9月)でJEP 415がリリースされ、このフィルターシステムはコンテキストに応じて動作するように強化されました。つまり、状況に適した方法でフィルターの動作を制御できます。

記事セキュリティとシリアライズを強化するJava 17の新機能では、これらの新機能をさらに詳しく掘り下げ、Javaのフィルターの詳細や実装方法、コンテキストに応じたフィルターが重要な理由を解説しています。

ysoserialのようなツールも、Javaのデシリアライズ脆弱性を見つけるうえで非常に役立ちます。このツールはペイロードを生成し、一般的なJavaライブラリ内のガジェットチェーンを検出します。条件がそろうと、オブジェクトの安全でないデシリアライズを行うJavaアプリケーションを悪用できます。

Javaのデシリアライズ脆弱性は、Java独自のシリアライズ実装に限られません。この記事ではその実装に焦点を当てていますが、処理を担うシリアライズまたはマーシャリングのフレームワークにも同じ脆弱性が存在します。XML、JSON、YAMLなどの形式からPOJOを自動的に作成するフレームワークがある場合、前述と同じようにリフレクションを使っている可能性があります。つまり、同じ問題が起こり得ます。

外部ライブラリに潜むこの種のJavaデシリアライズ脆弱性を防ぐには、Snyk Open Sourceでライブラリを早い段階から定期的に(無料で)スキャンしましょう。この種の脆弱性について詳しく知りたい方は、安全でないデシリアライズに関するSnyk Learnのレッスンをご覧ください。

カンファレンストーク「Javaのデシリアライズ攻撃:なぜ気にする必要があるのか?」

先日、英国最大かつ最も権威あるJavaコミュニティカンファレンスDevoxx UKで、デシリアライズ攻撃について講演しました。

この講演では、Javaでデシリアライズ脆弱性がどのように機能するのか、攻撃チェーンがどのように作られるのかを解説しています。Javaのデシリアライズは「後から後から問題が出てくる贈り物」と呼ばれているため、セキュリティへの潜在的な影響を示す講演とライブデモを用意しました。

もちろん、最近のセキュリティ問題であるLog4Shell脆弱性も取り上げています。Log4Shellがデシリアライズのガジェットチェーンの起点となり、終端のガジェットが任意コード実行を引き起こす可能性について説明しました。

JSON、XML、YAMLなど、ほかの形式のデシリアライズでも問題が起こる可能性があります。詳しくはJackson ObjectMapperを使ったJavaのJSONデシリアライズの問題に関するブログ記事をご覧ください。この講演ではさらに掘り下げ、実際にどのような影響が生じるかをデモで紹介します。

Deserialization exploits in Java: why should I care? by Brian Vermeer

Capture the Flagを始めよう

オンデマンドのバーチャル入門ワークショップを見て、Capture the Flagの課題の解き方を学びましょう。

続きを読む

Blog

フロンティアモデルは脆弱性を発見した。攻撃者だけがエクスプロイトチェーンを見つけた。

静的解析で欠陥は見つかりましたが、ライブ攻撃テストで侵害につながる連鎖を実証できたのは唯一でした。Evo COS、Claude Security、Claude Code Securityを比較します。

feature insights context
Blog

自律型攻撃はすでに始まっている。防御もそのスピードに追いつかなければならない。

自律型攻撃者によって、防御に使える時間は短くなっています。継続的な検出、修復、検証、予防で、セキュリティチームが攻撃に歩調を合わせる方法をご紹介します。

Blog

AIコーディングエージェントが不適切なアクセス制御を繰り返し実装する理由

AIコーディングエージェントは、コンパイルが通りレビューも通過する一方で、あるテナントのデータを別のテナントに公開してしまう認可ロジックを生成することがあります。不適切なアクセス制御が検出しにくい理由と、その防止策をご紹介します。