NativeImage 逆向工程

Java 代码的恢复和保护是一个由来已久且经常被讨论的问题。由于 Java 类文件采用字节码格式存储,其中包含大量元信息,因此很容易被还原成原始代码。为了保护 Java 代码,业界已经采用了多种方法,例如混淆、字节码加密、JNI 保护等等。然而,无论采用何种方法,仍然存在破解的途径。

二进制编译一直被认为是一种相对有效的代码保护方法。Java 的二进制编译采用 AOT(提前编译)技术,这意味着它是一种预编译技术。

然而,由于Java语言的动态特性,二进制编译需要处理反射、动态代理、JNI加载等问题,这带来了诸多困难。因此,长期以来,Java中一直缺乏成熟、可靠且适应性强的AOT编译工具,无法广泛应用于生产环境。(以前有一个名为Excelsior JET的工具,但现在似乎已经停止开发了。)

2019 年 5 月,Oracle 发布了 GraalVM 19.0,这是一个支持多语言的虚拟机,也是其首个可用于生产环境的版本。GraalVM 提供了一个 NativeImage 工具,可以实现 Java 程序的 AOT 编译。经过数年的发展,NativeImage 如今已非常成熟,SpringBoot 3.0 可以使用它将整个 SpringBoot 项目编译成可执行文件。编译后的文件启动速度快、内存占用低、性能优异。

那么,对于已经进入二进制编译时代的Java程序来说,它们的代码是否仍然像字节码时代那样容易被逆向工程?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

一个简单的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 的安装路径和版本不同,请相应地调整相关路径信息。

使用本地映像编译

现在使用 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

执行此文件,其输出将与之前使用 `.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 这样的工具很难对其进行有效的逆向工程。

然而,无论格式是字节码还是二进制,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)

在我们的 Java 代码中,简单的字符串连接操作实际上被转换成了三个函数调用: StringConcatHelper.mixStringConcatHelper.prepend, 和 StringConcatHelper.newString。 他们之中, StringConcatHelper.mix 计算连接后字符串的长度, StringConcatHelper.prepend 将包含特定字符串内容的 byte[] 数组组合在一起, StringConcatHelper.newString 从 byte[] 数组生成一个新的 String 对象。

在上面的代码中,我们看到了两种类型的变量名, sp_0x18tlab_0以……开头的变量 sp_ 表示在栈上分配的变量,而以……开头的变量则表示在栈上分配的变量。 tlab_ 指示分配在线程局部分配缓冲区上的变量。这只是对这两种变量名称由来的解释。在恢复的代码中,这两种变量类型没有区别。有关线程局部分配缓冲区的信息,请自行查找。

这里我们分配 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数组长度为 1 的原因是 ret_2 << ret_2 >> 32 这是因为在计算字符串长度时,需要根据编码转换数组的长度。您可以参考 java.lang.String.java 中的相关代码。接下来,prepend 函数将 0、1 和空格组合成 tlab0,然后从 tlab0 生成一个新的 String 对象 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 &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 程序暴露出更多信息。我甚至见过一些以二进制形式分发的软件,其函数名符号信息并未被移除,这相当于裸奔。

任何代码都由逻辑构成。只要代码包含逻辑,就可以通过逆向手段还原其逻辑。唯一的区别在于还原的难度。代码保护的目的就是最大限度地增加这种还原的难度。