#はじめに
この記事はシスコ同志によるAdvent Calendarの25日目として投稿しています。
幸いタイトルのような無茶振りには遭ったことはありませんが、シスコではJavaバイトコードを操作する技術を使った監視製品を開発しているので、その技術について本記事でサンプルを交えて紹介します。
この記事を読んで出来ること
おわかりでしょうか?HelloWorldはAliceに対して挨拶するプログラムだったのに、-javaagent:agent.jar
というオプションを付けて実行すると、Malloryに挨拶するプログラムに変わってしまいました。
このカラクリはJVMが読み込んだJavaバイトコードを実行時に書きかえ、変数の値をAliceからMalloryに変えています。HelloWorldのソースコードも、コンパイル後の.classファイルも変えていないにも関わらず、実行時に処理を変更できてしまうのです。
バイトコードインジェクション
これが本記事で紹介する**バイトコードインジェクション(BCI: Byte Code Injection/Instrumentation)**という技術です。ソースコードやビルド済みのアプリを変えることなく、ランタイムでアプリの挙動を変更できるという非常に強力なテクニックであり、うまく使用すると以下のようなことが可能です。
- ソースコードにログコードを入れずに、変数の値やパフォーマンスを取得する
- 本番環境にデプロイしたJavaアプリを動かしたまま処理を変更する
- 脆弱性が見つかったライブラリをランタイムで変更し、セキュアに実行する
バイトコードインジェクションがどのようなしくみでJavaバイトコードを書きかえるのか、サンプルコードとともに紹介します。
Dockerイメージ
本記事で使用するJVM環境と、サンプルコードが入ったDockerイメージ・ソースコードを公開しています。環境はOpenJDK1.8.0_275とUbuntu 20.10のシンプル構成です。
- DockerHubレポジトリ:https://hub.docker.com/repository/docker/cilto/bytecode-injection
- GitHubレポジトリ:https://github.com/cilto/bytecode-injection
バイトコードを操作する技術
JavaでHello Worldプログラムが実行されるまで
Javaのプログラムはコンパイル時に、JavaバイトコードというJVMが解釈できる中間言語の形に変換されます。JVMはバイトコードを読み込み、実行環境に応じたマシンコードに変換し、実行します。
バイトコードを実行時に書きかえるAPI
Java1.5より、JVM TI(Java Virtual Machine Tool Interface)という仕様が策定され、JVM上のアプリケーションを外からコントロールしたり、デバッグできるAPIが導入されました。その仕様ではjava.lang.instrumentパッケージを使って、JVMにロードされたバイトコードを実行時に変更する機能を提供しています。
-javaagent:xxx.jar
というJVMオプションを指定すると、アプリケーションをロードする際に、指定されたプログラムがバイトコードを読み込み、自在に書きかえ、アプリケーションの動作を変えることができます。
そのバイトコード操作を担うプログラムをエージェントと呼び、アプリケーションと独立して動くプログラムとなります。このエージェントがどのようなしくみで動くのか、デモプログラムのHello Worldを例に見てみましょう。
バイトコードを書きかえる仕組み
以下の図がバイトコードを置きかえる際の流れになります。一つずつ追って見ていきましょう。
-
javaagentオプションが指定されると、JVMはアプリケーションよりも前にjavaagentで指定したエージェントを読み込み、エージェントが定義するpremainメソッドを実行します。("pre"mainという名前の通りの動作です)
-
premainメソッドでは、バイトコードを書きかえるTransformerインスタンスを生成し、JVMにコールバック関数として登録します。
-
エージェントの処理が終了すると、JVMはアプリケーションのバイトコード(HelloWorld.class)のロードを始めます。
-
新しいクラスをロードする度に、JVMでClassFileLoadHookイベントが発火し、登録されたTransformerをコールバックとして呼び出します。(JVM TIの仕様です)
-
TransformerにはロードされたバイトコードがclassfileBufferという変数で渡されます。この変数を通してクラス定義を読み込み、書きかえることができます。エージェントはアプリケーションよりも前に読み込まれるため、アプリケーションのバイトコード全てをロード時に読み込み、置きかえることが可能です。これがバイトコードインジェクションの肝となります。
ClassFileLoadHookイベントの中身
5つ目のステップで渡されるClassFileLoadHookの中身は以下となります(Open JDK実装)。class_dataの先にロードされたバイトコードが保存されており、そのデータをJVMはclassfileBufferという変数としてTransformerに渡します。
Transformerは書きかえたバイトコードを返し、そのデータがnew_class_dataに入ります。JVMは、new_class_dataにデータが存在する場合、こちらの置換されたバイトコードを実行します。
ClassFileLoadHook(jvmtiEnv *jvmti_env,
JNIEnv* jni_env,
jclass class_being_redefined,
jobject loader,
const char* name,
jobject protection_domain,
jint class_data_len,
const unsigned char* class_data,
jint* new_class_data_len,
unsigned char** new_class_data)
エージェントを自作してバイトコードを書きかえてみよう
バイトコードインジェクションでは普段意識しないJVMの内部処理が関わってくるため、直感的に理解しづらいかと思います。なので、最低限の機能をもったエージェントをつくり、最初のデモのようにHello Worldプログラムを書きかえ、ハンズオンでインジェクションの処理を理解してみましょう。
環境を汚したくない方のために、JVM環境とサンプルコードが入ったDockerイメージを配布しています。Dockerイメージとソースコードのリンクはこちら
まずはサンプルを走らせてみる
以下のコマンドを実行すると、Ubuntuベースのコンテナ内でサンプルアプリをビルド・実行できます。
docker run -it cilto/bytecode-injection:latest bash
(コンテナ内で)./build.sh
このような出力になっていれば正常に動いています。
root@myagent-7fb7fbb849-jv89j:/tmp/instrument_example# ./build.sh
マニフェストが追加されました
instrument/MyAgent.classを追加中です(入=507)(出=311)(38%収縮されました)
=== Javaバイトコードが変更されました ===
Hello, I'm Mallory
root@myagent-7fb7fbb849-jv89j:/tmp/instrument_example#
サンプルコードの構成
コンテナイメージのデフォルトディレクトリ配下の構成と、各ファイルの役割は以下のとおりです。
root/
├ build.sh エージェントのビルドやアプリの実行を一括で行うシェルスクリプト
└ instrument/
├ HelloWorld.java BCI対象のアプリケーション
├ MyAgent.java BCIを行うエージェント
├ MANIFEST.MF エージェントをJarに圧縮するためのマニフェストファイル
└ lib/
└ javassist.jar BCIの補助ライブラリ
アプリケーションプログラム
Hello Worldプログラムは、名前を引数にとってsayHello関数を呼び出し、Hello, I'm <name>
と出力する単純なプログラムです。
javac instrument/Greetings.java && java instrument.HelloWorld
でコンパイル・実行すると、Hello, I'm Alice
と出力されているはずです。
package instrument;
public class HelloWorld {
public static void main(String[] args) {
sayHello("Alice");
}
public static void sayHello(String name) {
System.out.println("Hello, I'm " + name);
}
}
Hello, I'm Alice
ではこのコンパイル後のHelloWorld.classを全く変えずに、実行時に名前をMallory
に変えるエージェントを実装していきましょう。
エージェントプログラム
-javaagent
オプションで起動するBCIエージェントを実装するためには、以下の2つが必須要件となります。
1. エージェントクラスでpremainメソッドを実装する
2. JARのマニフェストに属性Premain-Classを定義し、エージェントクラスの名前を値とする
premainメソッドを実装する
premainはアプリケーションより先に呼ばれ、Instrumentationインスタンスを受けとります。このインスタンスからTransformer
を登録できます。
public class MyAgent {
public static void premain(String agentArgs, Instrumentation instrumentation) {
instrumentation.addTransformer(
...
このTransformer
はClassFileTransformerクラスの実装です。transform
関数は、JVMがクラスをロードする際にコールバックとして呼び出され、置換したバイトコードを返します。JVMはこのバイトコードを実行するため、transoform関数の実装がバイトコードを置きかえるロジックのコア部分といえます。
transform関数のclassNameにはロードされたクラス名が入っており、サンプルのHello Worldの場合instrument/HelloWorld
という文字列になります。続いて、classfileBufferには、クラス定義部分のバイトコードデータが保存されており、読み出せばどのような関数や変数が定義されているかが分かります。
最後に、関数の返り値として置換したバイトコードを返します。
instrumentation.addTransformer(
new ClassFileTransformer() {
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
....
return transformedByteCode;
}
});
バイトコードを再構築する
あとはバイトコードを書きかえるロジックを実装するのみです。しかし、バイトコードは人間に読める形であるものの、生のバイトデータを文字列処理するのは大変です。幸い、バイトコードを簡易に操作できるライブラリがサードパーティから提供されているので、本記事ではその一つであるjavassistというライブラリを使います。
以下のコードがバイトコード書きかえを行う部分です。デモのようにsayHello関数の引数を書きかえるため、まずは元のクラスのバイトコードをJavassistのクラス定義のインスタンスctClass
に読み直します。
getDeclaredMethods
でHelloWorldのメソッドを取得し、sayHelloの場合に分岐させます。
$1
という変数が、sayHelloの第1引数name
に当たります。よって、"$1=\"Mallory\";"
ではname変数にMalloryという文字列を代入しており、そのバイトコードを動的に生成しています。
このバイトコードをsayHelloの一番最初に挿入することで、Helloの相手をMalloryに書きかえています。
これでエージェントの動作として全ての処理が完了しました。
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.makeClass(new ByteArrayInputStream(
classfileBuffer));
CtMethod[] methods = ctClass.getDeclaredMethods();
for (CtMethod method : methods) {
if (method.getName().equals("sayHello")) {
method.insertBefore("$1=\"Mallory\";");
}
}
byteCode = ctClass.toBytecode();
...
マニフェストファイルを用意
エージェントはjar形式ですので、マニフェストファイルが必要です。通常のJavaアプリケーションではMain-Class
という属性でエントリーポイントを明示しますが、エージェントではPremain-Class
にエージェントのクラスを指定します。
Premain-Class: instrument.MyAgent
...
エージェントの生成
以下のコマンドでMyAgentをコンパイルし、jarファイルに圧縮します。これでエージェントの準備が整いました。
javac -classpath instrument/lib/javassist.jar instrument/MyAgent.java
jar cvfm agent.jar instrument/MANIFEST.MF instrument/MyAgent.class
自作エージェントでHello Worldを書きかえる
それではこの自作エージェントを使って、実行時にバイトコードが書きかわることを確認してみましょう
-javaagent:agent.jar
パラメーターを入れ、Hello Worldを実行します。
すると以下のように、変数がAliceからMalloryに置きかわっているはずです。
/tmp/instrument_example# java instrument.HelloWorld
Hello, I'm Alice
/tmp/instrument_example# java -javaagent:agent.jar instrument.HelloWorld
=== Javaバイトコードが変更されました ===
Hello, I'm Mallory
簡易プロファイラをBCIで作ってみる
今度は少し実用的な例として、メソッドの実行時間を測定する簡易的なプロファイラを、バイトコードインジェクションを使って実装してみます。先程のデモをほんの少し変更するだけで作れます。
走らせてみる
同様にサンプルコードを入れたDockerイメージを公開しているので、まず動かしてみましょう。
docker run -it cilto/bytecode-injection-profiler:latest bash
(コンテナ内で)./build.sh
結果は以下のようになります。最後の2行をみると、HelloWorld.javaの各関数mainとsayHelloの実行秒数が表示されていることが分かります。
root@7f4caf47ab89:/tmp/instrument_example# ./build.sh
マニフェストが追加されました
instrument/MyAgent.classを追加中です(入=507)(出=311)(38%収縮されました)
=== Javaバイトコードが変更されました ===
Hello, I'm Alice
*[Injected code] function:sayHello executed. Duration is 119usec
*[Injected code] function:main executed. Duration is 285usec
プロファイラのバイトコード
変更したのはtransform
関数の以下の部分のみです。今回は全てのメソッドに対して、バイトコードインジェクションを行います。
まずメソッドの実行時の時間を保持するstartTime
という変数を定義し、メソッドの始めに現時刻を入れておきます。メソッドの終わりでは、現在時刻を取得し、最初の時刻から引いた値を出力するバイトコードを入れています。このように単純ですが、メソッドの最初と最後にバイトコードを入れるだけで簡易プロファイラができてしまいます。
for (CtMethod method : methods) {
method.addLocalVariable("startTime", CtClass.longType);
method.insertBefore("startTime = System.nanoTime();");
method.insertAfter("System.out.println(\"*[Injected code] function:" + method.getName() + " executed. Duration is \" + (System.nanoTime() - startTime)/1000 + \"usec\"); ");
}
バイトコードインジェクションはどう活用されているのか
バイトコードインジェクションは仕組み自体は少し複雑ですが、-javaagent
オプションを追加するだけで使えるため、非常に強力で手軽に利用できるツールとして、様々な用途で使われています。
アプリケーションパフォーマンス監視:APM
バイトコードインジェクションを活用すると、ログのコードを追加することなく、システムのパフォーマンスを取得したり、データフローを可視化することができます。これをエンドツーエンドで一括に監視・可視化できるのがアプリケーションパフォーマンス監視(APM)というツールです。
APMはシステムにオブザーバビリティ(可観測性)を付加し、サービス全体の状態やデータフローをリアルタイムに把握できます。複雑なシステムでも、データフローを見ながら修正や機能追加を行えるため、安全に高速に開発サイクルを回せます。また問題が起きた箇所を横断的に確認できるため、運用のオペレーションコストが減り、DevOpsを推進する基盤となります。
例えば、(アプリケーション 1)->(メッセージキュー)->(アプリケーション 2)->(AWS S3)
というデータフローのシステムがあったとします。このシステムのアプリケーション1と2にエージェントを導入すると、以下のようなグラフがリアルタイムで自動的に可視化されるようになります。
実際に実行されたコードをトレースして可視化するため、一見するだけで動いたデータの流れが分かり、レスポンスタイムやコードレベルでのエラーが一見して分かるため、非常に強力なツールです。
今回はJavaのBCIのみ取り上げましたが、.NetやJavaScriptでも同様のことができ、商用APMベンダーではたいていのメジャーな言語に対応しています。特に分散化されたシステムには欠かせないツールで、最近ではCNCFのSandBoxプロジェクトとしてOpen telemetryのようなOSS実装のエージェントも出てくるなど、注目度が高くなっています。
ランタイムで自己防御するセキュリティ:RASP
脆弱性があるコードをランタイムに検知し自己改変する防御手法をRun-time Application Self-Protection(RASP)といい、すばやくアプリケーションを保護する手法として注目されています。
このRASPにおいて、検知・自己改変の部分にはバイトコードインジェクションが使われており、理論だけの技術ではなくこの会社の例のように実際にプロダクトとして販売している会社もあります。ドキュメントにある通り、-javaagent
でエージェントを読み込ませています。エージェントはロードされたクラス情報にアクセスできるため、脆弱性があるクラスや、意図とは違う処理が行われそうになると検知し、バイトコードを改変することで実行を防ぐことができます。
本番緊急パッチ
もし本番環境を落とせず(ロードバランサ等もなく)、コードのバグが致命的で緊急に直さなければならない、という場合にもBCIは使えます。
たとえば、『本番環境でやらかしちゃったアドベントカレンダー』で取り上げられているような、APIの向き先が間違ってしまった場合には、そのURLの変数のバイトコードをエージェントで書きかえることが可能です。あまり行儀のいい方法ではありませんが、取れる選択肢が少ない状況下で意外と有用な方法になりうるかもしれません。
JVM自体のデバッグ
JVMの動作やクラスローダーをデバッグする際には、一つ一つ直しながらビルドするよりも、非常に早くコードを改変して実行することができます。たとえば、クラスローダーの順番やそのロード解決を追いたい場合には、各ローダーの名前やローダーが読み込んだクラス名をprintするだけのエージェントは簡単に作ることができます。
最近ではJavaはモジュール化され軽量になっていますが、依然とビルドには時間がかかるため、こういったサイドカー的なデバッグ手法は強力です。
もっと詳しく知りたい方へ
BCI技術についてのドキュメント
- OpenJDKでのInstrument実装ソースコード:特に89-111行目のaddTransformerの処理をさかのぼっていくとTransformerがどう登録され、呼ばれるのかが分かります。
- ClassFileLoadHookイベントに対してJVMがコールバック関数を呼び出す仕組みについて:特にEvent Callbacksの項でcbClassFileLoadHookに注目です。cbはコールバックの意。
- OpenJDKでのAgent実装ソースコード:上記の仕組みのソースコードを見たければこちら。ClassFileLoadHookイベントをJVMがどう処理しているのかが分かります。ソースコード自体はエージェント全般の実装となっています。
- BCIを仕様策定したJVM TIのリファレンス
本記事で使用・引用した文献
- Instrumentパッケージの概要について
- Instrumentationインターフェースの詳細について
- ClassFileTransformerインターフェイス
- Javassistライブラリ 本ライブラリは東京大学の千葉滋教授の開発となります。
- JVM TIが策定されたJSR-163の内容(JSR: Java Specification Requests)
- IBMでのContrast Securityの実運用イメージ
お仕事で触りたい方は
バイトコードインジェクションの実用例で挙げた、APMベンダーのAppDynamics(Cisco)という製品部門で日本語サポートエンジニアを募集しています。筆者はそのチームを立ち上げ、マネージャーを担当しています。興味をもった方やこんな技術で遊びたいという方はこちらのリンクから応募してみてください。
補足事項
- 「バイトコードインジェクション」という訳については、Javaの正式名称はByte Code Instrumentationとなっています。Instrumentationというのは「器械を使用・導入する」や「計測」という意味で、バイトコードという「道具」を「入れて」動かすことを指していますが、より直感的な言葉としてByte Code Injectionとも言われてます。Oracleの日本語翻訳ドキュメントではインストゥルメンテーションとそのままカナにしており、また日本語ではいい訳語がないため、本記事では「バイトコードインジェクション」という言葉を使っています。
- ハンズオンで取り上げたBCIは、JVM起動時にエージェントを読み込む場合の流れですが、メジャーなJVM実装では、動作しているJavaプロセスに対してもエージェントを適用することができます。その場合はManifestの内容や関数名が異なります。
- Java以外の言語ではJavaバイトコードに当たる中間コードの名前は異なります。例えば.Netでは共通中間言語(CLI: Common Intermediate Language)という名前で、仕様についてはECMA-335に記述があります。
他のシスコシステムズアドベントカレンダーはこちら
2017年版: https://qiita.com/advent-calendar/2017/cisco
2018年版: https://qiita.com/advent-calendar/2018/cisco
2019年版: https://qiita.com/advent-calendar/2019/cisco
2020年版: https://qiita.com/advent-calendar/2020/cisco
2020年版(2枚目): https://qiita.com/advent-calendar/2020/cisco2
免責事項
本サイトおよび対応するコメントにおいて表明される意見は、投稿者本人の個人的意見であり、シスコの意見ではありません。本サイトの内容は、情報の提供のみを目的として掲載されており、シスコや他の関係者による推奨や表明を目的としたものではありません。各利用者は、本Webサイトへの掲載により、投稿、リンクその他の方法でアップロードした全ての情報の内容に対して全責任を負い、本Webサイトの利用に関するあらゆる責任からシスコを免責することに同意したものとします。