NativeImage逆向工程
恢复和保护 Java 代码是一个古老且经常讨论的问题。由于用于存储Java类文件的字节码格式,其中包含大量元信息,因此可以很容易地将其恢复为原始代码。为了保护Java代码,业界采取了很多方法,比如混淆、字节码加密、JNI保护等等。不过,不管用什么方法,破解它还是有方法和手段的。
二进制编译一直被认为是一种相对有效的代码保护方法。 Java的二进制编译支持AOT(Ahead of Time)技术,即预编译。
但由于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 来自 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编译
现在使用native-image命令将上面的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,点击Exports查看符号表,可以看到符号svm_code_section,它的地址就是Java Main函数的入口地址。
导航到此地址以查看汇编代码
您可以看到它已经成为一个标准的汇编函数,使用F5进行反编译
可以看到一些函数调用,传递了一些参数,但是不太容易看出逻辑。
当我们双击sub_1000C0020时,我们来看看函数调用的内部。 IDA提示分析失败。
NativeImage的反编译逻辑
因为NativeImage的编译是基于JVM编译的,所以也可以理解为给二进制代码封装了一层VM保护。因此,IDA等工具在缺乏相应信息和针对性处理措施的情况下,无法对其进行很好的逆向工程。
然而,无论何种格式,无论是字节码还是二进制形式,JVM执行的一些基本元素都必然存在,比如类信息、字段信息、函数调用、参数传递等。基于这样的思路,我开发的分析工具可以达到一定程度的修复效果,并且经过进一步改进,有能力达到较高的修复精度。
使用 NativeImageAnalyzer 进行分析
访问https://github.com/vlinx-io/NativeImageAnalyzer下载NativeImageAnalyzer
执行以下命令进行反向分析,目前仅分析主类的主函数
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) {
......
}
在这里我们可以看到在String参数中有一个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
将携带特定字符串内容的字节数组组合在一起,并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
是因为在计算String的长度时,需要根据编码对数组的长度进行转换。可以参考java.lang.String.java中的相关代码。接下来,prepend 函数将 0、1 和空格组合到 tlab0 中,然后从 tlab_0 生成一个新的 String 对象 ret_30 并将其传递给 java.io.PrintStream.write 函数用于打印输出。实际上,这里prepend函数恢复的参数不是很准确,而且位置也不正确。这是以后需要进一步改进的地方。
这两行Java代码转换成实际的执行逻辑后,还是相当复杂的。以后可以在目前恢复的代码的基础上,通过分析整合来简化。
继续向前走
for (int i = 2; i < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
对应的是
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
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程序暴露出更多的信息。我什至见过一些软件以二进制形式发布,没有去掉函数名的符号信息,相当于裸运行。
任何代码都是由逻辑组成的。只要它含有逻辑,就可以通过逆向手段恢复它的逻辑。唯一的区别在于修复的难度。代码保护就是要最大化这种恢复的难度。