喜欢jadx的人会常常吐槽JEB反编译器:卖的这么贵,反编译效果还不怎么样。这里我想说的是,JEB毕竟是纯dalvik反编译器,从字节码解析到高级代码生成的整个过程都得从头来过,反编译差点也可以理解( 对于写一款全新的反编译的本人来说深有感触,经典算法和理论也常常有不奏效的时候,因此往往需要改进、优化和扩展,甚至需要提出极端情况下的解决方案,尤其是对抗结构化混淆时)。应该说 JEB 主要槽点是昂贵的费用,其采用的结构化分析技术的确属于比较落后的,但是JEB的功能比jadx更为丰富,更有利于做逆向分析。
我们知道,反编译器是编译器的逆过程,许多反编译器的理论和技术来源于编译理论,这里不细说。反编译器主要包含二进制程序解析、指令(字节码/机器码)解码(句法分析、语义分析)、中间代码生成、控制流图生成、数据流分析、控制流分析、高级代码生成等过程,其中各个阶段都有相关算法来处理。在反编译理论中,控制流分析中最核心的算法是结构化分析算法,结构化算法的优劣决定了反编译器的代码还原能力。而还原循环结构的结构化算法成为反编译实现的一个难点,即便目前的论文已经实现完全无goto的算法,但是这种算法面对复杂混淆后控制结构时还是会出现代码丢失甚至是拒绝工作的情况。
因此,本文简单设计了几种循环类的控制结构来针对性的测试这几款反编译器的还原能力。同时也是为了检验GDA对循环结构的还原能力,发现不足并加以优化。对于反编译结果,我们遵循“语义不变性>代码可读性>代码还原度”的原则。为什么是这个原则,因为语义不变性保证了反编译的代码不会出现程序逻辑上的问题,也就是说,保留程序的等价性,任何输入都能得到同样的输出,出现语义错误在反编译技术领域被视为 不可接受的; 代码可读性需要建立在语义不变性的基础上,更易于人工的阅读和分析; 而代码还原度便是反编译代码与源代码相似程度。
工具下载地址:
GDA
jadx
JEB
第一个测试中,我设计了循环头和锁节点都为二路条件循环结构,为了测试循环结构化分析能力,我多嵌套了几个if语句(代码标号为基本块号)。程序简单如下:
int a = Math.getExponent(88);
int y = 0;
1 while(y>0){
2 if(a<=0){
3 a=a+1;
y=y+1;
}else{
4 if(a>10){
5 if(a>100){
6 a=a*5;
break;
}else{
7 y=y/a;
}
}
}
}
8 this.attachBaseContext(this);
后面两个图是我做的一张粗略的控制流图,通过android sdk将这段代码生成apk文件后,用Jeb、GDA、Jadx来反编译,并进行代码可读性和语义准确性上进行对比。如下图:
通过对比可以看出,Jeb的还原能力是最差的,其代码可读性比较差,且发生了语义错误,甚至在面对此种循环结构时,还出现了块儿的丢失且多了3个continue。此外还可以看出,JEB反编译对于label的处理是在高级代码输出之后处理的,在做goto-label分析时将其去除,所以导致了空行的存在。
GDA看起来是最接近源代码的,且保持了语义的准确性,并且识别出了符号。
Jadx代码可读性更好,同时反编译后语义和源代码保持了一致,并且对级联的if-else语句做了优化,但已不再是源代码的样子。
该测试案例在Test1的基础上仅仅多一条语句,其结果是在循环内的第一个if-else结构之后加了一个后随节点,源代码如下:
int a = Math.getExponent(88);
int y = 0;
1 while(y>0){
2 if(a<=0){
3 a=a+1;
y=y+1;
}else{
4 if(a>10){
5 if(a>100){
6 a = a*5;
break;
}else{
7 y = y/a;
}
}
}
8 y = y*y;
}
9 this.attachBaseContext(this);
编译成为apk后,我们使用JEB、GDA、Jadx来反编译看看效果。
同样GDA几乎做了完美的复原;JEB将其识别为了for类型循环,同样丢失了退出循环的基本块儿(语句a=a*5),导致语义发生错误。Jadx同样也高度的还原了代码,且保持语义的正确性。
接下来我们来看看他们对双层循环的结构化分析的能力。我设计一个双层循环,使内层循环break出外层循环,实际上基本块5不仅会是内存循环的锁节点,也会是外层循环的锁节点。并且该锁节点为二路条件节点,其一个分支路径回到内层循环,另外一个分支结构回到外层循环。一般对循环结构算法都是循环头-锁节点一一对应,因此处理过程中可能会复杂化该类结构。代码实现非常简单如下:
int a=Math.getExponent(88);
int y=0;
1 while(y>0){
2 while(a>0){
3 if(a<=0){
4 a=a+1;
y=y+1;
}else{
5 if(a>10){
6 break;
}
}
}
}
7 this.attachBaseContext(this);
编译成apk后,再反编译后可以看出GDA反编译的代码,外层循环并没识别出来,但是保持了语义的正确性;
JEB虽然识别出来双层循环,内存循环识别成了do-while结构,另外我们还可以看出其糟糕的goto跳转严重的影响了还原代码的可读性,并且发送语义错误。Jadx基本上还原了原始代码,并保持了语义的正确性。
这一段代码我在退出循环的”if(a>10)”语句中内嵌了另外一个if语句,这会导致内层循环的锁节点发生变化,并且给内层循环添加了一个跟随节点,另外代码做了稍稍的改动。当然代码也非常简单,如下图:
int a=Math.getExponent(88);
int y=0;
1 while(y>0){
2 while(a>50){
3 a=a+1;
y=y+1;
4 if(a>10){
5 if(a>100){
6 a=a*5;
break;
}else{
7 y=y/a;
}
}
}
8 this.attachBaseContext(this);
}
同样我们将该段代码编译成apk后,再反编译:
从反编译结果可以看出GDA保持了语义的不变性,并且几乎完全复原了源代码的结构。JEB虽然代码还原的语义没有发生错误,但是代码还原的质量并不是很好,与源代码相差较大。Jadx还原的代码和源代码几乎完全一样。
我继续在上一个例子的基础上增加一些代码来看看反编译的效果。
int a=Math.getExponent(88);
int y=0;
1 while(y>0){
2 y++;
3 while(a>50){
4 a=a+1;
y=y+1;
5 if(a>10){
6 if(a>100){
7 a=a*5;
continue;
}else{
8 y=y/a;
}
}
9 y=a*y;
break;
}
10 this.attachBaseContext(this);
}
这次我在内层循环的第一个if-else结构上添加一个后随节点,并且最后break出内层循环到外层循环。并且将a=a*5语句后的break改成continue。同样编译成apk后再反编译。
从反编译结果上来看,GDA还原代码时,虽然保持了与源码较高相似性,但是却因丢失了a=a*5语句后的continue语句导致语义错误。而JEB还原代码时,虽然保持语义的正确性,但是代码还原度比较低。Jdax和GDA一样都出现了语义错误,并且不仅丢失了continue语句而且还丢失了break语句。
从三款反编译器对循环结构的简单测试中可以看出,Jadx反编译效果最好,利用asm生成class文件后再利用改进的java反编译技术进行反编译,体现了其优势;而对于直接对dalvik字节码进行反编译,另外实现一套反编译引擎的GDA和JEB都会差一点,但是从对循环结构及2路分支结构的恢复上,GDA明显强于JEB,并且对源代码的还原程度也非常高。JEB在测试案例中表现得比较糟糕,甚至在简单情况下就出现了语义错误,这是反编译中无法容忍的,当然本次测试的情况属于极端一点的测试,一般情况下,JEB的循环识别也非常不错。