首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

可以肯定地说,任何设计合理的程序都是围绕着数据进行设计的。哪些数据必须由程序来管理呢?在程序中这些数据最准确、高效的表示方法是什么?这些都是有经验的软件设计人员和软件开发人员必须知道的最基本的问题。

对逆向工程而言,数据也是同样重要的。要真正理解一个程序,逆向者必须理解这个程序的数据。只要理解了程序中关键数据结构的总体规划和设计目的,我们就可以相对比较轻松地破译我们感兴趣的特定的代码区域。

本附录讲解了各种各样与程序中底层数据管理相关的主题。我们将从堆栈开始,讨论在程序中是如何使用堆栈的,然后接着讨论程序中使用的几种最基本的数据构造(data constructs。译注:数据构造要比数据结构的含义更广泛。),比如说变量,等等。接下来一节我们讨论数据在内存中的布置,并且描述了(从底层代码的视角来看)数组、链表等常用的数据构造。最后,我将演示类在底层是怎样实现的以及怎样在逆向工程中识别类。

C.1  堆栈

可以说堆栈是一块连续的内存,在系统中运行的例程将它组织成“层状”结构。堆栈中的内存单元在函数的生命周期内可以使用,而当函数返回时,这些内存单元就会被释放(释放后其他函数就可以用了)。

接下来的几小节将展示堆栈是怎样组织的,并讲述各种决定堆栈的基本布局的调用约定。

C.1.1  堆栈帧

堆栈帧指的是在堆栈中为当前正在运行的函数分配的区域(或空间)。传入的参数、返回地址(当这个函数结束后必须跳转到该返回地址。译注:即主调函数的断点处)以及函数所用的内部存储单元(即函数存储在堆栈上的局部变量)都在堆栈帧中。

对函数来说,堆栈帧内部具体的布局是一个非常关键的问题,因为布局会影响到函数访问堆栈中存放的传入参数以及存放其内部数据(例如局部变量)的方式。大多数函数调用代码都是以一段为函数设置堆栈帧的序言(prologue)开始的。设置堆栈帧的目的是:通过将一个指针存放在堆栈中参数区域和局部变量区域之间的那个单元,使得函数可以简便而快捷地访问这些参数和局部变量。这个指针通常存放在一个辅助寄存器中(通常是EBP),而腾出来ESP(ESP是主堆栈指针)来记录当前堆栈位置(即堆栈顶)。当前堆栈位置非常重要,因为这个函数可能还需要调用另外一个函数——这种情况下在ESP指向的当前位置下面(译注:“下面”指的是更低内存地址,而不是图C.1中所示的那种“上下”关系)的区域就要被用来给被调函数创建一个新的堆栈帧了。

图C.1展示了堆栈的总体布局以及堆栈帧是怎样布置的。

C.1.2  ENTER指令和LEAVE指令

ENTER指令和LEAVE指令是由CPU提供的内置工具,用于实现某种类型的堆栈帧。它们使用起来非常简便,只需一步就可以完成在例程中建立堆栈帧的操作。

ENTER指令建立堆栈栈的过程是:将当前EBP寄存器压入堆栈,并使它指向局部变量区的顶部(见图C.1)。ENTER指令还支持嵌套堆栈帧的管理,通常嵌套堆栈是在同一个例程内(当然高级语言也得支持嵌套块才行)。为了实现嵌套,使用ENTER指令代码的必须指明当前嵌套的层数(这使得ENTER的使用与实现具体的例程调用之间关联性较小)。当给出嵌套的层数时,ENTER指令将指向当前每一个活动的堆栈帧的起始位置的指针存储到例程的堆栈帧中。然后,代码就可以使用这些指针来访问其它当前活动的堆栈帧了。

ENTER指令是一条非常复杂的指令,它实际上完成了相当多条指令的工作。在内部,ENTER指令是用相当冗长的一段微码(microcode)实现的,这样会导致一些执行效率的问题。为此,大多数编译器好像都在避免使用ENTER指令,尽管它们都支持C和C++等

语言中使用嵌套代码。这些编译器在为例程安排局部堆栈布局时就简单地忽视代码块的存在,而将所有的局部变量存放在一个独立的内存区域中。

LEAVE是与ENTER指令配合使用的指令。LEAVE指令只是恢复ESP和EBP寄存器之前存储的值。因为LEAVE指令相对比较简单,许多编译器好像常在函数的尾声中使用该指令(尽管在函数的序言中不一定使用ENTER指令)。

C.1.3  调用约定

