Monitoring Deserialization to Improve Application Security

原文はこちら。
The original article was written by Chris Hegarty (Networking Lead of the Java Platform Group, Oracle).
https://inside.java/2021/03/02/monitoring-deserialization-activity-in-the-jdk/

多くのJavaフレームワークは、異なるコンピュータ上のJVM間でメッセージを交換したり、データをディスクに永続化したりするために、シリアライズ、デシリアライズを使っています。デシリアライズの監視は、最終的にアプリケーションに流れ込む低レベルのデータを把握する上で、このようなフレームワークを使用するアプリケーション開発者の役に立ちますし、この知見は、Java 9で導入されたシリアライズ・フィルタリングの設定に役立ちます。

シリアライズ・フィルタリング
https://docs.oracle.com/javase/jp/15/core/serialization-filtering1.html
Serialization Filtering
https://docs.oracle.com/en/java/javase/15/core/serialization-filtering1.html

シリアライズ・フィルタリングとは、受信したデータがアプリケーションに到達する前にスクリーニングすることで脆弱性を防ぐ仕組みです。また、フレームワークの開発者にとっても、フレームワークで実行されるデシリアライズのアクティビティをより効率的かつ動的にモニタリングできるというメリットがあります。

残念ながら、デシリアライズの監視は、Javaクラスライブラリがどのようにデシリアライズを行うかについての高度な知識を必要とするため困難でした。例えば、java.io.ObjectInputStreamのメソッドの呼び出しをデバッグしたり、インストルメント化したりするような脆い技術を使わなければなりません。より良い方法は、JDK Flight Recorder (JFR)を使用することです。JFRは、JavaアプリケーションとHotSpot JVMのトラブルシューティングのための低オーバーヘッドのデータ収集フレームワークで、OracleがJava 11でオープンソース化したものです。

JEP 328: Flight Recorder
https://openjdk.java.net/jeps/328

Java 17以降、JFRは、デシリアライズ操作によってトリガーされる新しいDeserialization Eventを使い、デシリアライズをファーストクラスでサポートします。JFRをJDK Mission Control (JMC)のようなツールと一緒に使用することで、ObjectInputStreamによるオブジェクトのデシリアライズを識別できます。

JDK-8261160: Add a deserialization JFR event
https://bugs.openjdk.java.net/browse/JDK-8261160
JDK Mission Control
https://www.oracle.com/java/technologies/jdk-mission-control.html

これにより、例えば、特定のクラスのオブジェクトのデシリアライズを識別したり、シリアライズ・フィルターが設定されていないデシリアライズ操作を監視したり、フィルターで拒否または許可された操作を監視したりできます。

この記事では、新たに導入されたDeserialization Eventを紹介し、有効化の方法を説明した上で、最後に実行中のJVMで発生するデシリアライズ操作を把握するためにどのように活用できるかを説明します。

JFR Deserialization Event

Java Platformでは、ObjectInputStreamはデシリアライズ処理を行います。つまり、ObjectInputStream::readObjectは、Javaヒープ内のライブオブジェクトを、バイトストリーム内の「静止状態」の表現から生成します。バイトストリームを処理してオブジェクトグラフを再構成するので、ObjectInputStreamの内部動作に対して、JFR Deserialization Eventが発生します。

Deserialization Eventは、ストリーム内の各新規オブジェクトに対して作成されます。このイベントは、ストリーム内の特定のオブジェクトに関連する詳細(デシリアライズされるオブジェクトのクラスなど)や、シリアライズ・フィルターが設定されている場合はその状態などの情報を取得します。Deserialization Eventが取得する情報と、シリアライズ・フィルターに報告される情報には多くの類似点がありますが、誤解のないように言うと、Deserialization Eventの作成は、フィルターが構成されているかどうかには関係ありません。

Deserialization Eventは以下の情報を捕捉します。

  • シリアライズ・フィルターを構成しているか否か
  • シリアライズ・フィルターを構成している場合、そのシリアライズ・フィルターの状態
  • デシリアライズされるオブジェクトのクラス
  • 配列をデシリアライズしている場合、その配列要素の個数
  • 現在のグラフ深度
  • 現在のオブジェクト参照の個数
  • 消費されたストリームの現在のバイト数
  • シリアライズ・フィルターによって例外が発生した場合、その種類とメッセージ

