NativeImageリバースエンジニアリング
Java コードの復元と保護は古い問題であり、頻繁に議論されます。 Java クラス ファイルの格納にはバイトコード形式が使用されており、多くのメタ情報が含まれているため、元のコードに簡単に復元できます。 Java コードを保護するために、業界は難読化、バイトコード暗号化、JNI 保護などの多くの方法を採用してきました。ただし、使用される方法に関係なく、それを解読する方法と手段はまだあります。
バイナリ コンパイルは、コード保護の比較的効果的な方法として常に考えられてきました。 Java のバイナリ コンパイルは、プリコンパイルを意味する AOT (Ahead of Time) テクノロジとしてサポートされています。
ただし、Java 言語の動的な性質により、バイナリ コンパイルではリフレクション、動的プロキシ、JNI ロードなどの問題を処理する必要があり、多くの困難が生じます。したがって、実稼働環境に広く適用できる、Java での AOT コンパイル用の成熟した、信頼性が高く、適応性のあるツールが長い間不足していました。 (以前はExcelsior JETというツールがありましたが、現在は廃止されているようです。)
2019 年 5 月に、Oracle は、最初の運用対応バージョンである多言語をサポートする仮想マシンである GraalVM 19.0 をリリースしました。 GraalVM は、Java プログラムの AOT コンパイルを実現できる NativeImage ツールを提供します。数年間の開発を経て、NativeImage は現在非常に成熟しており、SpringBoot 3.0 はこれを使用して SpringBoot プロジェクト全体を実行可能ファイルにコンパイルできるようになりました。コンパイルされたファイルは起動速度が速く、メモリ使用量が少なく、優れたパフォーマンスを備えています。
したがって、バイナリコンパイルの時代に入ったJavaプログラムについて、そのコードはバイトコードの時代と同じくらい簡単に逆転可能ですか? NativeImageによってコンパイルされたバイナリファイルの特徴は何ですか?バイナリコンパイルの強度は重要なコードを保護するのに十分ですか?
これらの問題を調査するために、私たちは最近 NativeImage 分析ツールを開発しましたが、これはある程度の逆効果を達成しました。
プロジェクト
https://github.com/vlinx-io/NativeImageAnalyzer
ネイティブイメージの生成
まず、NativeImage を生成する必要があります。 NativeImage は GraalVM から来ています。 GraalVM をダウンロードするには、次の場所にアクセスしてください。https://www.graalvm.org/ Java 17 のバージョンをダウンロードします。ダウンロード後、環境変数を設定します。 GraalVM には JDK が含まれているため、それを直接使用して java コマンドを実行できます。
環境変数に$GRAALVM_HOME/binを追加し、次のコマンドを実行してnative-imageツールをインストールします
gu install native-image
シンプルなJavaプログラム
簡単なJavaプログラムを書いてください、例えば:
public class Hello {
public static void main(String[] args){
System.out.println("Hello World!");
}
}
上記のJavaプログラムをコンパイルして実行します。
javac Hello.java
java -cp . Hello
次の出力が得られます:
Hello World!
コンパイル環境の準備
Windows ユーザーの場合は、まず Visual Studio をインストールする必要があります。 Linux または macOS ユーザーの場合は、gcc や Clang などのツールを事前にインストールする必要があります。
Windows ユーザーの場合、ネイティブ イメージ コマンドを実行する前に、Visual Studio の環境変数を設定する必要があります。次のコマンドを使用して設定できます。
"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build\vcvars64.bat"
Visual Studio のインストール パスとバージョンが異なる場合は、関連するパス情報を適宜調整してください。
ネイティブイメージでコンパイルする
次に、native-image コマンドを使用して、上記の Java プログラムをバイナリ ファイルにコンパイルします。ネイティブ イメージ コマンドの形式は Java コマンド形式と同じで、-cp、-jar パラメータもあります。Java コマンドを使用してプログラムを実行する方法、バイナリ コンパイルに同じ方法を使用します。ネイティブイメージを使用したJavaからのコマンド。次のようにコマンドを実行します
native-image -cp . Hello
一定期間のコンパイルが完了すると、CPU とメモリがさらに消費される可能性があります。コンパイルされたバイナリ ファイルを取得できます。出力ファイル名は、デフォルトでメイン クラス名の小文字 (この場合は「hello」) になります。 Windowsの場合は「hello.exe」となります。 「file」コマンドを使用してこのファイルの種類を確認すると、確かにバイナリ ファイルであることがわかります。
file hello
hello: Mach-O 64-bit executable x86_64
このファイルを実行すると、その出力は前の use.java -cp で取得したものと同じになります。こんにちは、結果は一貫しています
Hello World!
NativeImageの分析
IDAで分析する
IDA を使用して上記の手順でコンパイルされた hello を開き、[エクスポート] をクリックしてシンボル テーブルを表示します。シンボル svm_code_section が表示されます。そのアドレスは Java Main 関数のエントリ アドレスです。
このアドレスに移動してアセンブリコードを表示します
F5を使用して逆コンパイルすると、それが標準のアセンブリ関数になったことがわかります
いくつかの関数呼び出しが確認でき、いくつかのパラメータが渡されていますが、ロジックを確認するのは簡単ではありません。
sub_1000C0020 をダブルクリックして、関数呼び出しの内部を見てみましょう。 IDA は分析の失敗を促します。
NativeImageの逆コンパイルロジック
NativeImage のコンパイルは JVM コンパイルに基づいているため、バイナリ コードを VM 保護層で囲むこととしても理解できます。したがって、IDA のようなツールは、対応する情報や対象を絞った処理手段がないと、リバース エンジニアリングをうまく行うことができません。
ただし、形式に関係なく、バイトコードであるかバイナリ形式であるかに関係なく、クラス情報、フィールド情報、関数呼び出し、パラメーターの受け渡しなど、JVM 実行のいくつかの基本要素が必ず存在します。この考え方に基づき、私が開発した解析ツールは一定の修復効果が得られ、さらに改良を加えることにより高いレベルの修復精度を実現することが可能です。
NativeImageAnalyzerによる分析
訪問https://github.com/vlinx-io/NativeImageAnalyzerNativeImageAnalyzerをダウンロードする
逆解析のために次のコマンドを実行します。現在は、メインクラスのメイン関数のみを分析しています。
native-image-analyzer hello
出力は次のようになります
java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return
もう一度元のコードを見てみましょう。
public static void main(String[] args){
System.out.println("Hello World!");
}
次に、System.out の定義を見てみましょう。
public static final PrintStream out = null;
System クラスの 'out' 変数は PrintStream 型の変数であり、静的変数であることがわかります。コンパイル中、NativeImage はこのクラスのインスタンスをヒープと呼ばれる領域に直接コンパイルし、バイナリ コードは呼び出しのためにヒープ領域からこのインスタンスを直接取得します。復元後の元のコードを見てみましょう。
java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return
これらjava.io.PrintStream@0x554fe8
それはヒープ領域から読み取られるだけですjava.io.PrintStream
インスタンス変数はメモリ アドレス 0x554fe8 にあります。
私たちはもう一度見てみましょうjava.io.PrintStream.writeln
関数の定義
private void writeln(String s) {
......
}
ここでは、String引数があることがわかりますwritelin
関数ですが、復元されたコードではなぜ3つの引数が渡されているのですか?最初のwriteln
は、1つだけを隠すクラスメンバーメソッドですthis
変数は呼び出し元を指し示し、これは渡された最初のパラメータです。java.io.PrintStream@0x554fe8
3 番目のパラメーター rcx については、アセンブリ コードを解析する過程で、この関数が 3 つのパラメーターで呼び出されたことが判明したためです。ただし、定義を調べると、この関数が実際に呼び出すのは 2 つのパラメーターだけであることがわかります。これは、このツールの今後の改善が必要な領域でもあります。
より複雑なプログラム
次に、次のコードでフィボナッチ数列を計算するなど、より複雑なプログラムを分析します
class Fibonacci {
public static void main(String[] args) {
int count = Integer.parseInt(args[0]);
int n1 = 0, n2 = 1, n3;
System.out.print(n1 + " " + n2);
for (int i = 2; i < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
}
}
コンパイルして実行する
javac Fibonacci.java
native-image -cp . Fibonacci
./fibonacci 10
0 1 1 2 3 5 8 13 21 34
NativeImageAnalyzerを使用して復元後のコードは次のようになります
rdi = rdi[0]
ret_0 = java.lang.Integer.parseInt(rdi, 10)
sp_0x44 = ret_0
ret_1 = java.lang.StringConcatHelper.mix(1, 1)
ret_2 = java.lang.StringConcatHelper.mix(ret_1, 0)
sp_0x20 = java.io.PrintStream@0x554fe8
sp_0x18 = Class{[B}_1
tlab_0 = Class{[B}_1
tlab_0.length = ret_2<<ret_2>>32
sp_0x10 = tlab_0
ret_28 = ?java.lang.StringConcatHelper.prepend(tlab_0, " ", ret_2)
ret_29 = java.lang.StringConcatHelper.prepend(ret_28, sp_0x10, 0)
ret_30 = ?java.lang.StringConcatHelper.newString(sp_0x10, ret_29)
java.io.PrintStream.write(sp_0x20, ret_30)
if(sp_0x44>=3)
{
ret_7 = java.lang.StringConcatHelper.mix(1, 1)
tlab_1 = sp_0x18
tlab_1.length = ret_7<<ret_7>>32
sp_0x10 = " "
sp_0x8 = tlab_1
ret_22 = ?java.lang.StringConcatHelper.prepend(tlab_1, " ", ret_7)
ret_23 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_22)
rsi = ret_23
java.io.PrintStream.write(sp_0x20, ret_23)
rdi = 1
rdx = 1
rcx = 3
while(true)
{
if(sp_0x44<=rcx)
{
break
}
else
{
sp_0x34 = rcx
rdi = rdi+rdx
r9 = rdi
sp_0x30 = rdx
sp_0x2c = r9
ret_11 = java.lang.StringConcatHelper.mix(1, r9)
tlab_2 = sp_0x18
tlab_2.length = ret_11<<ret_11>>32
sp_0x8 = tlab_2
ret_17 = ?java.lang.StringConcatHelper.prepend(tlab_2, sp_0x10, ret_11)
ret_18 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_17)
rsi = ret_18
java.io.PrintStream.write(sp_0x20, ret_18)
rcx = sp_0x34+1
rdi = sp_0x30
rdx = sp_0x2c
}
}
}
java.io.PrintStream.newLine(sp_0x20, rsi)
return
復元されたコードを元のコードと比較します。
rdi = rdi[0]
ret_0 = java.lang.Integer.parseInt(rdi, 10)
sp_0x44 = ret_0
対応するものは
int count = Integer.parseInt(args[0]);
rdi は関数の最初の引数を渡すために使用されるレジスタです。Windows の場合、rdi = rdi[0]、これは args[0] に対応します。その後、java.lang.Integer.parseInt を呼び出して解析して取得します。 int 値を指定し、戻り値をスタック変数 sp_0x44 に代入します。
int n1 = 0, n2 = 1, n3;
System.out.print(n1 + " " + n2);
対応する。
ret_1 = java.lang.StringConcatHelper.mix(1, 1)
ret_2 = java.lang.StringConcatHelper.mix(ret_1, 0)
sp_0x20 = java.io.PrintStream@0x554fe8
sp_0x18 = Class{[B}_1
tlab_0 = Class{[B}_1
tlab_0.length = ret_2<<ret_2>>32
sp_0x10 = tlab_0
ret_28 = ?java.lang.StringConcatHelper.prepend(tlab_0, " ", ret_2)
ret_29 = java.lang.StringConcatHelper.prepend(ret_28, sp_0x10, 0)
ret_30 = ?java.lang.StringConcatHelper.newString(sp_0x10, ret_29)
java.io.PrintStream.write(sp_0x20, ret_30)
私たちのJavaコードでは、単純な文字列の連結操作は実際には3つの関数呼び出しに変換されます。StringConcatHelper.mix
、StringConcatHelper.prepend
そしてStringConcatHelper.newString
。その中で、StringConcatHelper.mix
連結された文字列の長さを計算します。StringConcatHelper.prepend
特定の文字列コンテンツを一緒に持つbyte[]配列を結合します。StringConcatHelper.newString
byte[] 配列から新しい String オブジェクトを生成します。
上記のコードでは、2種類の変数名が見られます。sp_0x18
とtlab_0
. で始まる変数 sp_
スタックに割り当てられた変数を示し、変数は「」で始まりますtlab_
スレッドローカルアロケーションバッファに割り当てられた変数を示します。これは、これら2種類の変数名の由来を説明したものです。復元されたコードでは、これら2種類の変数は区別されていません。スレッドローカルアロケーションバッファに関する情報は、ご自身で検索してください。
ここでは割り当てますtlab_0
に Class{[B}_1
の意味 Class{[B}_1
byte[]型のインスタンスです。[Bはbyte[]のJava記述子を表し、_1はこの型の最初の変数であることを示します。対応する型に後続の変数が定義されている場合、インデックスはそれに応じて増加します。例: Class{[B]}_2
、 Class{[B]}_3
など。同じ表現が他の型にも適用されます。Class{java.lang.String}_1
、 Class{java.util.HashMap}_2
、 等々。
上記のコードのロジックは、byte[]配列インスタンスを作成し、それをtlab0に割り当てるという単純なものです。配列の長さは ret_2 << ret_2 >> 32
配列の長さが ret_2 << ret_2 >> 32
これは、文字列の長さを計算する際に、エンコードに基づいて配列の長さを変換する必要があるためです。java.lang.String.java の関連コードを参照してください。次に、prepend 関数は 0、1、およびスペースを tlab0 に結合し、tlab_0 から新しい文字列オブジェクト ret_30 を生成して、java.io.PrintStream.write 関数に渡して出力します。実際には、ここで prepend 関数によって復元されたパラメータは正確ではなく、位置も正しくありません。これは今後さらに改善が必要な領域です。
2 行の Java コードを実際の実行ロジックに変換しても、依然として非常に複雑です。将来的には、現在復元されているコードに基づいて分析および統合することで簡素化することができます。
前進し続ける
for (int i = 2; i < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
対応するものは
if(sp_0x44>=3)
{
ret_7 = java.lang.StringConcatHelper.mix(1, 1)
tlab_1 = sp_0x18
tlab_1.length = ret_7<<ret_7>>32
sp_0x10 = " "
sp_0x8 = tlab_1
ret_22 = ?java.lang.StringConcatHelper.prepend(tlab_1, " ", ret_7)
ret_23 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_22)
rsi = ret_23
java.io.PrintStream.write(sp_0x20, ret_23)
rdi = 1
rdx = 1
rcx = 3
while(true)
{
if(sp_0x44<=rcx)
{
break
}
else
{
sp_0x34 = rcx
rdi = rdi+rdx
r9 = rdi
sp_0x30 = rdx
sp_0x2c = r9
ret_11 = java.lang.StringConcatHelper.mix(1, r9)
tlab_2 = sp_0x18
tlab_2.length = ret_11<<ret_11>>32
sp_0x8 = tlab_2
ret_17 = ?java.lang.StringConcatHelper.prepend(tlab_2, sp_0x10, ret_11)
ret_18 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_17)
rsi = ret_18
java.io.PrintStream.write(sp_0x20, ret_18)
rcx = sp_0x34+1
rdi = sp_0x30
rdx = sp_0x2c
}
}
}
java.io.PrintStream.newLine(sp_0x20, rsi)
return
sp_0x44
プログラムに入力するパラメータはcountです。Javaコードのforループは、count >= 3の場合にのみ実行されます。ここで、forループは基本的に同じセマンティクスを持つwhileループに変換されます。whileループの外では、プログラムはcount=3のロジックを実行します。count <= 3の場合、プログラムは実行を完了し、whileループに再び入ることはありません。これは、GraalVMがコンパイル時に行う最適化によるものかもしれません。
ループの終了条件をもう一度見てみましょう。
if(sp_0x44<=rcx)
{
break
}
これは、
i < count
同時に、rcx も各反復プロセス中に蓄積されます。
sp_0x34 = rcx
rcx = sp_0x34+1
対応する
++i
次に、ループ本体で数値を追加するロジックが復元されたコードにどのように反映されているかを見てみましょう。元のコードは次のとおりです。
for(......){
......
n3 = n1 + n2;
n1 = n2;
n2 = n3;
......
}
修復後のコードは
while(true){
......
rdi = rdi+rdx -> n3 = n1 + n2
r9 = rdi -> r9 = n3
sp_0x30 = rdx -> sp_0x30 = n2
sp_0x2c = r9 -> sp_0x2c = n3
rdi = sp_0x30 -> n1 = sp_0x30 = n2
rdx = sp_0x2c -> n2 = sp_0x2c = n3
......
}
ループ本体の他のコードは、以前と同様に文字列の連結と出力処理を実行します。復元されたコードは基本的に元のコードの実行ロジックを反映しています。
さらなる改善が必要
現在、このツールはプログラム制御フローを部分的に復元し、ある程度のデータフロー解析と関数名の復元が可能です。完全かつ実用的なツールとなるには、以下の点を達成する必要があります。
より正確な関数名、関数パラメータ、関数戻り値の復元
正確なオブジェクト情報とフィールド復元
より正確な表現とオブジェクトタイプの推論
ステートメントの統合と簡素化
バイナリ保護に関する考察
このプロジェクトの目的は、NativeImageのリバースエンジニアリングの実現可能性を探ることです。これまでの成果に基づくと、NativeImageのリバースエンジニアリングは可能ですが、同時にコード保護の課題も増大させます。多くの開発者は、ソフトウェアをバイナリにコンパイルすることでセキュリティを確保できると考え、バイナリコードの保護を軽視しています。C/C++で書かれたソフトウェアの場合、IDAなどの多くのツールは既に優れたリバースエンジニアリング効果を発揮しており、Javaプログラムよりも多くの情報を公開することさえあります。関数名のシンボル情報を削除せずにバイナリ形式で配布されているソフトウェアも目にしたことがあります。これは、裸で実行されているのと同等です。
あらゆるコードは論理で構成されています。論理が含まれている限り、逆の手段でその論理を復元することが可能です。唯一の違いは復元の難しさです。コード保護とは、そのような復元の難しさを最大限に高めることです。