调用约定(Calling Conventions)定义了程序中调用函数的方式。调用约定之所以与我们这里讨论的堆栈相关,是因为调用约定决定了在函数调用的时候数据(比如说参数)在堆栈中的组织方式。理解调用约定对你来说非常重要,因为在逆向过程中你会不时地遇到函数调用,准确地辨识所用的调用约定是哪一种将有助于你理解你正在解读的程序。

在讨论各个调用约定之前,我们先来讨论一下函数调用要用到的两条基本的指令:CALL指令和RET指令。CALL指令将当前的指令指针(这个指针指向紧接在CALL指令后面的那条指令)压入堆栈,然后执行一条无条件转移指令转移到新的代码地址。

RET是与CALL指令配合使用的指令,在绝大多数函数中它是最后一条指令。RET指令弹出返回地址(就是早些时候CALL指令压入堆栈的地址)并将其加载到EIP寄存器中,然后从这个地址开始继续执行。

接下来的几个小节中我们将讨论几种最常见的调用约定,及其它们在汇编语言中是怎样实现的。

cdecl调用约定

cdecl调用约定是C和C++语言中的标准调用约定。其特点是允许函数接收可变数量的参数。cdecl调用约定可以做到参数数量可变,是因为由主调函数负责在函数调用之后恢复堆栈指针。另外,与其他调用约定相比,cdecl函数是按照相反的顺序接收参数的。第一个参数被首先压入堆栈,最后一个参数最后压入堆栈。识别cdecl调用约定的方法非常简单:如果函数接收了一个或多个参数,并且以一个简单的不带任何操作数的RET指令收尾的话,这个函数很可能是采用cdecl调用约定。

fastcall调用约定

顾名思义,fastcall是一种相对比较高效的调用约定:它使用寄存器来给被调函数传递前两个参数,其余的参数通过堆栈传递给被调函数。最初,fastcall是Microsoft公司专用的调用约定,但是现在大多数主流编译器都支持支持这种调用了,所以你可以在更多的现代程序中碰见它。fastcall调用约定通常使用ECX寄存器和EDX寄存器来分别存放第一个参数和第二个参数。

stdcall调用约定

stdcall调用约定在windows系统中是非常常见的,因为windows系统函数和API都使用这种调用约定。stdcall调用约定在参数传递的方式和顺序上与cdecl调用约定相反。使用stdcall调用约定的函数接收参数的顺序与使用cdecl调用约定的函数的相反,即stdcall中最后一个参数最先压入堆栈。stdcall与cdecl另一个重要的区别在于:在stdcall中被调函数负责清栈,而在cdecl中是由主调函数负责清栈的。stdcall中函数使用RET指令清栈,这是RET指令带有一个操作数,该操作数指明在EIP跳回主要函数之前需要释放的堆栈空间的字节数。这就是说,stdcall调用约定中RET指令带的操作数往往就意味着函数一共传入几个参数。(操作数除以4=参数个数)这是在逆向工程中识别stdcall调用约定的一个重要的特征,并可以据此判断出函数所接收的参数的个数。

C++类成员调用约定(thiscall)

当C++程序中的类方法所接受的参数的个数是固定的时候,Microsoft和Intel编译器会使用这种调用约定。一种快速识别这种调用约定的技巧是:使用这种调用约定的函数指令流将在ecx寄存器中写入一个有效指针,并往堆栈中压入参数,但不使用edx寄存器。原因是每个C++的类方法都必须接收一个类指针(就是this指针),并可能较频繁的使用该指针。编译器则使用这种高效的技巧来传递和存储这个特殊的参数。

对于参数个数不确定的类方法,编译器就将使用cdecl调用约定,并把this指针作为第一个参数首先压入堆栈。

C.2  基本数据构造

下面几节中我们讨论高级语言中最基本的几种数据结构,及其是怎样在汇编语言中实现的。程序中有几种最基本的元素:全局变量、局部变量、常量等。作为一个逆向工程人员,理解这些元素的实现方式的好处在于:这些知识可以大大简化你识别此类结构的过程。

C.2.1  全局变量

在大多数程序中数据的层次都是从一个或多个全局变量开始的。这些全局变量是作为程序数据结构被访问时的一种数据根来使用的。通常找到并标记出全局变量对于理解程序是非常必要的。事实上,我经常在逆向工程的第一步中就要找到这些全局变量。

在大多数情况下,全局变量还是很容易找到的。全局变量通常位于可执行模块的数据段(.data段)的一个固定的地址上,当程序需要访问全局变量时,一般会用一个固定的硬编码的地址,这样我们在进行逆向工程时就可以很容易的识别出全局变量,如下例:

