逆流而上——泛谈对二进制可执行程序的静态反编译

欢迎对本blog相关主题感兴趣的团体或单位转载相关文章,但转载时请注明出处。谢谢! 

 

一、概述

首先应该声明的是,这里讨论的反编译是针对二进制可执行程序进行的静态反向编译操作。虽然对于类似Java Bytecode和MSIL的虚拟机中间代码的反编译与这里讨论的内容有很大相关,但本文还是强调所针对的对象,即编译为本地机器指令码的反编译操作。另外,文中提到的方法主要还是针对过程式语言,比如C和Fortran等,对于面向对象语言编写的程序进行反编译来说,路还有很长。

其实很长一段时间内,有着相当数量的人怀疑反编译的可行性。毕竟编译器在对程序代码进行操作的时候丢弃了大量的源代码信息,例如程序变量名称、高级数据类型声明信息(结构体、数组、类等等)。因此,严格意义上来说,试图从可执行程序逆向恢复出100%的源代码形式是不太可能的。但是反编译的历史几乎和编译理论研究的历史相当,按照澳大利亚昆士兰大学相关描述的说法,第一个反编译器出自1960年。其实从某个角度来讲,当今世界比较前沿的反编译研究更多集中在两个方面:一是对可执行程序进行语义化;一是针对某种相对更高级的语言形式(通常是C语言)进行特性恢复。

二、可执行程序的语义化

首先谈谈语义化操作。我们知道机器在执行程序的时候只能够识别文件中存在的二进制指令,但我们也得承认,对二进制指令进行分析和操作是相当繁琐的。通常的软件逆向分析方法主要基于反汇编操作,即根据体系结构手册中的指令格式描述,将二进制指令一一映射到该体系结构的汇编指令。在某些研究文献中,将这个过程成为Syntax Analysis(这里我翻译为句法分析,因为词法分析似乎并不太妥当)。之后的工作便是由人来理解反汇编得到的结果,从而理解程序的行为特征。这个过程在很多软件加、解密方法,病毒分析和识别等方面有着广泛的应用。但是这种方法依然无法有效的减少工作人员的工作强度。

而所谓的语义化就是根据指令实际操作的行为,将指令中所包含的意义用更加高级的形式表现出来。要实现这种方法,首先需要针对机器的体系结构做一定的形式化(符号化)定义。首先对寄存器进行编号,这样便可以用符号r[num]的形式来表示寄存器,用符号m[addr]来表示内存单元。其中num就是对寄存器的编号,addr表示内存地址,len指实际操作中使用的操作数位序长度。

例如对于Intel x86体系结构来说,如果寄存器ax编号为10,bp编号为15,则可以将指令

mov ax, [bp+4]翻译为:r[10]<16> = m[r[15]<32> + 4]<16>

也许有人会讲,这样翻译的效果还不如直接读汇编指令方便。如果单从这个角度来说,也许他的说法是正确的。那为什么还要做这种操作呢?主要有两个原因:第一、汇编的表示形式通常为:instr oplist,其中instr表示指令名称,oplist表示该指令的操作数列表。(当然这里我们暂且不讨论类似Itanium的谓词控制执行方式)同时也不得不承认,为了提高机器性能,处理器在设计的时候往往提供很多种指令来完成相似的操作。例如ADD指令的一个操作数为立即数1的时候与INC指令的含义是相同的。而这种差异对于我们人类的理解来说只能造成混乱(我这里只是举出一个例子,当然这个例子还是很容易理解的)。通过语义化,便可以讲这两种指令用一种相同的形式表现出来。第二、程序的执行过程往往是一系列指令配合操作的结果。因此对于可执行程序的理解多半也都是对于指令序列的理解。通过语义化的方法可以将汇编指令的语义用基于语法树结构的表达式形式表示出来,例如对于加法的表达式语法树如下:

3 + 5     --------->                    +
                                            /     /
                                          3       5

 这种形式的优点在于对表达式之间的相互替换会变得非常容易,即对其中的一棵子树做替换就可以了。但是基于汇编的方法则只能做指令名的累加,可以想象一下能变成什么样子:

CMP ax, (ADD ax,( MOV cx, ( SUB 1, ( MOV bx, (POP ax ))))), JLE INC ……(这里只是举个例子,是我胡编乱造的^_^)。

另外,基于语法树的表达式还能够针对相关立即数的操作数做静态计算替换,这种技术在编译方面的应用中已经相当成熟了。一句话,通过语义化的方法便可以利用编译相关研究中大多数的研究成果对程序进行分析和化简,从而简化人们对目标程序的理解。包括冗余表达式删除、常量传播和消除、表达式替换规约化简等等……

三、高级结构形式的恢复

这里主要有三个方面:复合型数据类型恢复、高级控制流结构恢复、过程定义恢复

1)复合型数据类型恢复

所谓复合型数据类型就是我们在写程序时可能定义的结构体、数组等等,以及他们之间的复合结果(比如结构变量的数组)。其实这方面的研究往往不是那么容易,因为编译器在编译的过程中已经对各种高级数据结构进行拆分,从而利于使用简单的汇编指令对他们进行表示。就我个人所接触到的相关研究来说,现在这个问题还没有从理论上加以解决,我们能做的就是:能找出来多少就找出来多少!如何找呢?主要还是通过内存分配的信息获取。比如还是对于x86体系结构,我们都知道每个过程在执行的时候系统会为它分配一定的内存栈空间,这些空间分配的大小主要可以通过看一个过程入口处对于栈指针的调整便可以知道。另外,对于通过类似malloc或new来分配的内存空间一般在内存堆上进行分配。全局变量空间则通常另存在一个全局空间。通过统计一些内存引用的规律来尽量达到恢复高级数据类型的目的。当然如果实在恢复不出来,则可以用简单的数据类型(例如int, float, char等)表示。虽然难看了点,但是肯定是不会错的。

