NativeImage 역공학

자바 코드 복원 및 보호는 오래되고 자주 논의되는 문제입니다. 자바 클래스 파일은 많은 메타 정보를 포함하는 바이트코드 형식으로 저장되기 때문에 원래 코드로 쉽게 복원될 수 있습니다. 자바 코드를 보호하기 위해 업계에서는 난독화, 바이트코드 암호화, JNI 보호 등 다양한 방법을 사용해 왔습니다. 그러나 어떤 방법을 사용하더라도 여전히 해킹할 수 있는 방법이 존재합니다.

바이너리 컴파일은 코드 보호에 비교적 효과적인 방법으로 여겨져 왔습니다. 자바의 바이너리 컴파일은 AOT(Ahead of Time) 기술, 즉 사전 컴파일을 지원합니다.

하지만 자바 언어의 동적인 특성 때문에 바이너리 컴파일 시 리플렉션, 동적 프록시, JNI 로딩 등의 문제를 처리해야 하므로 어려움이 많습니다. 따라서 오랫동안 프로덕션 환경에서 널리 적용할 수 있는 성숙하고 신뢰할 수 있으며 적응성이 뛰어난 자바 AOT 컴파일 도구가 부족했습니다. (과거 Excelsior JET이라는 도구가 있었지만 현재는 개발이 중단된 것으로 보입니다.)

2019년 5월, 오라클은 다국어 지원 가상 머신인 GraalVM 19.0을 출시했는데, 이는 GraalVM의 첫 번째 상용 버전이었습니다. GraalVM은 Java 프로그램의 AOT(자동화된 아웃투스 타임) 컴파일을 지원하는 NativeImage 도구를 제공합니다. 수년간의 개발을 거쳐 NativeImage는 이제 매우 성숙해졌으며, SpringBoot 3.0은 이를 사용하여 전체 SpringBoot 프로젝트를 실행 파일로 컴파일할 수 있습니다. 컴파일된 파일은 빠른 시작 속도, 낮은 메모리 사용량, 그리고 뛰어난 성능을 제공합니다.

그렇다면 바이너리 컴파일 시대에 접어든 자바 프로그램의 코드는 바이트코드 시대처럼 여전히 쉽게 되돌릴 수 있을까요? NativeImage로 컴파일된 바이너리 파일의 특징은 무엇이며, 바이너리 컴파일 강도는 중요한 코드를 보호하기에 충분할까요?

이러한 문제들을 탐구하기 위해, 저희는 최근 NativeImage 분석 도구를 개발했으며, 이를 통해 어느 정도 역효과를 얻었습니다.

프로젝트

https://github.com/vlinx-io/NativeImageAnalyzer

NativeImage 생성 중

먼저 NativeImage를 생성해야 합니다. NativeImage는 GraalVM에서 제공됩니다. GraalVM을 다운로드하려면 다음 링크를 방문하세요. https://www.graalvm.org/ Java 17 버전을 다운로드하세요. 다운로드 후 환경 변수를 설정하십시오. GraalVM에는 JDK가 포함되어 있으므로 이를 사용하여 java 명령을 직접 실행할 수 있습니다.

환경 변수에 $GRAALVM_HOME/bin을 추가한 다음 다음 명령을 실행하여 native-image 도구를 설치하십시오.

gu install native-image

간단한 자바 프로그램

예를 들어 다음과 같은 간단한 자바 프로그램을 작성하세요.

public class Hello {
	public static void main(String[] args){
		System.out.println("Hello World!");
	}
}

위의 자바 프로그램을 컴파일하고 실행하십시오.

javac Hello.java
java -cp . Hello

다음과 같은 출력이 표시됩니다.

Hello World!

컴파일 환경 준비

윈도우 사용자라면 먼저 Visual Studio를 설치해야 합니다. 리눅스 또는 macOS 사용자라면 gcc와 clang 같은 도구를 미리 설치해야 합니다.