上面这条指令读取全局变量中值,存放到eax寄存器中。硬编码的地址0x00403038使你可以比较容易的识别出这里使用的是一个全局变量。这种硬编码的地址很少在除全局变量外的其他变量上使用。当然,例外的情况还是有的,这些特例我们将在注释“静态变量”和本章的其他一些地方加以讨论。

C.2.2  局部变量

局部变量在程序中被用于存放会被当前函数直接使用的数据。局部变量包含有:计数器、指针以及其他短周期信息。编译器管理局部变量有两种主要的方法:把局部变量放在堆栈中,或者直接把局部变量放在寄存器中,则两种方法我们在下一节中详细讨论。

静态变量

static关键词用于不同类型的对象会产生不同的效果。当static应用于全局变量(在函数之外的全局变量)时,static将该全局变量的作用域限定在其所在源文件。这一信息不会出现在程序的二进制可执行程序中,所以即使源程序中这样使用了static关键词,逆向工程人员也看不到。

当static关键词应用于局部变量时,static关键词会把该局部变量转换成该模块的数据段中的一个全局变量。事实上,当然这个static修饰的变量只在定义它的函数内可见,但是逆向工程人员还是看不到这个变量发生了哪些变化。这一限制是在编译阶段实施的。逆向工程人员要确定一个局部变量是static的唯一的方法是检查该变量是不是只在一个函数中访问了这个变量。通常全局变量很可能(当然不能保证)会被多个函数访问。

存放在堆栈中的局部变量

在大多数情况下,编译器会在函数的堆栈空间中为变量预留空间。这个预留空间就在返回地址和存放EBP寄存器这些单元的下面(或者说前面,即低地址单元)。在大多数堆栈帧中,EBP寄存器指向这个预留空间的末尾,因此,任何需要访问局部变量的代码都必须使用EBP寄存器(将EBP减去一个偏移量来访问相应的局部变量),如下例:

这行代码从“EBP – 4”所指向的单元读取数据,该单元通常是局部变量区域的起始位置。从这条指令我们无法确定这个变量的具体数据类型,但是显然编译器把它当作一个32位的数值来处理的,因为使用了EAX寄存器,而不是AX或者AL这样更小的寄存器类型。需要指出的是,因为这个局部变量实际上是用一个相对于EBP的硬编码偏移地址地址来访问的,所以这个变量和它周围的变量都必须长度固定,而且要能预先知道其长度。

在逆向过程中,映射和命名函数中的局部变量是非常关键的一步。完成这一步后,解读函数的逻辑和控制流的过程就变得简单多了!

改写传入的参数

在程序设计过程中,当需要被调函数能够修改传入的参数,并且修改后的参数由主调函数读取时,程序开发人员会使用传引用的方法将参数传给被调函数,而不是传值。其基

本思想是:主调函数将参数的地址(而不是参数的值)压入堆栈,这样,当被调函数接收到参数时就可以读取其值(通过访问传来参数的内存地址),并在修改后写到指定的内存地址(译注:一般就在参数上进行原地修改)。

这样,逆向工程人员要弄清楚程序中发生了什么就比较容易了。当你发现一个函数在向其堆栈中的参数区域写入数据时,你应该意识到这可能就是用这一内存空间来存放某些特殊的变量,因为函数很少(如果会的话)是通过把返回值写入堆栈中的参数区域来给主调函数返回数据的。

存放在寄存器中的局部变量

出于性能方面的考虑,编译器总是尽量把所有的局部变量存放到寄存器中。寄存器是存放立即数最有效的方式,而且使用寄存器的指令生成的代码执行速度最快、体积最小(生成的代码体积最小是因为大多数指令都有专门针对寄存器访问的短小的预分配编码——即指机器码)。编译器通常都有一个独立的寄存器分配器组件,负责优化生成代码对寄存器的使用。编译器的设计者们通常会花很大的力气优化这种组件,以便尽可能有效地利用这些寄存器,因为这对程序整体的体积和效率都有非常大的影响。

有好几个因素会影响到编译器是否把局部变量放到寄存器中。这其中最重要的因素就是空间问题。在IA-32处理器中总共有8个通用寄存器,其中有2个用于管理堆栈了。剩下的6个通用寄存器通常就要尽可能高效地分配给多个局部变量使用。对于逆向工程人员来说,最重要的一点是要记住大多数变量不是在函数的整个生命周期都会用到的,它们所占的空间是可以再利用的。这可能会让你感到迷惑,因为当一个变量被改写了,你很难确定存放它的寄存器还是表示同一个事物(也就是说刚才所表示的那个旧变量)呢,还是现在已经在表示一个全新的变量了。最后,导致编译器使用内存地址存储局部变量的另一个因素是:当变量的地址是通过&操作符访问的——在这种情况下,编译器就没有办法,只能把这个局部变量存放到堆栈中去。