2)高级控制结构恢复

高级控制结构通常指我们在编程过程中使用的控制结构,例如if-else、while、switch等等。这种恢复则是通过构建过程的控制流图,并且对其进行分析完成。其中比较难的是循环结构和switch结构。对于循环结构的恢复,目前存在两种比较通用的方法。一是找支配节点和找回边来确定循环体,一是通过一种称为括号(Parenthesis)原理的方法恢复。要解释支配节点,首先需要定义程序指令的位置,我们可以对程序中出现的指令进行编号,然后基于控制流图进行恢复。在控制流图中,每个节点代表一条指令,这样对于i位置的指令和j位置的指令来说,如果从程序的开始节点到j位置指令的每一条执行路径都经过i位置指令节点的话,则称i位置的指令支配j位置的指令。指令支配关系的分析是通过不动点方法,让分析结果达到最小的稳定状态完成,有兴趣的话可以查阅相关文献或编译相关书籍(推荐机械工业出版社出版的《高级编译器设计与实现》)。然后对于指令i和指令j来说,如果存在一条从j到i的边(回边,可以想象为从后往前的边),而且i支配j的话,则认为存在一个循环。括号原理的基本思想是这样的,对于我们平时写算术表达式的时候,括号是成对出现的。例如(((((((((()))))))))),其中每对括号都在表达式中处于一个相对固定的位置。通过这种思想,在对控制流图进行深度优先遍历的时候,根据不同遍历的顺序得到的结果可以发现,一个循环体入口节点的对应括号序号是稳定的。当然这两种方法对于循环嵌套的情况处理都比较棘手,更不要说一些非常不规则的情况了。

下面说说switch结构。编译器在生成对应switch的汇编代码时有两种方法,一种是用等价的if-else结构表示,这种情况没什么说的,通常恢复成if-else;一种是通过跳转表的方法实现。所谓跳转表,其中存放的内容就是switch结构中每个case所对应的指令地址,然后根据前面的计算结果将某一个指令地址放入目标寄存器,最后执行间接跳转。对于平时我们写的程序来说,大多数间接跳转都是因为switch结构产生的。当我们分析代码时遇到一条间接跳转的话,则需要找到目标寄存器中可能存放的内容。Cristina对各种跳转表结构进行分析,总结出四种跳转表结构,然后对于间接跳转的目标寄存器进行切片操作(程序切片是程序理解中经常使用的一种方法之一,其中心思想就是找出和某一个存储单元相关联的程序语句的集合,如果有时间我会再写一篇文章来描述程序切片),在切片中执行表达式替换和传播。最后将计算得到的结果与上面的四种结果做匹配,从而找到跳转表的位置。

3)过程定义恢复

我们写程序很少有不用函数调用的,我们这里的过程就是指函数吧。(其实过程和函数还是有些差别的,一般称有返回值的为函数,没有返回值的为过程。)过程定义恢复主要包括两个方面:参数恢复和返回值恢复。由于可执行程序中没有对过程定义的相关信息做保存,所以只能通过分析的方法得到。需要说明的是,过程名称未必在二进制可执行程序中有所保存,因此常见的方法就是随便给过程取个名字     --_--

参数恢复的主要思想是这样的:对于我们程序中出现的变量来说,一般来自三个地方:过程参数、全局变量和局部变量(类的成员变量?我也不知道该怎么分析)。其中全局变量和局部变量都有一些固定的特征,例如全局变量的内存位置不在本地栈帧中,而局部变量是本过程自己定义,自己赋值,自己使用的。所以分析参数的主要思想就是找出那些没有赋值就使用其值的变量,再排除掉全局变量之后就是参数了。

返回值分析稍微有点麻烦,例如x86处理器将返回值放在eax寄存器中,而eax寄存器又很可能作为临时寄存器使用。比较好的办法就是找出所有变量的定值-引用路径(即从对一个变量的赋值到对这个变量的使用之间的控制流图路径),如果这条路径经过一条过程返回边的话,就可以判定它是一个返回值了。

参数和返回值分析的主要思想基于程序的数据流分析方法,以及对数据流方程的运用。有兴趣的话可以查阅相关书籍或文献。

四、总结

可见,反编译技术在很大程度上与编译技术是密切相关的。当我们对可执行程序完成语义化转换之后便可以有效利用编译理论和相关技术进行分析。当前比较好的一个反编译器当属boomerang了,一个开源的反编译器工具。Boomerang由昆士兰大学的Emmerik Mike等人开发,Mike也借此完成他个人的博士学位研究。当然,反编译器的研究成果还是非常有限的,现在还停留在对一些类似玩具(toy)的程序进行操作。其实我们更应该关注的不是反编译器的实现,而是这种思想的运用。因为很多时候我们无法得到目标软件的源代码,而不得不通过这种方法达到我们的目的(什么?知识产权?……反编译方面的研究确实有可能造成软件版权的侵害,但单从技术的角度来说它是无善恶之分的。比如一把刀,我们可以用它来作为生活中必须的工具,也可以用来伤害别人。所以关键的不是刀,而是用刀的人)。美国威斯康星大学的一些研究团体就在如何对可执行程序进行高级形式分析方面展开广泛的研究,通过构建基础的可执行程序分析框架,可以帮助用户完成程序分析、恶意代码检测、编译器正确性验证等很多对我们生活有利的工作。面对这些,我们国内的研究却是太有限了。

你可能感兴趣的:(逆流而上——泛谈对二进制可执行程序的静态反编译)