具体例を見ていきましょう。

Setting up the Example

この例では、できるだけわかりやすくするために、Java Development Kit (JDK) のツールのみを使用します。現在早期アクセスのJDK 17ビルドが必要です。

JDK 17 Early-Access Builds
https://jdk.java.net/17/

Deserialization Eventの作成をトリガーするため、まずはシリアライズおよびデシリアライズ可能なクラスが必要です。シリアライズ可能なrecordクラスを使いましょう。

package q;

record Point(int x, int y) implements java.io.Serializable { }

シリアライズおよびデシリアライズするためにちょっとしたユーティリティメソッドが必要になります。

/** Returns a serialized byte stream representation of the given obj. */
public static <T> byte[] serialize(T obj) throws IOException {
    try(var baos = new ByteArrayOutputStream();
        var oos = new ObjectOutputStream(baos)) {
        oos.writeObject(obj);
        oos.flush();
        return baos.toByteArray();
    }
}
/** Returns (reconstitutes) an object from a given serialized byte stream. */
static <T> T deserialize(byte[] streamBytes) throws Exception {
    try (var bais = new ByteArrayInputStream(streamBytes);
         var ois  = new ObjectInputStream(bais)) {
        return (T) ois.readObject();
    }
}

最後に、デシリアライズを発生するちょっとしたプログラムです。

public class BasicExample {
    public static void main(String... args) throws Exception {
        byte[] serialBytes = serialize(new q.Point(5, 6));
        q.Point p = deserialize(serialBytes);
    }
}

このちょっとしたプログラム(BasicExample)は、まずPointオブジェクトをシリアライズして、ある既知のシリアルバイとストリームデータを生成します。2番目のパートは、JFRでイベントを捕捉し検査するデシリアライズ操作を実行するため、より興味深い部分です。

上記のパーツが揃ったことで、 Deserialization Eventの生成と分析に必要なものがすべて揃いました。

Running with JFR

jcmdのようなツールでJFRの記録を動的に有効にしたり無効にしたりできますが、デモ目的であれば、コマンドライン引数を渡してJavaランチャーがJFRの記録を開始するようにするのが最も簡単です。

jcmd
https://docs.oracle.com/javase/jp/15/docs/specs/man/jcmd.html
https://docs.oracle.com/en/java/javase/15/docs/specs/man/jcmd.html

$ java -XX:StartFlightRecording=filename=recording.jfr,settings=deserEvent.jfc BasicExample
Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 2884 JFR.dump name=1 to copy recording data to file.

StartFlightRecordingオプションでは、いくつかの引数を受け入れます。filenameは、キャプチャされた記録の出力ファイル、settingsは、JFRの設定を含むファイルです。今回の場合は、カレントディレクトリにあるdeserEvent.jfcを設定ファイルとして利用します。この中で、以下のようにDeserialization Eventを有効にします。

<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0" description="test">
    <event name="jdk.Deserialization">
       <setting name="enabled">true</setting>
       <setting name="stackTrace">false</setting>
    </event>
</configuration>

今回は、jdk.Deserializationという1種類のイベントに関心があるだけなので、このイベントのみを構成ファイル内で有効にしています。

プログラムが終了すると、recording.jfrファイルには記録されたイベントが含まれています。JDKのjfrコマンドラインツールを使って記録を出力しましょう。

$ jfr print recording.jfr
jdk.Deserialization {
  startTime = 19:55:55.773
  filterConfigured = false
  filterStatus = N/A
  type = q.Point (classLoader = app)
  arrayLength = -1
  objectReferences = 1
  depth = 1
  bytesRead = 34
  exceptionType = N/A
  exceptionMessage = N/A
  eventThread = "main" (javaThreadId = 1)
}

1個のDeserialization Eventが出力されますが、これは、プログラムが些細な Point オブジェクトのdeserialize操作を 1 回だけ実行することから予想されるものです。typeフィールドは、デシリアライズされるオブジェクトのクラスを識別するもので、この場合は q.Pointです。ストリームオブジェクトは配列ではないため、arrayLengthは適用されず、値は-1です。このDeserialization Eventは、ストリーム内の最初のオブジェクト参照をキャプチャするため、objectReferencesの値は1です。 bytesReadフィールドには、イベントが作成された時点でシリアルバイトストリームから読み込まれた合計バイトの値が表示され、この例では34バイトです。