C.2.3  导入变量

导入变量(Imported Variables)指的是在另一个二进制模块(也就是另一个动态模块或者动态链接库DLL)中存储和维护的全局变量。任何一个二进制模块都可以将全局变量声明为“exported”(这在不同平台上的实现方法也不同),并允许其他二进制程序将这些变量加载到同一个地址空间中进行访问。

REGISTER和VOLATILE关键词

还有一种影响编译器将局部变量分配到寄存器中的因素是C和C++语言中REGISTER和VOLATILE关键词的使用。register关键词告诉编译器该局部变量是要频繁使用的,如果可能的话一定要给它分配一个寄存器。但是现在由于寄存器分配算法的发展,一些编译器已经开始忽略register关键词,而完全依赖于它们的寄存器分配算法。另一方面,volatile关键词告诉编译器其他的软件或者硬件组件可能需要与本程序异步读、写这个变量,因此,该变量必须经常进行更新(这就表明该变量不能缓存在寄存器中)。使用该关键词将迫使编译器为该变量分配一个内存地址。

无论是register关键词还是volatile关键词,都不会在最终的二进制代码中留下任何明显的痕迹,但是有时候volatile关键词的使用还是可以发现的。因为不管当前有多少个寄存器空着没有使用,用volatile关键词定义的局部变量还是要通过直接访问内存的方式进行访问。这在现代编译器生成的代码中是一个很少见的情景。register关键词好像不会在程序的二进制代码中为我们留下任何容易识别出来的线索。

对逆向工程人员来说,导入变量可以提供很多重要的信息,这是因为:最重要的一点是(不同于其他变量)导入变量中都是命了名的,这是为了导出实现变量的导出,导出模块和导入模块必须引用同一个变量名,这对于逆向工程人员来说,极大地提高代码的可读性——他们可以从变量名中推断变量中存放数据一些线索。需要指出的是,在某些情况下导入变量没有名称,因为它们是按顺序导出的(参见第3章)或者是为了阻碍和扰乱逆向者而在创建阶段有意把变量名毁坏了。

识别导入变量通常比较容易,因为访问导入变量时总是通过某种间接的方式(顺便提一下,这也会导致一些代码的运行效率略有降低)。

访问导入变量的低级语言代码序列通常类似下面这样:

实际上,这种代码在逆向过程中是相当常见——这是通过指向另外一个指针的指针来间接读取数据的代码。IATAddress的值暴露了这个导入变量:因为这个指针指向该模块的Import Address Table(到处地址表,IAT),我们可以相当容易地找出这种类型的指令序列。

结论就是:任何双指针间接访问(第一个指针是一个直接指向当前模块的IAT)都应该解释为指向导出变量的引用。

C.2.4  常量

C和C++语言提供了两种使用常量的方法,一种是由编译器的预处理器进行解释,另一种是由编译器的前端在处理其他代码的时候进行解释。

任何用#define指示符定义的常量都会在预处理阶段被其真实的值替换掉。这就是说在代码中使用常量名还是使用其对应的真实的值两种方法是等价的。这种常量最终通常变成了嵌入在代码中的一个立即数。

在C/C++语言中定义常量的另一种方法是定义一个全局变量并在其定义中加一个const关键词(译注:C语言中没有const关键词)。在这样生成的代码中访问常量就像访问普通的全局变量一样。在这种情况下,你可能不能确定你所处理的是不是常量。一些开发工具将这种常量直接放在数据段中(和其余的全局变量放在一起)。const关键词将由编译器在编译阶段进行处理。在这种情况下,你无法判断这个变量是一个常量还是一个不会被修改的全局变量。

其他一些开发工具则会把全局变量分别放在两个不同的段里,一个段是可读可写的,另一个段是只读的。这样,所有的常量都会被放在只读的段中,而你就可以根据这一信息推断出你处理的是常量了。

C.2.5  线程本地存储

对于线程独立并维护每线程数据结构(per-thread data structures)的程序来说,线程本地存储(Thread-Local Storage,TLS)是非常有用的。使用线程本地存储来管理线程专用的数据结构要比通常的全局变量要更加高效。在Windows系统中,程序中实现TLS的方法主要有两种。一种是使用TLS API函数来分配TLS存储空间。TLS API中包含数个函数,比如TlsAlloc、TlsGetValue和TlsSetValue等,它们为程序提供了管理小型的32位的线程本地池的能力。