Windows 사용자의 경우, native-image 명령을 실행하기 전에 Visual Studio용 환경 변수를 설정해야 합니다. 다음 명령을 사용하여 설정할 수 있습니다.

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

Visual Studio의 설치 경로 및 버전이 다른 경우 관련 경로 정보를 적절히 조정하십시오.

네이티브 이미지를 사용하여 컴파일

이제 `native-image` 명령어를 사용하여 위의 Java 프로그램을 바이너리 파일로 컴파일합니다. `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를 이용한 분석

위 단계에서 컴파일된 hello 파일을 IDA로 열고, "Exports"를 클릭하여 심볼 테이블을 확인하세요. 그러면 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/NativeImageAnalyzer 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 함수인데, 복원된 코드에서는 왜 세 개의 인수가 전달되는 거죠? 첫 번째 writeln 클래스 멤버 메서드로, 하나만 숨깁니다. this해당 변수는 호출자를 가리키며, 이는 전달된 첫 번째 매개변수입니다. java.io.PrintStream@0x554fe8 세 번째 매개변수 rcx의 경우, 어셈블리 코드 분석 과정에서 이 함수가 세 개의 매개변수를 사용하여 호출되는 것으로 판단되었기 때문입니다. 하지만 함수 정의를 살펴보면 실제로는 두 개의 매개변수만 사용하는 것을 알 수 있습니다. 이는 향후 이 도구에서 개선이 필요한 부분입니다.

더 복잡한 프로그램

이제 다음 코드를 사용하여 피보나치 수열을 계산하는 것과 같은 좀 더 복잡한 프로그램을 분석해 보겠습니다.

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)

자바 코드에서 간단한 문자열 연결 작업은 실제로 세 번의 함수 호출로 변환됩니다. StringConcatHelper.mix, StringConcatHelper.prepend, 그리고 StringConcatHelper.newString그중에서, StringConcatHelper.mix 연결된 문자열의 길이를 계산합니다. StringConcatHelper.prepend 특정 문자열 내용을 담고 있는 byte[] 배열을 하나로 결합합니다. StringConcatHelper.newString byte[] 배열에서 새로운 String 객체를 생성합니다.

위 코드에는 두 가지 유형의 변수 이름이 있습니다. sp_0x18 그리고 tlab_0. ~로 시작하는 변수 sp_ 스택에 할당된 변수를 나타내는 반면, 로 시작하는 변수는 tlab_ 스레드 로컬 할당 버퍼에 할당된 변수를 나타냅니다. 이는 단지 이 두 가지 유형의 변수 이름이 생겨난 배경에 대한 설명일 뿐입니다. 복원된 코드에서는 이 두 유형의 변수를 구분하지 않습니다. 스레드 로컬 할당 버퍼와 관련된 정보는 직접 검색해 보시기 바랍니다.

여기서 우리는 배정합니다 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 함수가 복원하는 매개변수의 정확도가 떨어지고 위치도 올바르지 않습니다. 이는 향후 개선이 필요한 부분입니다.

두 줄의 자바 코드를 실제 실행 로직으로 변환한 후에도 여전히 상당히 복잡합니다. 향후 현재 복원된 코드를 기반으로 분석 및 통합을 통해 간소화할 수 있을 것입니다.

계속 앞으로 걸어가세요

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 프로그램보다 더 많은 정보를 드러내기도 합니다. 심지어 함수 이름의 심볼 정보를 제거하지 않고 바이너리 형태로 배포되는 소프트웨어도 본 적이 있는데, 이는 사실상 아무런 제약 없이 실행되는 것과 마찬가지입니다.

모든 코드는 논리로 구성되어 있습니다. 논리가 포함된 코드라면 역추적을 통해 그 논리를 복원할 수 있습니다. 차이점은 복원 난이도에 있습니다. 코드 보호는 이러한 복원을 최대한 어렵게 만드는 데 있습니다.