シリアライズ・フィルターを使用することの重要性と潜在的なセキュリティ上の利点を考慮して、イベントにはフィルターに関連するフィールドがいくつかあります。filterConfiguredの値は、シリアライズ・フィルターが設定されているかどうかを示すブール値です。今回の場合、フィルターは設定されていないので、値はfalseです。filterStatusには、フィルターの決定状況を示す値が含まれるのですが、フィルターが設定されていないため、Not Applicable (N/A)です。最後にexceptionTypeexceptionMessageは、フィルターからスローされた例外の詳細を(もしあれば)捕捉しますが、やはりフィルターが設定されていないので、これらのフィールドは Not Applicable (N/A)です。

Running with a Serialization Filter

比較的よく使われるフィルタリング方法は、信頼されていないクラスのリストを拒否する防御的な拒否リストの設定です(一連のクラスがわかっている場合には、許可リストのほうが好ましい)。

同じプログラムをもう一度実行してみましょう。ただし今度は、クラスがq.Pointであるオブジェクトのシリアライズを拒否するよう、シリアライズ・フィルターを設定します。これを実現するために、コマンドラインでjdk.serialFilterプロパティを使います。これにより、プログラムコードを変更せずにパターンベースのフィルターを定義できます。ここでは、クラス名の前に「!」が付いたものにマッチする基本パターンを指定します(「!」が付いたパターンにマッチするクラスは拒否されます)。

$ java -Djdk.serialFilter='!q.Point' -XX:StartFlightRecording=filename=recording.jfr,settings=deserEvent.jfc  -cp target/ q.BasicExample
Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 14725 JFR.dump name=1 to copy recording data to file.
Exception in thread "main" java.io.InvalidClassException: filter status: REJECTED
	at java.base/java.io.ObjectInputStream.filterCheck(ObjectInputStream.java:1378)
	at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2032)
	at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1889)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2196)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1706)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:496)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:454)
	at serial.Utils.deserialize(Utils.java:46)
	at BasicExample.main(BasicExample.java:32)

(クラスがp.Pointであるオブジェクトのデシリアライズを拒否するようフィルターを構成しているため)デシリアライズ操作中に、予想通りプログラムが例外をスローしました。このプログラムの実行時に作成された Deserialization Event を見てみましょう。

$ jfr print recording.jfr
jdk.Deserialization {
  startTime = 11:06:02.865
  filterConfigured = true
  filterStatus = "REJECTED"
  type = q.Point (classLoader = app)
  arrayLength = -1
  objectReferences = 1
  depth = 1
  bytesRead = 34
  exceptionType = N/A
  exceptionMessage = N/A
  eventThread = "main" (javaThreadId = 1)
  stackTrace = [ ... ]
}

フィルターが構成されていることを反映し、filterConfiguredの値がtrueになっていることを確認できます。filterStatusREJECTEDになっていますが、これはデシリアライズの操作が(想定通り)拒否されたためです。

Wrapping Up

Deserialization EventがJava 17で追加され、これにより、Java PlatformのSerialization API、はっきり言うとObjectInputStreamが実行する全てのデシリアライズ操作を監視、検査できるようになりました。Deserialization Eventを有効にして記録すると、次のような問い合わせに回答できるようになります。

  • 全てのデシリアライズ操作が構成済みのフィルターを使っているか?
  • Fooクラスに対するデシリアライズ操作があるか?
  • 何個のデシリアライズの操作を所定の期間で拒否しているか?

Deserialization EventはJFRイベントなので、JDK Mission ControlやAdvanced Management Consoleなどのツールから利用でき、実行中のJVMやリモートシステム上の複数のJVMで関心のあるデシリアライズ操作を動的に監視・検査できます。

JDK Mission Control
https://www.oracle.com/java/technologies/jdk-mission-control.html
Advanced Management Console
https://www.oracle.com/java/technologies/advancedmanagementconsole.html

この記事で概説したソースコードはこちらにあります。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中