在Windows程序中实现线程本地存储的另一种方法与第一种方法截然不同,这种方法并不涉及任何API函数的调用。该方法的基本思路是定义一个具有“declspec (thread)”属性的全局变量,这样这个变量就会被放到映像可执行文件的专用线程本地段中。在这种情况下,逆向工程人员在逆向过程中可以比较容易地识别出线程本地变量,因为它指向一个

不同的映像段,这个段不同于可执行文件中用于存放其他全局变量的段。如果需要的话,查验包含变量的段(查看其属性)是否是线程本地存储非常简单(使用PE-dumping工具,如DUMPBIN)。要注意的是,thread属性通常是Microsoft专用编译器的扩展。

C.3  数据结构

数据结构指的是为了满足某一程序的需要而专门在内存中组织的某种数据构造。识别内存中的数据结构通常并不容易,因为你不知道数据这样组织的原则和思想。下面几小节中,我们将讨论最常见的数据结构以及它们在汇编语言中的实现方式。这些常见的数据结构包括通用数据结构、数组、链表和树,等等。

C.3.1  通用数据结构

通用数据结构是指内存中一块连续的空间,用它来表示几种不同类型的数据字段的集合,其中每个字段都位于相对于块起始处距离固定的位置。上面给出了有关数据结构的一个广义的定义,即只要是用C语言中的struct关键词或是用在C++语言中的class关键词定义的都算是通用数据结构。有关通用数据结构,你要记住:它的重要特性是按照编译时的定义静态组织的,而且它占用的内存空间大小也是不变的。当然,我们也可以创建一个最后一个成员是一个变长数组(variable-sized array)的数据结构,这样的结构需要在运行时根据计算出的数据结构的大小动态地分配内存空间。我们很少把这种数据结构放在堆栈中,因为通常堆栈只存放长度固定的元素。

C.3.1.1  对齐

数据结构通常要与处理器的本地字长边界(word-size boundaries)进行对齐(Alignment)。这是因为在大多数系统上访问未经数据对齐的数据会导致系统运行性能下降。有很重要的一点你必须认识到——尽管数据结构中某些成员的长度可能比处理器的本地字长要短,但编译器通常会将它们与处理器的字长对齐。

这里有一个典型例子——Boolean成员在一个32位对齐的数据结构的布置。Boolean成员只需要1位的存储空间,但是大多数编译器会为它分配一个32位的字,这是因为浪费掉31位的存储空间比起让该数据结构中其余的成员不对齐而造成程序在运行性能上的损失来说是微不足道的。你要记住,32位处理器可以直接寻址的最小单元通常是一个字节。而创建了一个1位长的数据结构成员意味着:为了访问这个成员和它后面的每个成员,处

理器不但要执行非对齐的内存访问,还要做大量的移位操作和与操作才能得到正确的成员。这样做只有在特别强调要降低内存消耗的情况下才是值得的。

即使你为你的Boolean成员分配了一个完整的字节(8位),你仍然会因为数据结构中其他成员没有32位对齐而导致严重的性能损失。因此,在逆向的时候,不管是用什么编译器编译的,你会发现你看到的数据结构几乎都是32位对齐的。

C.2  数组

数组(Arrays)是指在内存中连续存放的一连串数据项。在各种数据结构中,数组可能是将一连串数据项存放在内存中的最简单的布局结构,这也许就是在逆向工程中对数组的访问比较容易识别出来的原因吧。从低级语言的角度来看,对数组的访问很容易看出来,因为编译器几乎总是要在数组的基址上加上某个变量(通常是加一个寄存器,有时还要将这个寄存器乘以一个常量)来访问其中的数据项。数组和其他常用的数据结构不容易分辩的情况只有一种,那就是当源代码中通过硬编码的索引来访问数组的时候。这时,你就无法肯定你看到的是一个数组还是通用数据结构,因为这个偏移地址可能是数组的索引,也可能是某个通用数据结构的内部偏移地址。

与通用数据结构不同,编译器通常不对数组实施对齐,数组项在内存中是连续存放的,而且数据项之间没有对齐间隔。这样做主要有两个原因。首先,数组的规模可能会很大,这样的话实现对齐就会浪费大量的内存;其次,对数组项的访问通常是按序进行的(不像访问通用数据结构的成员,通常没有什么明显的顺序),因此,不管数组项的实际长度是多少,编译器都可以生成以适当大小的块来读写数组项的代码。

通用数据类型数组

通用数据类型数组通常是指指针数组、整数数组或者其他单个字长(single-word-sized)数据项的数据。这种数组非常便于管理,因为其索引只要简单地乘以机器字长就可以得到偏移地址了。在32位处理器上,这也就是说索引乘以4,因此当程序要访问一个32位字长的数组时,只要把要访问数据项的索引乘以4再加上数组的起始位置,就可获得了想要访问数据项的内存地址了。

