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 関数のエントリ アドレスです。 image-20230218194013099

このアドレスに移動してアセンブリコードを表示します

image-20230218194126014

F5を使用して逆コンパイルすると、それが標準のアセンブリ関数になったことがわかります

image-20230218194235234

いくつかの関数呼び出しが確認でき、いくつかのパラメータが渡されていますが、ロジックを確認するのは簡単ではありません。

sub_1000C0020 をダブルクリックして、関数呼び出しの内部を見てみましょう。 IDA は分析の失敗を促します。

image-20230218194449494

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.mixStringConcatHelper.prependそしてStringConcatHelper.newString。その中で、StringConcatHelper.mix連結された文字列の長さを計算します。StringConcatHelper.prepend特定の文字列コンテンツを一緒に持つbyte[]配列を結合します。StringConcatHelper.newString byte[] 配列から新しい String オブジェクトを生成します。

上記のコードでは、2種類の変数名が見られます。sp_0x18tlab_0。で始まる変数sp_スタックに割り当てられた変数を示し、変数は「」で始まりますtlab_ スレッドローカル割り当てバッファに割り当てられた変数を示します。以上が、これら 2 種類の変数名の由来の説明です。復元されたコードでは、これら 2 種類の変数の間に区別はありません。スレッドローカル割り当てバッファに関する情報については、ご自身で検索してください。

ここでは割り当てますtlab_0Class{[B}_1。の意味Class{[B}_1 byte[] 型のインスタンスです。 [B は byte[] の Java 記述子を表し、_1 はこの型の最初の変数であることを示します。対応する型に対して後続の変数が定義されている場合、インデックスはそれに応じて増加します。Class{[B]}_2Class{[B]}_3など。同じ表現が他の型にも適用されます。Class{java.lang.String}_1Class{java.util.HashMap}_2、 等々。

上記のコードのロジックは、単に byte[] 配列インスタンスを作成し、それを tlab0 に割り当てることを説明しています。配列の長さはret_2 << ret_2 >> 32。配列の長さが次の理由ret_2 << ret_2 >> 32 これは、String の長さを計算するときに、エンコードに基づいて配列の長さを変換する必要があるためです。 java.lang.String.java で関連するコードを参照できます。次に、prepend 関数は 0、1、およびスペースを tlab0 に結合し、tlab_0 から新しい String オブジェクト ret_30 を生成し、出力を印刷するために java.io.PrintStream.write 関数に渡します。実際、ここでは prepend 関数によって復元されたパラメータはあまり正確ではなく、その位置も不正確です。これは今後さらに改善する必要がある領域です。

2 行の Java コードを実際の実行ロジックに変換しても、依然として非常に複雑です。将来的には、現在復元されているコードに基づいて分析および統合することで簡素化することができます。

前に進み続けてください

for (int i = 2; i &lt; count; ++i){
  	n3 = n1 + n2;
  	System.out.print(" " + n3);
  	n1 = n2;
  	n2 = n3;
}
System.out.println();

対応するものは

if(sp_0x44&gt;=3)
{
	ret_7 = java.lang.StringConcatHelper.mix(1, 1)
	tlab_1 = sp_0x18
	tlab_1.length = ret_7&lt;&lt;ret_7&gt;&gt;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&lt;=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&lt;&lt;ret_11&gt;&gt;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 プログラムよりも多くの情報を公開することもあります。関数名のシンボル情報を削除せずにバイナリ形式で配布されているソフトウェアも見たことがありますが、これは裸で実行するのと同じです。

すべてのコードはロジックで構成されています。論理が含まれている限り、逆の手段で論理を復元することが可能です。唯一の違いは復元の難しさです。コード保護は、このような復元の難易度を最大限に高めることです。