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/ネイティブイメージアナライザー

NativeImage の生成

まず、NativeImageを生成する必要があります。NativeImageはGraalVMから提供されています。GraalVMをダウンロードするには、 https://www.graalvm.org/ Java 17のバージョンをダウンロードしてください。ダウンロード後、環境変数を設定してください。GraalVMにはJDKが含まれているため、直接javaコマンドを実行できます。

環境変数に$GRAALVM_HOME/binを追加し、次のコマンドを実行してネイティブイメージツールをインストールします。

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ユーザーの場合、native-imageコマンドを実行する前に、Visual Studioの環境変数を設定する必要があります。以下のコマンドで設定できます。

 "C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build\vcvars64.bat"

Visual Studio のインストール パスとバージョンが異なる場合は、関連するパス情報を適宜調整してください。

ネイティブイメージでコンパイルする

上記のJavaプログラムをnative-imageコマンドを使ってバイナリファイルにコンパイルします。native-imageコマンドの形式はjavaコマンドと同じで、-cp、-jarなどのパラメータも使用できます。javaコマンドを使ってプログラムを実行する方法は、バイナリコンパイルと同じで、javaコマンドの部分をnative-imageに置き換えるだけです。コマンドは以下のように実行します。

native-image -cp . Hello

コンパイルに時間がかかるため、CPUとメモリの消費量が増える可能性があります。コンパイル済みのバイナリファイルが生成されます。出力ファイル名はデフォルトでメインクラス名の小文字(この場合は「hello」)になります。Windowsの場合は「hello.exe」になります。「file」コマンドを使用してファイルの種類を確認すると、バイナリファイルであることがわかります。

file hello
hello: Mach-O 64-bit executable x86_64

このファイルを実行すると、その出力は前のuse.java -cpで得られたものと同じになります。Hello結果は一貫しています。

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 のようなツールは、対応する情報や対象とする処理手段がない場合、NativeImage を適切にリバースエンジニアリングすることができません。

しかし、バイトコード形式であろうとバイナリ形式であろうと、JVM実行の基本要素、例えばクラス情報、フィールド情報、関数呼び出し、パラメータ渡しなどは必ず存在します。この考え方に基づき、私が開発した解析ツールは一定の復元効果を得ることができ、さらに改良を加えることで高い復元精度を実現できると考えています。

NativeImageAnalyzerを使用して分析を行う

訪問 https://github.com/vlinx-io/ネイティブイメージアナライザー NativeImageAnalyzerをダウンロードするには

逆解析のために次のコマンドを実行します。現在はメインクラスのMain関数のみ解析しています。

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はこのクラスのインスタンスをHeapと呼ばれる領域に直接コンパイルし、バイナリコードはHeap領域からこのインスタンスを直接取得して呼び出します。復元後の元のコードを見てみましょう。

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) {
		......        
}

ここで、文字列引数があることがわかります。 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_0x18 そして tlab_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 これは、文字列の長さを計算する際に、エンコードに基づいて配列の長さを変換する必要があるためです。java.lang.String.java の関連コードを参照してください。次に、prepend 関数は 0、1、およびスペースを tlab0 に結合し、tlab_0 から新しい文字列オブジェクト 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 &gt;= 3の場合にのみ実行されます。ここで、forループは基本的に同じセマンティクスを持つwhileループに変換されます。whileループの外では、プログラムはcount=3のロジックを実行します。count &lt;= 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プログラムよりも多くの情報を公開することさえあります。関数名のシンボル情報を削除せずにバイナリ形式で配布されているソフトウェアも見たことがあります。これは、裸で実行されているのと同等です。

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