数据结构数组

数据结构数组与常规数组类似(常规数组指的是包含基本的数据类型的数组,如整型数组,等等),只是其数据项的长度可以是任意大小,当然,具体长度取决于这种数据结构的长度。下面是一段常见的数据结构数组的访问代码。

这段代码是从一个循环的中间取出来的。局部变量“ebp - 0x20”看上去有点像循环的计算器。这很明显,因为“ebp - 0x20”被加载到EAX寄存器中,而EAX又被左移了4位(这相当于乘以16,参见附录B)。指针几乎不会以这种方式进行乘法运算——这种方式常见于计算数组索引的代码。要注意的是,在使用现场调试器进行逆向时,你可以简单地通过观察两个本地变量的值来确定这两个局部变量。

乘法操作之后,ECX寄存器从地址“ebp-0x24”处加载了数,这个数好像是数组的基指针。最后,这个指针被加到索引乘以4再加4上。这是一个典型的数据结构数组的代码序列。第一个变量(ECX)是数组的基指针,第二个变量(EAX)是当前字节在数组中的偏移地址。偏移地址是通过用每个数据项的长度乘以当前的逻辑索引得到的,因此你现在可以获知数组中每个数据项的长度是16个字节。最后,程序还要加4是因为要访问该数据结构中具体的成员。在这个范例中,程序访问的是该数据结构的第二个成员。

C.3.3  链表

链表(Linked Lists)是一种广泛使用且便捷的在内存中组织数据的方法。如果要组织一连串需要在不同的位置频繁地进行添加、删除操作的数据项时,程序经常会使用链表。链表的主要缺点是它的数据项不能像数组中那样直接通过索引来访问(尽管公平地说这只会影响那些需要这种直接访问的应用)。另外,链表占用的内存要比实际存放数据项的内存要多,因为它的每个数据项都要存放一到两个指针。

从逆向工程的角度来看,链表与数组最大的不同在于链表中数据项是分散地存放在内存中的,并且每个数据项都包含一个指向下一个数据项的指针,也可能还有一个指向前一个数据项的指针(在双向链表中),而数组中的数据项是连续存放的。下面几小节中我们将来讨论单向链表和双向链表。

单向链表

单向链表是一种简单的数据结构,它由一个“有效载荷(payload)”和一个指向下一数据项的“next”指针组合而成。单向链表的基本思想是:内存中每一个数据项的位置与其在链表中的逻辑顺序无关,因此,当数据项的顺序发生变化或者有数据项被添加进来或者被删除时,不需要对内存中的数据项进行拷贝。图C.2展示了链表在内存中的逻辑组织。

下面的代码展示了在程序中是怎样遍历和访问链表的。

这一段代码是常用的链表迭代循环。在这个例子中,编译器把当前数据项的指针赋给了ESI寄存器——这个变量在源代码中必定叫做pCurrentItem(或者就是这个意思的其他名称)。在一开始,程序把“ebp+0x10”处的值赋给了当前数据项变量(即ESI寄存器)。“ebp+0x10”处的值是传递给当前这个函数的参数——它极有可能是链表的头指针。

循环体中包含了把当前数据项中两个成员的值传给一个函数的代码,为了增加可读性,我把这个函数命名为ProcessItem。要注意的是,上边的代码中对ProcessItem函数的返回值进行了检查,如果返回值为非零的话就跳出循环。

如果你看一下结尾处的代码,你会看到访问当前数据项的“next”成员并用它替换当前数据项的指针的代码。需要注意的是,到下一个数据项的偏移地址是196,这是一个相当大的数,这就说明你正在处理很大的数据项,可能是一个大的数据结构。在读取了“next”指针后,代码将检查它是不是NULL,如果是NULL的话就跳出循环。这很像一个检查pCurrentItem值的while循环。下面是前面这段汇编语言代码对应的最初的源代码。

图C.2  单向链表的逻辑组织(Logical arrangement)及内存组织(In-memory arrangement)

注意一下为什么源代码中使用的是while循环,而上面的汇编语言版本中显然在一开始使用了一个if语句,接着用了一个“do … while()”循环。这里使用了我们在附录A中提到过的典型的循环优化技术。

双向链表

双向链表与单向链表很相似,区别在于双向链表中每个数据项多一个“previous”指针,用于指向链表中前一个数据项。这使得从链表中间删除数据项变得很简单,对单向链表而言这可不是件轻松的操作。双向链表的另一个优点是如果需要的话程序可以从后往前遍历(朝着链表的头遍历)链表。图C.3展示了双向链表的逻辑组织和内存组织。

