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

간단한 자바 프로그램

간단한 자바 프로그램을 작성하세요. 예를 들어:

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 버전이 다른 경우 그에 맞게 관련 경로 정보를 조정하시기 바랍니다.

네이티브 이미지로 컴파일

이제 네이티브 이미지 명령을 사용하여 위의 Java 프로그램을 바이너리 파일로 컴파일합니다. Native-image 명령의 형식은 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를 다운로드하려면

역분석을 위해 다음 명령을 실행하십시오. 현재는 주 클래스의 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는 이 클래스의 인스턴스를 힙이라는 영역으로 직접 컴파일하고 바이너리 코드는 호출을 위해 힙 영역에서 이 인스턴스를 직접 검색합니다. 복원 후 원본 코드를 살펴보겠습니다.

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)

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

위의 코드에서는 두 가지 유형의 변수 이름을 볼 수 있습니다.sp_0x18그리고tlab_0. 다음으로 시작하는 변수sp_스택에 할당된 변수를 나타내는 반면, 변수는 ~로 시작합니다.tlab_ 스레드 로컬 할당 버퍼에 할당된 변수를 나타냅니다. 이는 이 두 가지 유형의 변수 이름의 유래에 대한 설명일 뿐입니다. 복원된 코드에서는 이 두 가지 유형의 변수 사이에 차이가 없습니다. Thread Local Allocation Buffers 관련 내용은 직접 검색해 보시기 바랍니다.

여기에 할당합니다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 기능으로 복원된 매개변수는 그다지 정확하지 않으며 해당 위치도 올바르지 않습니다. 추후에 더욱 개선이 필요한 부분입니다.

두 줄의 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 프로그램보다 더 많은 정보를 노출하기도 합니다. 함수 이름의 기호 정보를 제거하지 않고 바이너리 형식으로 배포되는 일부 소프트웨어도 본 적이 있습니다. 이는 알몸으로 실행하는 것과 같습니다.

모든 코드는 로직으로 구성됩니다. 논리가 포함되어 있는 한 역방향 수단을 통해 논리를 복원하는 것이 가능합니다. 유일한 차이점은 복원의 어려움에 있습니다. 코드 보호는 이러한 복원의 어려움을 극대화하는 것입니다.