C.3.4  树

二叉树实际上是链表与数组折衷的产物。树可以像链表那样提供快速添加删除数据项的功能(这在数组中可是既慢又麻烦的工作),而且树中的数据项很容易存取(尽管没有通常的数组那么容易)。

二叉树的实现与链表非常相似,其中的每个数据项都独立地存在自己的内存块中,区别在于二叉树中一个数据项与其他数据项的链接关系是基于它们的值或者索引号建立的(具体是值还是索引,取决于树是怎样根据其所包含的内容组织的)。

二叉树的数据项通常包含两个指针(与双向链表中的“prev”和“next”指针类似):第一个是“left-hand”指针,指向比当前数据项索引更小或者相等的数据项或数据项组。第二个是“right-hand”指针,指向比当前数据项索引更大的数据项或数据项组。当在二叉树中搜索时,程序就通过遍历数据项并从一个结点跳到另一个结点来查找与要找的索引匹配的数据项。这对于在大量的数据项中搜索来说是一种非常高效的方法。图C.4给出了二叉树的内存布局和逻辑组织。

图C.3  双向链表的逻辑组织和内存布局

图C.4  二叉树的逻辑布局与内存布局

C.4  类

“class”实际上是C++语言中的术语(尽管大部分高级面向对象语言都使用了这个术语),用于在面向对象设计中描述“object(对象)”。类这种逻辑构造(logical constructs)综合了数据和操作这些数据的代码。

在面向对象语言中类是非常重要的构造,因为程序中几乎各个方面的工作都是围绕着类开展的。因此,深入理解类是怎样实现的并在逆向过程中掌握各种识别它们的方法是十分重要的。这一节中,我将向大家展示类在汇编语言中的实现的方方面面,包括类的数据成员、代码成员(即方法)、以及虚拟成员函数。

C.4.1  数据成员

一个不带继承的简单类实际上就是带有一些相关联函数的数据结构。这些相关联的函数会被自动设置为接收一个指向该类实例(即该类的对象)的指针(this指针)作为它们的第一个参数的函数(我在前边讨论过,就是通常由ECX寄存器传递的那个this指针)。当程序要访问类的数据成员时,所生成的代码和访问普通的数据结构所用的代码完全相同。因为数据访问是一样的,所以你必须使用成员函数调用来访问成员数据,这样才能把类和普通的数据结构区分开。

C.4.2  继承类中的数据成员

只有开始使用继承后,你才能感受到面向对象程序设计强大的功能。继承使得程序设计人员创建一个拥有多个子类的通用基类,而每个子类都有不同的功能。当实例化一个对象时,实例化代码必须选择要创建哪个类的对象。当编译器遇到这种实例化时,将由编译器确定要初始化的具体数据类型,并生成为对象成员及其父类成员分配内存空间的代码。类在内存中的组织由编译器来安排:基类(继承树上最顶层的父类)的数据成员放在内存中最前面,接着是下一层的父类,依次逐层往下排列。

为了与那些不知道当前初始化的是哪个类的对象(而只知道这个类的某些基类)的代码保证向后兼容性,这样布置是必须的。举个例子来说,当一个函数接收到一个指向某个子类对象的指针,而这个函数只知道这个子类的基类,它就可以假定该对象所占内存的第一个对象是这个基类的实例,从而可以忽略掉其他的子类对象。如果同样是这个函数,它

熟悉这个子类的具体类型,它就知道如何跳过基类(以及其他基类派生出来的类)到达子类对象。编译器会根据该函数所接收的对象类型把所有这些行为都嵌入到机器码中。图C.5描述了继承类的内存布局。

C.4.3  类方法

通常所说的类方法就是简单的函数。因此,一个非虚拟成员函数的调用实际上就是一个把this指针作为第一个参数的直接函数调用。一些编译器,比如Intel公司或者Microsoft公司的编译器通常使用ECX寄存器传递this指针,其他的编译如G++(GCC的C++编译器版本)是通过把this指针压入堆栈中第一个参数的位置来传递的。

图C.5  继承对象在内存中的布局

要确定类方法调用是不是一个普通的、非虚拟的调用,你可以检查函数的地址,如果函数的地址嵌入在代码中说明是非虚拟调用,而如果是通过一个函数表获得的说明不是非虚拟的调用。

C.4.4  虚拟函数

虚拟函数的思想是允许程序在不知道具体的对象类型的情况下使用对象的服务。程序只需要知道整个具体的对象是继承自哪个基类的就可以了。当然,代码所调用的方法必须是在那个基类中定义了的。

通过上一段的描述你应该立即明白,虚拟函数是一个运行时的特征。当某个函数可以接收一个基类指针作为输入参数时,主调函数也可能给这个函数传递指向该基类的继承类的指针。在编译的时候,编译器无法知道会给这个函数传来哪个具体的继承类。因此,编译器必须在对象中加入运行时信息,通过这些信息来确定在发生重载的基类方法调用时应该调用哪个具体的方法。

编译器是通过虚拟函数表来实现虚函数机制的。编译器在编译时将为那些定义了虚拟函数的类以及那些为其他类中定义的虚拟函数提供了重载实现的继承类定义虚拟函数表。虚拟函数表通常放在.rdata段中,.rdata是可执行映像中的一个只读数据段。虚拟函数表中包含了指向在具体的类中所有虚函数实现的硬编码指针。当有函数调用这些虚拟函数时,这些指针将用于在运行时找到正确的函数。

在运行时,编译器在对象的开始处添加一个新的VFTABLE指针,这个指针通常放在第一个数据成员之前。在实例化对象的时候,VFTABLE被初始化(由编译器生成的代码来完成)为指向了正确的虚拟函数表的指针。图C.6展示了虚拟函数在内存中的组织。

C.4.5  识别虚拟函数调用

好了,现在你了解了知道虚函数是怎样实现的了,那么怎样在逆向过程中识别虚拟函数调用呢?实际上很简单——在逆向的时候,虚拟函数调用在代码中很明显。下面这段代码是有关一个不带任何参数的普通虚拟函数的调用。

上面代码中暴露出虚拟函数调用的要素是:对ECX寄存器的使用以及CALL指令没有使用硬编码地址(为了得到要调用函数的地址,它访问了一个数据结构)。这告诉你函数的指针就包含在对象的实例中,也就等于明确告诉你这实际上是一个虚拟函数调用!

我们再来看一个虚拟函数调用的例子。不过这一个调用接收了一些参数。

没有太多需要说的。这一指令序列除了在CALL指令之前将两个参数压入了堆栈以外,和前一段代码是完全一样的。总而言之,识别虚拟函数调用通常比较简单,不过也取决于具体是哪一种编译器生成的代码。一般来说,任何将一个有效指针加载到ECX寄存器中并间接调用一个函数(通过这个指针获得该函数的地址)的函数调用指令序列有可能就是一个C++虚拟成员函数调用。用Microsoft公司和Intel公司的编译器生成的代码就是这样的。

在用其他编译器如G++(这些编译器不用ECX传递this指针)生成的代码中,识别虚拟函数调用要更具有挑战性,因为在这种代码中没有任何明确的性质你可以用来判断函数调用的种类。在这种情况下,如果我们看到函数的指针及其处理的数据处于同一个数据结构中,我们完全可以认为这是个类。得承认,这个方法不能保证正确,不过如果某人使用通用数据结构实现了他(或她)的“类”概念(其中既有数据成员又有函数指针)的话,你也可以把它当作类来对待——从低级语言的角度来看完全是一回事儿。

C.4.6  识别继承对象的构造函数

对于继承对象,它的构造函数(constructors)很有趣,因为它会对虚拟函数表指针进行初始化!如果你看一下两个构造函数(一个是继承类构造函数,另一个是该继承类的基类的构造函数),你会发现两者都会初始化对象的虚拟函数表(尽管对象只存放了一个虚拟函数表指针)。每个构造函数初始化指向它自己的虚拟函数表的虚拟函数表指针。这是因为构造函数不实例化的到底是哪种对象类型——是继承类的对象还是基类的对象。下面是一个简单的继承类构造函数的代码。

注意这个构造函数是怎样调用基类的构造函数的。这就是C++中对象的初始化方法。要调用初始化对象。调用对应类的构造函数来初始化对象,如果这个对象是继承类对象,编译器会把其父类的构造函数加在子类构造函数代码之前。在每一层父类的构造函数中都会执行这一过程,直到调用到基类的构造函数为止。下面是一个基类构造函数例子。

要注意的是:基类的构造函数将虚拟函数指针指向自己的拷贝,然后在返回后马上就被继承类的构造函数替换掉了。还要指出的是这个函数没有调用任何其他的构造函数,因为它是基类的构造函数。如果你跟踪整个类的构造函数调用链(即每个类的构造函数都调用其父类的构造函数),你就会知道这里已经调用基类的构造函数了,因为这个构造函数没有再调用其他任何一个构造函数,它只是初始化了虚拟函数表然后返回的。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论