逆向分析技术

​ 将可执行程序反汇编,通过分析反汇编代码来理獬其代码功能(例如各接口的数据结构等),然后用高级语言重新描述这段代码,逆向分析原始软件的思路,这个过程就称作逆向工程(ReverseEngineering),有时也简单地称作逆向(Reversing)。这是一项很重要的技能,需要扎实的编程功底和汇编知识。逆向分析的首选工具是IDA,它的插件Hex-Rays Decompiler能完成许多代码反编译工作,在逆向时可以作为一款辅助工具使用。
​ 逆向工程可以让我们了解程序的结构及程序的逻辑,因此,利用逆向工程可以洞察程序的运行过程。一般的所谓“软件破解”只是逆向工程中非常初级的部分.

一、32位软件逆向技术

​ 示例使用的是vc6.0编译的32位程序

  1. 启动函数

​ 在编写Win32应用程序时,都必须在源码里实现一个WinMain函数。但Windows程序的执行并不是从WinMain函数开始的,首先被执行的是启动函数的相关代码,这段代码是由编译器生成的。在启动代码初始化进程完成后,才会调用WinMain函数。

​ 对Visual C++程序来说,它调用的是C/C+运行时启动函数,该函数负责对C/C+运行库进行初始化。Visual C+配有C运行库的源代码,可以在crtlsre\crto.c文件中找到启动函数的源代码(在安装时,Visual C++必须启用安装源代码选项)。用于控制台程序的启动代码存放在crt\src\wincmdln.c中。

​ 所有C/C++程序运行时,启动函数的作用基本相同,包括检索指向新进程的命令行指针、检索指向新进程的环境变量指针、全局变量初始化和内存栈初始化等。当所有的初始化操作完成后,启动函数就会调用应用程序的进人点函数(main和WinMain)。调用WinMain函数的示例如下。

image-20240715114444225

​ 进入点返回时,启动函数便调用C运行库的exit函数,将返回值(nMainRetVal)传递给它,进行一些必要的处理,最后调用系统函数ExitProcess退出。

​ 一个用Visual C+编译的程序,其程序启动代码的汇编代码如下。

image-20240715114659936

image-20240715114743978

image-20240715114913979

​ 开发人员可以修改启动源代码,但这样做会导致即使是同一编译器,生成的启动代码也不同。其他编译器都有相应的启动代码。

  1. 函数

​ 程序都是由具有不同功能的函数组成的,因此在逆向分析中将重点放在函数的识别及参数的传递上是明智的,这样做可以将注意力集中在某一段代码上。函数是一个程序模块,用来实现一个特定的功能。一个函数包括函数名、入口参数、返回值、函数功能等部分。

函数的识别:程序通过调用程序来调用函数,在函数执行后又返回调用程序继续执行。函数如何知道要返回的地址呢?实际上,调用函数的代码中保存了一个返回地址,该地址会与参数一起传递给被调用的函数。有多种方法可以实现这个功能,在绝大多数情况下,编译器都使用call和ret指令来调用函数及返回调用位置。

​ call指令与跳转指令功能类似。不同的是,call指令保存返回信息,即将其之后的指令地址压入栈的顶部,当遇到ret指令时返回这个地址。也就是说,call指令给出的地址就是被调用函数的起始地址。ret指令则用于结束函数的执行(当然,不是所有的ret指令都标志着函数的结束)。通过这一机制可以很容易地把函数调用和其他跳转指令区别开来。
​ 因此,可以通过定位call机器指令或利用ret指令结束的标志来识别函数。call指令的操作数就是所调用函数的首地址。看一个例子,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
int Add(int x, int y);
main( )
{
int a = 5, b = 6;
Add(a, b);
return 0;
}

Add(int x, int y)
{
return(x + y);
}

​ 编译结果如下

image-20240715141146105

​ 这种函数直接调用方式使程序变得很简单一所幸大部分情况都是这样的。但也有例外,程序调用函数是间接调用的,即通过寄存器传递函数地址或动态计算函数地址调用。例如CALL [4*eax+10h]

函数的参数:函数传递参数有3种方式,分别是栈方式、寄存器方式及通过全局变量进行隐含参数传递的方式。如果参数是通过栈传递的,就需要定义参数在栈中的顺序,并约定函数被调用后由谁来平衡栈。如果参数是通过寄存器传递的,就要确定参数存放在哪个寄存器中。每种机制都有其优缺点,且与使用的编译语言有关。

​ (1)利用栈传递参数

​ 栈是一种“后进先出”的存储区,栈顶指针esp指向栈中第1个可用的数据项。在调用函数时,调用者依次把参数压入栈,然后调用函数。函数被调用以后,在栈中取 得数据并进行计算。函数计算结束以后,由调用者或者函数本身修改栈,使栈恢复原样(即平衡栈数据)。
​ 在参数的传递中有两个很重要的问题:当参数个数多于1个时,按照什么顺序把参数压人栈?函数结束后,由谁来平衡栈?这些都必须有约定。这种在程序设计语言 中为了实现函数调用而建立的协议称为调用约定(Calling Convention)。这种协议规定了函数中的参数传送方式、参数是否可变和由谁来处理栈等问题。不同的语言定义 了不同的调用约定,常用的调用约定如下。

image-20240715141839888

​ C规范(即__cdecl)函数的参数按照从右到左的顺序人栈,由调用者负责清除栈。__cdecl是C和C++程序的默认调用约定。C/C+和MFC程序默认使用的调用约定是、 __cdecl,也可以在函数声明时加上__cdecl关键字来手动指定。
​ pascal规范按从左到右的顺序压参数人栈,要求被调用函数负责清除栈。
​ stdcall调用约定是Win32API采用的约定方式,有“标准调用”(Standard CALL)之意,采用C调用约定的入栈顺序和pascal调用约定的调整栈指针方式,即函数入口参数 按从右到左的顺序入栈,并由被调用的函数在返回前清理传送参数的内存栈,函数参数的个数固定。由于函数体本身知道传入的参数个数,被调用的函数可以在返回前 用一条retn指令直接清理传递参数的栈。在Win32API中,也有一些函数是__cdecl调用的,例如wsprintf。

​ 为了了解不同类型约定的处理方式,我们来看一个例子。假设有调用函数test1(Parl,Par2,Par3)按__cdecl、pascal和stdeall的调用约定,其汇编代码如下。

1
2
3
4
5
6
cdecl:
push par3 ;参数从右到左传递
push par2
push par1
call test1
add esp,0c ;平衡栈
1
2
3
4
5
pascal:
push par1 ;参数从左到右传递
push par2
push par3
call test1 ;函数内平衡栈
1
2
3
4
5
stdcall:
push par3 ;参数从右到左传递
push par2
push par1
call test1 ;函数内平衡栈

​ 可以清楚地看到,__cdecl类型和stdcall类型先把右边的参数压人栈,pascal则相反。在栈平衡上,__cdecl类型由调用者用“add esp,0c”指令把12字节的参数空间清 除,pascal和stdcall类型则由子程序负责清除。
​ 函数对参数的存取及局部变量都是通过栈来定义的,非优化编译器用一个专门的寄存器(通常是ebp)对参数进行寻址。C、C+、pascal等高级语言的函数(子程 序)执行过程基本一致,情祝如下。

​ 调用者将函数(子程序)执行完毕时应返回的地址、参数压入栈。

​ 子程序使用“ebp指针+偏移量”对栈中的参数进行寻址并取出,完成操作。
​ 子程序使用ret或retf指令返回。此时,CPU将eip置为栈中保存的地址,并继续执行它。

​ 栈在整个过程中发挥着非常重要的作用。栈是一个先进后出的区域,只有一个出口,即当前栈顶。栈操作的对象只能是双操作数(占4字节)。例如,按stdcall约定 调用函数test2(Parl,Par2)(有2个参数),其汇编代码大致如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
push par2 							;参数2
push par1 ;参数1
call test2 ;调用子程序test2
{
push ebp ;保护现场,原来的ebp指针
mov ebp, esp ;设置新的ebp,使其指向栈顶
mov eax, dword ptr [ebp+0c] ;调用参数2
mov ebx, dword ptr [ebp+08] ;调用参数1
sub esp, 8 ;若函数要使用局部变量,则要再栈中留出一部分空间
......
add esp, 8 ;释放局部变量占用的栈
pop ebp ;恢复现场的ebp
ret 8 ;返回(相当于ret;add esp,8)
;ret后面的值等于参数个数乘4h
}

​ 因为esp是栈指针,所以一般使用ebp来存取栈。其栈建立过程如下。

​ ①此例函数中有2个参数,假设执行函数前栈指针的esp为K。
​ ②根据stdcall调用约定,先将参数Par2压进栈,此时esp为K-04h。
​ ③将参数Par1压人栈,此时esp为K-08h.

​ ④参数入栈后,程序开始执行call指令。cal指令把返回地址压人栈,这时esp为K-0Ch。
​ ⑤现在已经在子程序中了,可以开始使用ebp来存取参数了。但是,为了在返回时恢复ebp的值,需要使用“push ebp”指令来保存它,这时esp为K-10h。
​ ⑥执行“mov ebp,.esp”指令,ebp被用来在栈中寻找调用者压人的参数,这时[ebp+8]就是参数1,[ebp+c]就是参数2。
​ ⑦“sub esp,8”指令表示在栈中定义局部变量。局部变量1和局部变量2对应的地址分别是[ebp-4和[ebp-8]。函数结束时,调用“add esp,8”指令释放局部变量占用 的栈。局部变量的作用域是定义该变量的函数 ,也就是说,当函数调用结束后局部变量便会消失。
​ ⑧调用“ret 8”指令来平衡栈。在ret指令后面加一个操作数,表示在ret指令后给栈指针esp加上操作数,完成同样的功能。
​ 处理完毕,就可以用ebp存取参数和局部变量了,这个过程如图所示。

image-20240715151420270

​ 此外,指令enter和leave可以帮助进行栈的维护。enter语句的作用就是“push ebp”“mov ebp,esp”“sub esp,xxx”,而leave语句则完成“add esp,xxx”“pop ebp”的功能。 所以,上面的程序可以改成如下形式。

1
2
3
4
enter xxxx,0 ;0表示创建xxxx空间来放置局部变量
......
leave ;恢复现场
ret 8 ;返回

​ 在许多时候,编译器会按优化方式来编译程序,栈寻址稍有不同。这时,编译器为了节省ebp寄字器或尽可能减少代码以提高速度,会直接通过esp对参数进行寻 址。esp的值在函数执行期间会发生变化,该变化出现在每次有数据进出栈时。要想确定对哪个变量进行了寻址,就要知道程序当前位置的esp的值,为此必须从函数的 开始部分进行跟踪。
​ 同样,对上例中的test2(Parl,Par2)函数,在VC6.0里将优化选项设置为“Maximize Speed’”。重新编译该函数,其汇编代码可能如下。

1
2
3
4
5
6
7
8
9
push par2 							  ;参数2
push par1 ;参数1
call test2 ;调用子程序test2
{
mov eax, dword ptr [esp+04] ;调用参数1
mov ebx, dword ptr [esp+08] ;调用参数1
......
ret 8 ;返回
}

​ 这时,程序就用esp来传递参数了。其栈建立情况如图所示,过程如下。
​ ①假设执行函数前栈指针esp的值为K。
​ ②根据stdcall调用约定,先将参数Par2压入栈,此时esp为K-04h。
​ ③将Par1压入栈,此时esp为K-08h。
​ ④参数入栈后,程序开始执行call指令。call指令把返回地址压人栈,这时esp为K-OCh。
​ ⑤现在已经在子程序中了,可以使用esp来存取参数了。

image-20240715152157868

​ (2)利用寄存器传递参数

​ 寄存器传递参数的方式没有标准,所有与平台相关的方式都是由编译器开发人员制定的。尽管没有标准,但绝大多数编译器提供商都在不对兼容性进行声明的情况 下遵循相应的规范,即Fastcall规范。Fastcall,顾名思义,特点就是快(因为它是靠寄存器来传递参数的)。

​ 不同编译器实现的Fastcall稍有不同。Microsoft Visual C++编译器在采用Fastcall规范传递参数时,左边的2个不大于4字节(DWORD)的参数分别放在ecx和edx寄存器 中,寄存器用完后就要使用栈,其余参数仍然按从右到左的顺序压入栈,被调用的函数在返回前清理传送参数的栈。浮点值,远指针和int64类型总是通过栈来传递的。 而Borland Delphi/C+编译器在采用Fastcall规范传递参数时,左边的3个不大于4字节(DWORD)的参数分别放在eax、edx和ecx寄存器中,寄存器用完后,其余参数按照从 左至右的PASCAL方式压人栈。

​ 另有一款编译器Watcom C总是通过寄存器来传递参数,它严格为每一个参数分配一个寄存器,默认情况下第1个参数用eax,第2个参数用edx,第3个参数用ebx,第4个 参数用ecx。如果寄存器用完,就会用栈来传递参数。Vatcom C可以由程序员指定任意一个寄存器来传递参数,因此,其参数实际上可能通过任何寄存器进行传递。来看 一个用Microsoft Visual C++6.0编译的Fastcall调用实例,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
int  __fastcall Add(char, long, int, int);

main(void)
{
Add(1, 2, 3, 4);
return 0;
}

int __fastcall Add(char a, long b, int c, int d)

{
return (a + b + c + d);
}

​ 使用Visual C++进行编译,将“Optimizations’”选项设置为“Default’”。编译后查看其反汇编代码,具体如下。

image-20240715160336803

​ Add()函数
image-20240715160347231

​ 另一个调用规范thiscall也用到了寄存器传递参数。thiscall是C++中的非静态类成员函数的默认调用约定,对象的每个函数隐含接收this参数。采用thiscall约定时, 函数的参数按照从右到左的顺序人栈,被调用的函数在返回前清理传送参数的栈,仅通过ecx寄存器传送一个额外的参数——this指针。

​ 定义一个类,并在类中定义一个成员函数,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
class CSum
{
public:
int Add(int a, int b) //实际Add原型具有如下形式:Add(this,int a,int b)
{
return (a + b);
}
};

void main()
{
CSum sum;
sum.Add(1, 2);
}

​ 使用Visual C++进行编译,将“Optimizations”选项设置为“”Default’”。编译后查看其反汇编代码。
image-20240715162537397

image-20240715162640456

​ (3)名称修饰约定

​ 为了允许使用操作符和函数重载,C++编译器往往会按照某种规则改写每一个入口点的符号名,从而允许同一个名字(具有不同的参数类型或者不同的作用域)有 多个用法且不会破坏现有的基于C的链接器。这项技术通常称为名称改编(Name Mangling)或者名称修饰(Name Decoration)。许多C++编译器厂商都制定了自己的名称修 饰方案。
​ 在VC++中,函数修饰名由编译类型(C或C++)、函数名、类名、调用约定、返回类型、参数等因素共同决定。关于名称修饰的内容很多,下面仅简单谈一下常见的C 编译、C++编译函数名的修饰。

​ C编译时函数名修饰约定规则如下。

​ stdcall调用约定在输出函数名前面加一个下画线前缀,在后面加一个“@”符号及其参数的字节数,格式为“functionname(@number”。
​ __cdecl调用约定仅在输出函数名前面加一个下画线前缀,格式为”_functionname”。
​ Fastcall调用约定在输出函数名前面加一个“@”符号,在后面加一个“@”符号及其参数的字节数,格式为“@functionname@number”。

​ 它们均不改变输出函数名中的字符大小写。这和pascall调用约定不同。pascal约定输出的函数名不能有任何修饰且全部为大写。
​ C++编译时函数名修饰约定规则如下。

​ stdcall调用约定以“”标识函数名的开始,后跟函数名;在函数名后面,以“@@YG”标识参数表的开始,后跟参数表;参数表的第1项为该函数的返回值类型,其后依 次为参数的数据类型,指针标识在其所指数据类型前:在参数表后面,以“@Z”标识整个名字的结束(如果该函数没有参数,则以“Z”标识结束)。其格式 为“? functionname@@YC****@Z”或“?functionname@@YG*XZ。
​ __cdecl调用约定规则与上面的stdcall调用约定规则相同,只是参数表的开始标识由“@@YG”变成了“@@YA”。
​ Fastcall调用约定规则与上面的stdcall调用约定规则相同,只是参数表的开始标识由“@@YG”变成了“@@YT”。

函数的返回值:函数被调用执行后,将向调用者返回1个或多个执行结果,称为函数返回值。返回值最常见的形式是return操作符,还有通过参数按传引用方式返回值、通过全局变量返回值等。

​ (1)用return操作符返回值

​ 在一般情况下,函数的返回值放在eax寄存器中返回,如果处理结果的大小超过eax寄存器的容量,其高32位就会放到edx寄存器中,例如下面这段C程序。

1
2
3
4
5
6
MyAdd(int x, int y)
{
int temp;
temp = x + y;
return temp;
}

​ 这是一个普通的函数,它将两个整数相加。这个函数有两个参数,并使用一个局部变量临时保存结果。其汇编实现代码所下。

image-20240715173428513

​ (2)通过参数按传引用方式返回值

​ 给函数传递参数的方式有两种,分别是传值和传引用。进行传值调用时,会建立参数的一份副本,并把它传给调用函数,在调用函数中修改参数值的副本不会影响 原始的变量值。传引用调用允许调用函数修改原始变量的值。调用某个函数,皆把变量的地址传递给函数时,可以在函数中用间接引用运算符修改调用函数内存单元中 该变量的值。例如,在调用函数max时,需要用两个地址(或者两个指向整数的指针)作为参数,函数会将结果较大的数放到参数a所在的内存单元地址中返回,代码如 下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

void max(int *a, int *b);
main( )
{
int a = 5, b = 6;
max(&a, &b);
printf("a、b中较大的数是%d", a); //将最大的数显示出来
return 0;
}

void max( int *a, int *b)
{
if(*a < *b)
*a = *b; //经比较后,将较大的数放到a变量之中
}

​ 其汇编代码如下

image-20240716141604216

  1. 数据结构

​ 数据结构是计算机存储、组织数据的方式。在进行逆向分析时,确定数据结构以后,算法就很容易得到了。有些时候,事情也会反过来,即根据特定算法来判断数据结构。本节将讨论常见的数据结构及它们在汇编语言中的实现方式。

局部变量:局部变量(Local Variables)是函数内部定义的一个变量,其作用域和生命周期局限于所在函数内。使用局部变量使程序模块化封装成为可能。从汇编的角度来看,局部变量分配空间时通常会使用栈和寄存器。

​ (1)利用栈存放局部变量

​ 局部变量在栈中进行分配,函数执行后会释放这些栈,程序用“sub esp,8”语句为局部变量分配空间,用[ehp-xxxx]寻址调用这些变量,而参数调用相对于ebp偏移量 是正的,即[ebp+xxxx],因此在逆向时比较容易区分。编译器在优化模式时,通过esp寄存器直接对局部变量和参数进行寻址。当函数退出时,用“add esp,8”指令平衡栈, 以释放局部变量占用的内存。有些编译器(例如Delphi)通过给esp加一个负值来进行内存的分配。另外,编译器可能会用“push reg’”指令取代“sub esp,4”指令,以节省几 字节的空间。
​ 局部变量分配与清除栈的形式如表所示。

image-20240716144036292

​ 下面这个实例是用“push reg””指令来取代“sub esp,4”指令的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int add(int x, int y);
int main(void)
{
int a = 5, b = 6;
add(a, b);
return 0;
}

int add(int x, int y)
{
int z;
z = x + y;
return(z);
}

​ 用Microsoft Visual C+6.0进行编译,不进行忧化,其汇编代码如下。

image-20240716144329185

image-20240716144347269

​ add函数里不存在“sub esp,n”之类的指令,程序通过“push ecx’”指令来开辟一块栈,然后用[ebp-04]来访问这个局部变量。局部变量的起始值是随机的,是其他函数 执行后留在栈中的垃圾数据,因此需要对其进行初始化。初始化局部变量有两种方法:一种是通过mov指令为变量赋值,例如“mov[ebp-04],5”;另一种是使用push指令直 接将值压人栈,例如“push 5”。

​ (2)利用寄存器存放局部变量

​ 除了栈占用2个寄存器,编译器会利用剩下的6个通用寄存器尽可能有效地存放局部变量,这样可以少产生代码,提高程序的效率。如果寄存器不够用,编译就会将 变量放到栈中。在进行逆向分析时要注意,局部变量的生存周期比较短,必须及时确定当前寄存器的变量是哪个变量。

全局变量:全局变量作用于整个程序,它一直存在,放在全局变量的内存区中。局部变量则存在于函数的栈区中,函数调用结束后便会消失。在大多数程序中,常数一般放在全局变量中,例如一些注册版标记、测试版标记等。在大多数情况下,在汇编代码中识别全局变量比在其他结构中要容易得多。全局变量通常位于数据区块(.data)的一个固定地址处,当程序需要访问全局变量时,一般会用一个固定的硬编码地址直接对内存进行寻址,示例如下。

1
mov eax, dword ptr [40874c0h]

​ 全局变量可以被同一文件中的所有函数修改,如果某个函数改变了全局变量的值,就能影响其他函数(相当于函数间的传递通道),因此,可以利用全局变量来传递参数和函数返回值等。全局变量在程序的整个执行过程中占用内存单元,而不像局部变量那样在需要时才开辟内存单元。

​ 看一个利用全局变量传递参数的实例,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int z;

int add(int x, int y);
int main(void)
{
int a = 5, b = 6;
z = 7;
add(a, b);
return 0;
}

int add(int x, int y)
{
return(x + y + z);
}

​ 用Microsoft Visual C+6.0进行编译,但不进行优化,其汇编代码如下。

image-20240716151649239

image-20240716151700772

​ 用PEID打开编译后的程序,查看区块,区块信息如图所示。全局变量004084C0h在.data区块中,该区块的属性为可读写。

image-20240716153017410

​ 使用这种对内存直接寻址的硬编码方式,比较容易识别出这是一个全局变量。一般编译器会将全局变量放到可读写的区块里(如果放到只读区块里,就是一个常量)。
​ 与全局变量类似的是静态变量,它们都可以按直接方式寻址等。不同的是,静态变量的作用范围是有限的,仅在定义这些变量的函数内有效。

数组:数组是相同数据类型的元素的集合,它们在内存中按顺序连续存放在一起。在汇编状态下访问数组一般是通过基址加变址寻址实现的。
​ 请看下面这个数组访问实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

int main(void)
{
static int a[3] = {0x11, 0x22, 0x33};
int i, s = 0, b[3];
for(i = 0; i < 3; i++)
{
s = s + a[i];
b[i] = s;
}

for(i = 0; i < 3; i++)
{
printf("%d\n", b[i]);
}

return 0;
}

​ 用Microsoft Visual C+6.0进行编译,将优化选项设置为“Maximize Speed”,其汇编代码如下。

image-20240716153737093

​ 在内存中,数组可存在于栈、数据段及动态内存中。本例中的a[ ]数组就保存在数据段.data中,其寻址用“基址+编移量”实现。

1
mov     edi, dword_407030[eax]

​ 这种间接寻址一般出现在给一些数组或结构赋值的情况下,其寻址形式一般是[基址+偏移量]。基址可以是常量,也可以是寄存器,为定值。根据n值的不同,可以对结构中的相应单元赋值。
​ b[ ]数组放在栈中,这些栈在编译时分配。数组在声明时可以直接计算偏移地址,针对数组成员寻址是采用实际的偏移量完成的。

  1. 虚函数

​ C++是一门支持面向对象的语言,为面向对象的软件开发提供了丰富的语言支持。要想高效、正确地使用C++中的继承、多态等语言特性,就必须对这些特性的底层实现有一定的了解。其实,C++的对象模型的核心概念并不多,最重要的概念是虚函数。虚函数是在程序运行时定义的函数。虚函数的地址不能在编译时确定,只能在调用即将进行时确定。所有对虚函数的引用通常都放在一个专用数组——虚函数表(Virtual Table,VTBL)中,数组的每个元素中存放的就是类中虚函数的地址。调用虚函数时,程序先取出虚函数表指针(Virtual Table Pointer,VPTR),得到虚函数表的地址,再根据这个地址到虚函数表中取出该函数的地址,最后调用该函数,整个过程如图所示。VPTR是一个虚函数表指针,所有虚函数的入口都列在虚函数表(VTBL)中。

image-20240716164056624

​ 将实例thiscall.exe的普通成员函数改为虚函数调用,看看VC是如何处理虚函数的,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
class CSum
{
public:
virtual int Add(int a, int b)
{
return (a + b);
}

virtual int Sub(int a, int b )
{
return (a - b);
}

};

void main()
{
CSum *pCSum = new CSum ;

pCSum->Add(1, 2);
pCSum->Sub(1, 2);


}

​ 用Microsoft Visual C+6.0进行编译,将优化选项设置为“Maximize Speed”,其汇编代码如下。

image-20240716164408970

​ 这段代码先调用new函数分配class所需的内存((new函数是由IDA来识别的)。调用成功后,eax保存分配到内存的指针,然后将对象实例指向CSum类虚函数表(VTBL)004050A0h。004050A0h处的数据如图所示。

image-20240716165459471

​ 里面有两组数据

image-20240716175940740

​ 查看这两个指针的内容

​ add()

image-20240716180007092

​ sub()

image-20240716180447954

​ 原来虚函数是通过指向虚函数表的指针间接地加以调用的。程序仍以ecx作为this指针的载体传递给虚成员函数,并利用两次间接寻址得到虚函数的正确地址从而执行,代码如下。

image-20240716180545916

  1. 控制语句

​ 在高级语言中,用IF-THEN-ELSE、SWITCH-CASE等培句来构建程序的判新流程,不仅条理清楚,而且可维护性强。但是,其汇编代码比较复杂,我们会看到cmp等指令后面跟着各类跳转指令,例如jz、jnz。识别关键跳转是软件解密的一项重要技能,许多软件用一个或多个跳转实现了注册或非注册功能。

IF-THEN-ELSE语句

​ 将语句IF-THEN-ELSE编译成汇编代码后,整数用cmp指令进行比较,浮点值用fcom、fcomp等指令进行比较。将语句IF-THEN-ELSE编译后,其汇编代码形式通常如下。

1
2
cmp a,b
jz(jnz) xxx

​ cmp指令不会修改操作数。两个操作数相减的结果会影响处理的几个标志,例如零标志、进位标志、符号标志和溢出标志。jz等指令就是条件跳转指令,根据a、b的值决定跳转方向。实际上,在许多情况下编译器都使用tesl或or之类较短的逻辑指令来替换cmp指令,形式通常为“test eax,,eax”。如果eax的值为0,则其逻辑与运算结果为0,设置ZF为1,否则设置ZF为0。我们来看一个实例,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int a, b = 5;
scanf("%d", &a);
if(a == 0)
a = 8;


return a + b;
}

​ 用Microsoft Visual C+6.0进行编译,将优化选项设置为“Maximize Speed’”,其汇编代码如下

image-20240717101451631

SWITCH-CASE语句
​ SWITCH语句是多分支选择语句。编译后的SWITCH语句,其实质就是多个IF-THEN语句的嵌套组合。编译器会将SWITCH语句编译成一组由不同的关系运算组成的语句。我们来看一个例子,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>

int main(void)
{
int a;
scanf("%d", &a);

switch(a)
{
case 1 :
printf("a=1");
break;
case 2 :
printf("a=2");
break;
case 10:
printf("a=10");
break;
default :
printf("a=default");
break;
}

return 0;
}

​ 用Microsoft Visual C++6.0进行编译,但不进行优化,其反汇编代码如下。

image-20240717102015303

image-20240717102027311

​ 如果编译时设置优化选项为“Maximize Speed”,其汇编代码如下。

image-20240717103859379

​ 编译器在优化时用“dec eax””指令代替cmp指令,使指令更短、执行速度更快。而且,在优化后,编译器会合理排列switch后面的各个case节点,以最优方式找到需要的节点。

​ 如果各case的取值表示一个算术级数,那么编译器会利用一个跳转表(ump Table)来实现,示例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>

int main(void)
{
int a;
scanf("%d", &a);

switch(a)
{
case 1 :
printf("a=1");
break;
case 2 :
printf("a=2");
break;
case 3:
printf("a=3");
break;
case 4:
printf("a=4");
break;
case 5:
printf("a=5");
break;
case 6:
printf("a=6");
break;
case 7:
printf("a=7");
break;

default :
printf("a=default");
break;
}

return 0;
}

​ 由编译器编译后,“jmp dword ptr[4*eax+004010B0]”指令相当于switch(a),根据eax的值进行索引,计算出指向相应case处理代码的指针。其汇编代码如下。

image-20240717111255179

转移指令机器码的计算

​ 在软件分析过程中,经常需要计算转移指令机器码或修改指定的代码。虽然有许多工具可以完成这项工作,但掌握其原理和技巧仍然很有必要。

​ 根据转移距离的不同,转移指令有如下类型。

​ 短转移(Short Jump):无条件转移和条件转移的机器码均为2字节,转移范围是-128~127字节。
​ 长转移(Long Jump):无条件转移的机器码为5字节,条件转移的机器码为6字节。这是因为,条件转移要用2字节表示其转移类型(例如je、jg、js),其他4字节表示转移偏移量,而无条件转移仅用1字节就可表示其转移类型(jmp),其他4字节表示转移偏移量。

​ 子程序调用指令(call):call指令调用有两类。一类调用是我们平时经常接触的,类似于长转移;另一类调用的参数涉及寄存器、栈等值,比较复杂,例如“call dword ptr[eax+2]”。
​ 条件转移指令的转移范围是16位模式遗留下来的。当时,为了使代码紧凑一些,CPU开发人员只给目的地址分配了1字节,这样就将跳转的长度限制在255字节之内。
​ 表中列出了常用的转移指令机器码,通过该表就可根据转移偏移量计算出转移指令的机器码了。

image-20240717115416996

​ 有两个因素可以制约转移指令机器码,一个是表中列出的转移类型,另一个是转移的位移量。

​ (1)短转移指令机器码计算实例
​ 代码段中有一条无条件转移指令,具体如下。

1
2
3
4
.....
401000 jmp 401005
.....
401005 xor eax,eax

​ 无条件短转移的机器码形式为“EBxx”,其中EB00h~EB7Fh是向后转移,EB8Oh~EBFFh是向前转移。该转移指令的机器语言及用位移量来表示转向地址的方法如图所示。

image-20240717150704615

​ 可以看出,位移量为3h,CPU执行”jmp401005”指令后eip的值为00401002h,执行“(EIP)←(EIP)+位移量”指令,就会跳转到00401005h处,即“jmp401005”指令的机器码形是“EB 03” 。也就是说,转移指令的机器码形式是

1
2
位移量 = 目的地址 - 起始地址 - 跳转指令本身的长度
转移指令机器码 = 转移类别机器码 + 位移量

image-20240717150711035

​ (2)长转移指令机器码计算实例

​ 在代码段中有一条无条件转移指令,具体如下。

1
2
3
4
.....
401000 jmp 402398
.....
402398 xor eax,eax

​ 无条件长转移指令的长度是5字节,机器码是“E9”。根据上面的公式,此例中转移的位移量为00402398h-00401000h-5h=00001393h

​ 如图所示,00001393弘在内存中以双字(32位)存储。存储时,低位字节存入低地址,高位字节存人高地址,也就是说,“00001393”以相反的顺序存入,形成了“93130000”的存储形式。

​ 上面两个实例演示了转移指令向后转移(由低地址到高地址)的计算方法,向前转移(由高地址到低地址)的计算方法与此相同。
​ 在代码段中有一条向前转移的无条件转移指令,具体如下。

1
2
3
4
.....
401000 xor eax,eax
.....
402398 jmp 401000

image-20240717152059367

条件设置指令

​ 条件设置指令的形式是“SETcc r/m8”,其中“r/m8”表示8位寄存器或单字节内存单元。

​ 条件设置指令根据处理器定义的16种条件测试一些标志位,把结果记录到目标操作数中。当条件满足时,目标操作数置1,否则置0。这16种条件与条件转移指令jcc中的条件是一样的,如表所示。

image-20240717152300624

​ 条件设置指令可以用来消除程序中的转移指令。在C语言里,经常会见到执行如下功能的语句。

1
c = (a < b)? c1:c2;

​ 如果允许出现条件分支,编译器会产生如下代码或者类似的代码。

1
2
3
4
cmp a,b
mov eax,c1
jl L1
mov eax,c2

循环语句

​ 循环是高级语言中可以进行反向引用的一种语言形式,其他类型的分支语句(例如IF-THEN-EISE等)都是由低地址向高地址区域引用的。通过这一点可以方便地将循环语句识别出来。
​ 如果确定某段代码是循环代码,就可分析其计数器。一般将ecx寄存器作为计数器,也有用其他方法来控制循环的,例如“test eax,eax”指令。一段最简单的循环代码如下。

1
2
3
4
5
xor ecx,ecx
:0044000
inc ecx ;计数
cmp ecx,05 ;循环6次
jbe 0044000 ;重复

​ 再来看一段比较复杂的循环,例如下面这段C程序。

1
2
3
4
5
6
7
8
9
10
int main(void)
{
int sum = 0, i = 0;

for(i = 0; i <= 100; i++)
sum = sum + i;


return 0;
}

​ 用Microsoft Visual C.+6.0进行编译,但不进行优化,其反汇编代码如下。

image-20240717155355514

​ 如果编译时设置优化选项为“Maximize Speed”,看看汇编代码是如何变化的

image-20240717160558920

数学运算符

​ 高级语言中的运算符范围很广,这里只介绍整数的加、减、乘、除运算。如果编译器没有进行优化,则这些运算符很容易理解,下面主要介绍经编译器优化的运
算符。

​ (1)整数的加减法

​ 在一般情况下,整数的加法和减法会分别被编译成add和sub指令。在进行编译优化时,很多人喜欢用lea指令来代替add和sub指令。lea指令允许用户在1个时钟内完成对c=a+b+78h的计算,其中a、b与c都是在有寄存器的情况下才有效的,会被编译成“lea c,[a+b+78]”指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int a, b;
//scanf("%d",&a);
//scanf("%d",&b);

printf("%d", a + b + 0x78);


return 0;
}

​ 用Microsoft Visual C++6.0进行编译,设置优化选项为“Maximize Speed”,反汇编代码如下。

image-20240717162434386

​ 在这段代码中,lea指令是一条纯算术指令,它的实际意义等价于edx=ecx+eax+78h。

​ (2)整数的乘法

​ 乘法运算符一般被编译成mul、imul指令,这些指令的运行速度比较慢。编译器为了提高代码的效率,倾向于使用其他指令来完成同样的计算。如果一个数是2的幂,那么会用左移指令shl来实现乘法运算。另外,加法对于提高3、5、6、7、9等数的乘法运算效率非常有用,示例如下。例如,“eax*5”可以写成“lea eax,[eax+4*eax]”。lea指令可以实现寄存器乘以2、4或8的运算。

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void)
{
int a;

printf("%d %d %d", a * 11 + 4, a * 9, a * 2);


return 0;
}

​ 用Microsoft Visual C++6.0进行编译,设置优化选项为“Maximize Speed”,其反汇编代码如下。

image-20240717162759003

​ (2)整数的除法

​ 除法运算符一般被编译成div、idiv指令。除法运算的代价是相当高的,大概需要比乘法运算多消耗10倍的CPU时钟。

​ 如果被除数是一个未知数,那么编译器会使用div指令,程序的执行效率将会下降。
​ 除数/被除数有一个是常量的情况就复杂很多。编译器将使用一些技巧来更有效地实现除法运算。如果除数是2的幂,那么可以用处理速度较快的移位指令“shr a,n”来替换。移位指令只需花费1个时钟,其中a是被除数,n是基数2的指数。shr指令适合进行无符号数计算。若进行符号数计算,则使用sar指令。当然,也会根据一定的算法,用乘法运算来代替除法运算。
​ 我们来看一个除法运算实例,代码如下。

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void)
{
int a;
scanf("%d", &a);

printf("%d ", a / 11);

return 0;
}

​ 用Microsoft Visual C+6.0进行编译,但不进行优化,其反汇编代码如下。

image-20240717164448183

​ 除法指令需要使用符号扩展指令cdq,其作用是把eax寄存器中的数视为有符号的数,将其符号位(即eax的最高位)扩展到edx寄存器中,即若eax的最高位是1,则执行后edx的每个位都是1h,edx=FFFFFFFFh;若eax的最高位是0,则执行后edx的每个位都是0,edx=00000000h。这样,就把eax中32位带符号的数变成了edx:eax中64位带符号的数,满足了64位运算指令的需要,但转换后的值没有变化。
​ 编译器在优化时,会用乘法运算代替除法运算,这样能提高数倍的效率。不过,对逆向分析来说,这样的代码较难理解。
​ 用于优化的公式比较多,最常用的就是倒数相乘,举例如下。

image-20240717165252186

​ 用Microsoft Visual C+6.0进行编译,设置优化选项为“Maximize Speed”,其反汇编代码如下。

image-20240717165419718

​ 这段代码就是一个简单的除法运算,编译器优化后的代码比一个v指令长,但运行速度提高

了3倍。还有很多除法优化算法,不同编译器采取的方法也有所不同。
文本字符串

​ 字符的识别和分析是软件逆向的一个重要步骤,特别是在序列号分析过程中,经常会遇到各类字符操作。

​ (1)字符串存储格式

​ 在程序中,一般将字符串作为字符数组来处理。但是,不同的编程语言,其字符存储格式是不同的。常见的字符串类型有C字符串、PASCAL字符串等。

​ C字符串:也称“ASCIIZ字符串”,广泛应用于Windows和UNIX操作系统中,“Z”表示其以“\0”为结束标志。“\0”代表ASCII码为0的字符,如图所示。ASCⅡ码为0的字符不是可以显示的字符,而是“空操作符”。

image-20240717170343394

​ DOS字符串:在DOS中,输出行的函数以“$”字符作为终止字符

image-20240717170417827

​ PASCAL字符串:没有终止符,但在字符串的头部定义了1字节,用于指示当前字符串的长度。由于只用了1字节来表示字符串的长度,字符串不能超过255个字符,如图所示。字符串中的每个字符都属于AnsiChar类型(标准字符类型)

image-20240717170508999

​ Delphi字符串:为克服传统PASCAL字符串的局限性,32位Delphi增加了对长字符串的支持。

​ ●双字节Delphi字符串:表示长度的字段扩展为2字节,使字符串的最大长度值达到65535,

image-20240717170600034

​ ●四字节Delphi字符串:表示长度的字段扩展为4字节,使字符串长度达到4GB。目前这种字符类型很少使用。

​ (2)字符寻址指令

​ 80x86系统支持寄存器直接寻址与寄存器间接寻址等模式。与字符指针处理相关的指令有mov、lea等。
​ mov指令将当前指令所在内存复制并放到目的寄存器中,其操作数可以是常量,也可以是指针

1
2
mov eax, [401000h]
mov eax, [ecx]

​ “lea”的意思是“装入有效地址”(Load Effective Address),它的操作数就是地址,所以“lea eax,[addr]”就是将表达式addr的值放入eax寄存器,示例如下。

1
lea eax, [401000h]

​ lea指令右边的操作数表示一个近指针,指令“lea eax,[401000h]”与“mov eax,401000h”是等价的。
​ 在计算索引与常量的和时,编译器一般将指针放在第1个位置,而不考虑它们在程序中的顺序,例如以下初始化代码。

1
2
mov dword ptr [eax+8],67452301
mov dowrd ptr [eax+c],EFCDAB89

​ 编译器不仅广泛地使用lea指令来传递指针,而且经常用lea指令来计算常量的和,其等价于add指令。也就是说,“lea eax,[eax+8]”等价于“add eax,8”。不过,lea指令的效率远高于add指令,这种技巧可以使多个变量的求和在1个指令周期内完成,同时可以通过任何寄存器将结果返回。

​ (3)字母大小写转换

​ 大写字母的ASCII码范围是41h一5Ah,小写字母的ASCⅡ码范围是6Ih~7Ah,它们之间的转换方式就是将原ASCII码的值加/减20h。

​ 如下汇编代码的功能是将小写字母转换成大写字母。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Labe101:
mov al, byte ptr [edx]
cmp al, 61
jb Labe102
cmp al, 7A
ja Labe102
sub al, 20
Labe102:
mov byte ptr [esi] , al
inc edx
inc esi
dec ebx
test ebx,ebx
jnz Labe101

​ 这段代码先用“a”来作比较,如果小于“a”,可能是大写字母或其他字符,再与“z”作比较,如果大于“z”,则不是小写字母,不处理。如果确定是小写字母,则将该字符的ASCII码减20,即可转换成大写字母。
​ 还有一种转换大小写字母的方法。如图所示是大写字母“A”与小写字母“:”的二进制形式。如果第5位是0,则是大写字母;如果第5位是1,则是小写字母。

image-20240717175153232

​ 因此,如下代码也能实现大小写字母的转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
	proc near
lea bx, title+1
mov cx, 31
B20:
mov ah, [bx]
cmp ah, 61h
jb B30
cmp ah, 7Ah
jb B30
and ah, 1101 1111b
mov [bx],ah
B30:
inc bx
loop B20
ret

​ (4)计算字符串的长度

​ 在高级语言里,会有特定的函数来计算字符串的长度,例如C语言中经常用strlen()函数计算字符串的长度。strlen()函数在优化编译模式下的汇编代码如下。

1
2
3
4
5
6
7
mov ecx, FFFFFFFF
sub eax, eax
repnz
scasb
not ecx
dec ecx
je xxxxxx

​ 这段代码使用串扫描指令scasb把AL的内容与edi指向的附加段中的字节逐一比较,把edi指向的字符串长度保存在ecx中。

二,64位软件逆向技术

  1. 寄存器

​ x64是AMD64与Intel64的合称,是指与现有x86兼容的64位CPU。在64位系统中,内存地址为64位。x64位环境下寄存器有比较大的变化,如图所示。

image-20240718095231721

​ x64系统通用寄存器的名称,第1个字母从“E”改为“R”(例如“RAX”),大小扩展到64位,数量增加了8个(R8~R15),扩充了8个128位XMM寄存器(在64位程序中,XMM寄存器经常被用来优化代码)。64位寄存器与x86下的32位寄存器兼容,例如RAX(64位)、EAX(低32)AX(低16位)、AL(低8位)和AH(8~15位)。x64新扩展的寄存器高低位访问,使用WORD、BYTE、DW0RD后缀,例如R8(64位)、R8D(低32位)、R8W(低16位)和R8B(低8位),如图所示。

image-20240718095242960

  1. 函数

​ 在64位Windows操作系统上可以运行32位和64位程序。

​ (1)栈平衡

​ 栈是程序在内存中的一块特殊区域,它的存储特点是先进后出,即先存储进去的数据最后被释放。RSP用来保存当前的栈顶指针,每8字节的栈空间用来保存一个数 据。在汇编指令中,通常使用push和pop来人栈和出栈。栈在内存中的结构如图所示。

image-20240718095438530

​ 栈中存储的数据主要包括局部变量、函数参数、函数返回地址等。每当调用一个函数时,就会根据函数的需要申请相应的栈空间。当函数调用完成时,就需要释放 刚才申请的栈空间,保证栈顶与函数调用前的位置一致。这个释放栈空间的过程称为栈平衡。
​ 为什么需要栈平衡?在程序运行过程中,栈内存空间会被各函数重复利用,如果函数调用只申请栈空间而不释放它,那么随着函数调用次数的增加,栈内存很快就 会耗光,程序会因此无法正常运行。平衡栈的操作,目的是保证函数调用后的栈顶位置和函数调用前的位置一致,这样就可以重复利用栈的内存空间了。过多或者过少 地释放栈空间都会影响其他函数对栈空间数据的操作,进而造成程序错误或者崩溃。需要注意的是,在x64环境下,某些汇编指令对栈顶的对齐值有要求,因此,Visual Studio编译器在申请栈空间时,会尽量保证栈顶地址的对齐值为16(可以被16整除)。如果在逆向过程中发现申请了栈空间却不使用的情况,可能就是为了实现对齐。

​ (2)启动函数

​ 程序在运行时,先执行初始化函数代码,再调用main函数执行用户编写的代码。在上节中已经分析了用VC生成的32位程序启动代码,此处不再重复。下
​ 面通过一个例子来说明如何快速定位64位程序的入口函数(main和WinMain)

​ 如下示例程序调用printf函数输出了一串字符。用Visual C++2010将其编译成x64程序,用IDA Pro打开示例程序,在函数窗口中找到名为“start’”的函数,如图4.17所 示。

1
2
3
4
5
6
7
8
9
10
#include "stdafx.h"

int _tmain(int argc, _TCHAR *argv[])
{

printf("Hello World!");


return 0;
}

​ ida打开,找start函数

image-20240718144647188

​ 根进“jmp__tmainCRTStartup”,持续翻页,就能找到main函数了,代码如下。

image-20240718144818594

​ 也可以直接在图中找到名为“main”的函数,快速定位到main函数内部。

image-20240718145648515

​ 在编译器的项目属性中选择“C/C++”+“所有选项”→“运行库”→“多线程DLL(MD)”选项,IDA就会显示main符号。当运行库设置为“/MT(多线程)”时,IDA不会显示 main符号。在第2种情况下,可以通过代码特征定位main函数,当main函数执行完成时,通常会调用库函数exit退出进程。根据此特征,在入口代码中找到第1处“call cs:exit”代码,该处上面的第1个“call”通常就是main函数(在更高版本的Visual Studio中,可能该处上面的第1个“call”内部的“call”才是main函数)。

​ (3)调用约定

​ x86应用程序的函数调用有stdcall、_edecl、Fastcall等方式,但x64应用程序只有1种寄存器快速调用约定。前4个参数使用寄存器传递,如果参数超过4个,多余的参 数就放在栈里,人栈顺序为从右到左,由函数调用方平衡栈空间。前4个参数存放的寄存器是固定的,分别是第1个参数RCX、第2个参数RDX、第3个参数R8、第4个参数 R9,其他参数从右往左依次人栈。任何大于8字节或者不是1字节、2字节、4字节、8字节的参数必须由引用来传递(地址传递)。所有浮点参数的传递都是使用XMM寄存器 完成的,它们在XMM0、XMM1、XMM2和XMM3中传递,如表所示。

image-20240718152316864

​ 函数的前4个参数虽然使用寄存器来传递,但是栈仍然为这4个参数预留了空间(32字节),为方便描述,这里称之为预留栈空间。在x64环境里,前4个参数使用寄存 器传递,因此在函数内部这4个寄存器就不能使用了,相当于函数少了4个可用的通用寄存器。当函数功能比较复杂时,这可能导致寄存器不够用。为了避免这个问题, 可以使用预留栈空间,方法是函数调用者多申请32字节的栈空间,当函数寄存器不够用时,可以把寄存器的值保存到刚才申请的栈空间中。预留栈空间由函数调用者提 前申请。由函数调用者负责平衡栈空间。

​ 函数调用后,寄存器和内存的情况如图所示。

image-20240718153558509

​ (4)参数传递

​ 下面通过一个实例来分析x64环境下参数的传递。

2个参数的传递

​ 当参数个数小于4时的示例代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "stdafx.h"

int Add(int nNum1, int nNum2)
{
return nNum1 + nNum2;
}


int _tmain(int argc, _TCHAR *argv[])
{
printf("%d\r\n", Add(1, 2));
return 0;
}

​ 用Visual Studio2010进行编译后,用IDA Pro打开示例的Debug版。main函数的代码如下。

image-20240718160923875

​ Add()

image-20240718164142490

​ 在本实例中,两个参数通过寄存器进行传递,第1个参数为ECX、第2个参数为EDX,但在栈中仍为它们预留了4个参数大小的空间,申请了32字节(20h=32d=4个参数 ×8字节)的预留栈空间。
​ 这个例子中使用的是Debug版的汇编代码。当程序被编译成Release版时,函数参数的传递并无本质区别。当开启内联函数扩展编译优化选项时,函数可能会进行内 联扩展优化,编译器会在编译时将可计算结果的变量转换成常量,代码如下。

image-20240718164511982

4个以上参数的传递

​ 再来分析一下参数多于4个时程序是如何传递的,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "stdafx.h"


int Add(int nNum1, int nNum2, int nNum3, int nNum4, int nNum5, int nNum6)
{
return nNum1 + nNum2 + nNum3 + nNum4 + nNum5 + nNum6;
}

int _tmain(int argc, _TCHAR *argv[])
{
printf("%d\r\n", Add(1, 2, 3, 4, 5, 6));
return 0;
}

​ 编译后,打开Debug版程序。main函数的代码如下。

image-20240719093718853

​ add()

image-20240719094023558

​ 从本例中可以看出,如果参数多于4个,前4个参数通过寄存器传递,从第5个参数开始使用栈传递,指令为“mov dword ptr[rsp+20h],5”。由于栈为前4个参数预留了 大小相同的栈空间,申请了32字节(20h=32d=4个参数×8字节)的预留栈空间,第5个参数从栈的[rsp+20h]处开始保存。参数使用的栈空间由函数调用者负责平衡。

参数为结构体

​ 当参数为结构体时,参数的大小就有可能超过8字节。先看一下当参数为结构体,并且结构体大小不超过8字节的时候,参数是如何传递的,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "stdafx.h"


struct tagPoint
{
int x1;
int y1;
};

void fun(tagPoint pt)
{
printf("x=%d y=%d\r\n", pt.x1, pt.y1);
}

int _tmain(int argc, _TCHAR *argv[])
{
tagPoint pt = { 1, 2 };
fun(pt);
return 0;
}

​ 编译后,打开Debug版程序。main函数的汇编代码如下。

image-20240719094348185

​ fun()

image-20240719095509920

​ 如果参数为结构体且结构体小于8字节,在传递结构体参数时,应直接把整个结构体的内容放在寄存器中。在函数里,通过访问寄存器的高32位和低32位来分别访 问结构体的成员。在进行逆向分析时,应根据函数对参数的使用特征来判断函数参数是否为一个结构体类型。
​ 下面看看当结构体大小超过8字节时参数是如何传递的,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "stdafx.h"


struct tagPoint
{
int x1;
int y1;
int x2;
int y2;
};
void fun(tagPoint pt)
{
printf("x1=%d y1=%d x2=%d y2=%d\r\n", pt.x1, pt.y1, pt.x2, pt.y2);
}

int _tmain(int argc, _TCHAR *argv[])
{
tagPoint pt = { 1, 2, 3, 4 };
fun(pt);
return 0;
}

​ 编译后,打开Debug版程序。main函数的汇编代码如下。

image-20240719100555724

image-20240719100615166

​ fun()

image-20240719101044631

​ 通过以上代码可以看出,如果参数是结构体且大于8字节,在传递参数时,会先把结构内容复制到栈空间中,再把结构体地址当成函数的参数来传递(引用传递)。 在函数内部通过“结构体地址+偏移”的方式访问结构体的内容。

thiscall传递

​ 在VC++环境下,还有一种特殊的调用约定,叫作thiscall。它是C++类的成员函数调用约定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "stdafx.h"


class CAdd
{
public:
int Add(int nNum1, int nNum2)
{
return nNum1 + nNum2;
}
};

int _tmain(int argc, _TCHAR *argv[])
{
CAdd Object;
printf("%d\r\n", Object.Add(1, 2));
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719102836270

​ add()

image-20240719103043350

​ 通过这个实例我们可以知道,类的成员函数调用、参数传递方式与普通函数没有很大的区别。唯一的区别是,成员函数调用会隐含地传递一个this指针参数。

​ (5)函数返回值

​ 在64位环境下,使用RAX寄存器来保存函数返回值。返回值类型由浮点类型使用MMX0寄存器返回。RAX寄存器可以保存8字节的数据。当返回值大于8字节时,可 以将栈空间的地址作为参数间接访问,进而达到目的。

  1. 数据结构

​ x64程序的数据结构和x86类似,主要是对局部变量、全局变量、数组等的识别。

​ (1)局部变量

​ 局部变量是函数内部定义的变量,其存放的内存区域为栈区,其生命周期为进入函数时分配、函数返回时释放。下面通过一个例子来看看应用程序是如何分配和释 放局部变量空间,以及如何访问局部变量的,代码如下。

1
2
3
4
5
6
7
8
9
10
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
int nNum1 = argc;
int nNum2 = 2;
printf("%d\r\n", nNum1 + nNum2);
return 0;
}

​ 编译后,用DA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719103752739

​ 函数在入口处申请了预留栈空间和局部变量空间,指令为“sub rsp,30h”。其中,从rsp+0h到rsp+20h为32字节预留栈空间,从rsp+20h到rsp+30h为局部变量空间。 也就是说,预留栈空间在低地址,局部变量空间在高地址。当应用程序编译为Release版时,因为程序访问寄存器比访问内存时有更高的性能,所以编译器会尽可能使用 寄存器来存放局部变量,当寄存器不够用时才把局部变量存放在栈空间中。

​ (2)全局变量

​ 全局变量的地址在编译期就会固定下来,因为一般会用固定的地址去访问全局变量。下面通过一个例子来看看如何访问全局变量。

1
2
3
4
5
6
7
8
9
10
#include "stdafx.h"


int g_nNum1;
int g_nNum2;
int _tmain(int argc, _TCHAR *argv[])
{
printf("%d\r\n", g_nNum1 + g_nNum2);
return 0;
}

​ 编泽后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719105026165

​ 全局变量的地址也是先定义的在低地址,后定义的在高地址。根据此特征可以还原全局变量在源代码中的定义顺序。

​ (3)数组

​ 数组是相同数据类型的集合,以线性方式连续存储在内存中。数组中的数据在内存中的存储是线性连续的,数组中的数据是从低地址到高地址顺序排列的。

1
int ary[4]={1,2,3,4}

​ 此数组中有4个类型为it的集合,其占用内存大小为:

1
sizeof(类型)*个数

​ 此数组大小为:

1
sizeof(int)*4

​ 因此,此数组占用的内存空间为16字节。假设ary数组的首地址为0x1000,那么数组元素ary[0]的地址为0x1000,数组元素ary[1]的地址为0x1004,数组元素ary[2]的地址 为0x1008,数组元素ary[3]的地址为0x100C。

数组寻址公式

​ 编译器在访问数组元素时,要先定位数组元素的地址,再访问数组元素的内容。编译器采用数组寻址公式定位一个数组元素的地址。因此,掌握数组寻址公式,在 进行软件逆向分析时可以快速识别编译器访问的是数组哪个元素。先来看一维数组的寻址公式,具体如下。

1
数组元素的地址=数组首地址+sizeof(数组类型)×下标

​ 多维数组也可以看成一个一维数组。例如,有一个二维数组int ary[2][3],可以将其看成一个一 维数组,其数组元素类型为一维数组。假设数组的首地址为0x1000,现 在想访问ary[1][2],下标1访问第1个一维数组,下标2访问第2个一维数组。根据一维数组寻址公式0x1000+sizeof(int[3])×1=0x1000+0xC=0x100C,得到第1个一维数组的数 组元素。因为数组元素的类型为一维数组,所以需要再次寻址。通过计算,0x100C+sizeof(int)×2=0x100C+0x8=0x1014,因此ary[1][2]的数组元素的地址为0x1014。把两 个一维数组的寻址公式加起来就是二维寻址公式,具体如下。

1
数组元素的地址=数组首地址+sizeof(一维数组类型)×下标1+sizeof(数组类型)×下标2

一维数组

​ 下面通过一个例子来看看编译器是如何访问一维数组元素的,代码如下。

1
2
3
4
5
6
7
8
9
10
11
#include "stdafx.h"


int g_ary[4] = { 4, 5, 6, 7 };
int _tmain(int argc, _TCHAR *argv[])
{
int ary[4] = { 1, 2, 3, 4 };
printf("%d %d\r\n", ary[2], ary[argc]);
printf("%d %d\r\n", g_ary[3], g_ary[argc]);
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719110453781

image-20240719110516690

​ 从本例中可以看出,编译器访问数组的代码就是利用数组寻址公式去访问的。当访问的数组下标为常量时,编译器会根据数组一维寻址公式直接计算出数组相对于 数组首地址的偏移。例如,[g_ary+3*4]会直接被优化成【g_ary+12]。如果数组下标未知(下标通常是变量),就会用一维数组寻址公式去定位数组元素。

​ 访问二维数组时使用的也是数组寻址公式。数组的特征总结如下。

    [数组首地址+n]
    [数组首地址+寄存器×n]
  1. 控制语句

​ (1)if

​ if语句是分支结构的重要组成部分。if语句的功能是对表达式的结果进行判定,根据表达式结果的真假跳转到对应的语句块执行。其中,“真”表示表达式结果非 0,“假”表示表达式结果为0,示例如下。因为逻辑问题,编译器生成的汇编代码会对表达式的结果进行取反操作。

1
2
3
4
5
6
7
#include "stdafx.h"
int _tmain(int argc, _TCHAR *argv[])
{
if (argc > 1)
printf("argc > 1\r\n");
return 0;
}

image-20240719142305964

     特征识别:首先会有一个jxx指令用于向下跳转,且跳转的目的近end中没有jmp指令。根据以上特征,把jxx指令取反后,即可还原if语句的代码,如图所示。

image-20240719143636466

​ 图形识别:在逆向分析工具中,为了方便地表示跳转的位置,使用虚线箭头表示条件跳转jxx,使用实线箭头表示无条件跳转jmp。if语句中有一个jxx跳转,因此会有 一个向下的虚线箭头,看到此图形即可判断其为f语句,虚线箭头之间的代码为if代码。IDA中的if语句图形如图所示。

image-20240719143906057

​ (2)if……else 语句

​ if….eles语句比f语句多出了一个“else”,当if表达式结果为真时跳过else分支语句块,当if表达式结果为假时跳转到else分支语句块中,示例如下。

1
2
3
4
5
6
7
8
9
10
11
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
if (argc == 1)
printf("argc == 1\r\n");
else
printf("argc != 1\r\n");
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719152431961

​ 特征识别:首先会有一个jxx指令用于向下跳转,且跳转的目的else中有jmp指令。else代码的结尾没有jmp指令,else的代码也会执行if_else_end的代码。根据以上特 征,把jxx指令取反后,即可还原if…else语句的代码,如图所示。

image-20240719152536016

​ 图形识别:因为f语句中有一个jxx指令用于向下跳转,所以会有一个向下的虚线箭头;又因为else语句中有jmp跳转,所以虚线箭头中会有一个向下的实线箭头。看 到此图形即可判断其为if…..else语句,虚线箭头之间的代码为f代码,实线箭头之间的代码为else代码。

image-20240719152700102

​ (3)if…else if…else语句

​ 在if….else语句的“else”之后再嵌套if语句,就形成了一个多分支结构,示例如下。

1
2
3
4
5
6
7
8
9
10
11
#include "stdafx.h"
int _tmain(int argc, _TCHAR *argv[])
{
if (argc > 2)
printf("argc > 2\r\n");
else if (argc == 2)
printf("argc == 2\r\n");
else
printf("argc <= 1\r\n");
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719153050349

image-20240719153107909

​ 特征识别:首先会有一个jxx指令用于向下跳转,且跳转的目的else if中有jmp指令。else if的跳转目的else中有jmp指令,且else代码的结尾没有jmp指令,所有jmp的 目标地址一致。根据以上特征,把jxx指令取反,即可还原if…else if…else语句的代码,如图所示。

image-20240719153244088

​ 图形识别:因为if语句中有一个jxx指令用于向下跳转,所以会有一个向下的虚线箭头;又因为else_if中有jm即跳转,所以虚线箭头中会有一个向下的实线箭头。在 else if代码中有个jxx跳转和一个jmp即跳转,因此有一个虚线箭头和一个实线箭头,它们相互交叉。看到此图形即可判断其为f…else if…else语句,第1个虚线箭头之间的 代码为if代码,第2个虚线箭头之间的代码为else if代码,最后一个实线箭头之间的代码为else代码。如果第2个虚线箭头和最后一个实线箭头的跳转目标地址一致,就是 没有else,那么该语句就是一个if….else if控制语句。可以将不同的控制语句相互嵌套,并使用不同的工具来观察图形的样式。

image-20240719153415917

​ (4)switch-case语句

​ switch是比较常用的多分支结构。switch语句通常比if语句有更高的效率。编译器有多种优化方案,在进行逆向分析时要注意识别。当switch分支数小于6时会直接用 if…else语句来实现,当switch分支数大于等于6时编译会进行优化,示例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
switch (argc)
{
case 1:
printf("argc == 1");
break;
case 2:
printf("argc == 2");
break;
case 3:
printf("argc == 3");
break;
case 6:
printf("argc == 6");
break;
case 7:
printf("argc == 7");
break;
case 8:
printf("argc == 8");
break;
}
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719153755402

image-20240719153809596

image-20240719153826234

​ case表的代码如下

image-20240719154025871

​ 当case≥6,且case值的间隔比较小时,编译器会采用case表的方式实现siwtch语句。这是编译器优化siwtch语句的一种方法,其优化原则就是避免使用if语句。编译 器实现的思路是先把所有要跳转的case位置偏移放在一个一维数组的表中(这个表叫作case表),然后把case的值当成数组下标进行跳转,这样就可以避免使用if语句, 从而提高性能了。

​ case表的结构体如表所示。

image-20240719154147957

​ 例如,switche(argc)只需要把argc-l当成case表的数组下标,得出偏移,直接跳转过去。为什么要把argc-1当成数组下标呢?直接把argc当成数组下标不行吗?看看 如下switch代码。

1
2
3
4
switch (argc) {
case 100: printf("argc == 100"); break;
case 101: printf("argc == 200"); break;
}

​ 如果把上面的代码做成case表,数组的项数是101项,而实际上只用了2项,其他项中填写的是switch结束地址偏移,这非常浪费内存空间。因此,将argc的值减I00, 再做一个switch表,只要2项就够了。

​ 当case项较多时,编译器直接用if语句来实现switch语句。为了减少if语句的判断次数,采用了另一种优化方案一判定树。将每个case值作为一个节点,从这些节点 中找到一个中间值作为根节点,形成一棵二叉平衡树,以每个节点为判定值,大于和小于关系分别对应于左右子树,从而提高效率,减少if语句的判断次数,如图所示。

image-20240719161328917

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
switch (argc)
{
case 1:
printf("argc == 1");
break;
case 3:
printf("argc == 3");
break;
case 5:
printf("argc == 5");
break;
case 10:
printf("argc == 10");
break;
case 35:
printf("argc == 35");
break;
case 50:
printf("argc == 50");
break;
case 300:
printf("argc == 300");
break;
}
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719161612125

image-20240719161642978

image-20240719161652781

​ (5)转移指令机器码的计算

call/jmp direct

​ 机器码的计算仍与x86应用程序类似,示例如下。

1
00000001400018c1 E9 D2 00 00 00  jmp   140001998
1
2
	位移量=目的地址-起始地址-跳转指令长度=140001998h-1400018C1h-5h=D2h
转移指令机器码=转移类别机器码+位移量="E9"+"D2 00 00 00"="E9 D2 00 00 00"

call/jmp memory direct

​ 这种方式在x86和x64下稍有不同。在32位系统里,代码如下。

1
004014F6 FF15 3C414200   CALL DWORD PTR DS:[42413C]

​ “FF15 3C414200”这行指令用于调用某地址,其中“42413C”为绝对地址。x64应用程序使用相同的指令,但解析方法不同。

​ 在64位系统里,指令地址由原来的4字节变为8字节。若x64也采用与x86相同的方式,FF15后跟着绝对地址,指令的长度就会增加。为了解决这个问题,在x64系统 中,指令后面仍然是4字节指令,只不过该地址为“相对地址”,示例如下。

1
00000001400018CB FF 15 B7 9A 00 00    CALL QWORD PTR CS: [14000B388]
1
2
相对地址=14000B388h-1400018CBh-跳转指令长度=9ABDh-6h=9AB7h
机器码="FF15"+相对地址=FF15B79A0000h
  1. 循环语句

​ 在C+中有3种循环语法,分别为do、while、for。虽然它们完成的功能都是循环,但是每种语法有不同的执行流程。

​ (1)do

​ do循环的流程是:先执行语句块,再进行表达式判断。当表达式结果为真时,会继续执行语句块,示例代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "stdafx.h"

int _tmain(int argc, _TCHAR *argv[])
{
int nCount = 0;
do
{
printf("%d\r\n", nCount);
nCount++;
}
while (nCount < argc);

return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719162820352

​ 特征识别:首先会有一个jxx指令用于向上跳转(循环与if语句的最大区别就是循环可以向上跳转),且跳转的目的do…..while…stat语句中设有jxx跳转指令。根据以上特征,jxx指令不取反,即可还原do……while语句的代码,如图所示。

image-20240719162915568

​ 图形识别:因为do…..while语句中有一个jxx指令用于向上跳转,所以会有一个向上的虚线箭头。看到此图形即可判断其为do…while语句,虚线箭头之间的代码为do….while代码。

image-20240719162939531

​ (2)while循环

​ while循环的流程是:先进行表达式判断,再执行语句块。当表达式结果为真时,会继续执行语句块,示例如下。

1
2
3
4
5
6
7
8
9
10
11
12
#include "stdafx.h"

int _tmain(int argc, _TCHAR *argv[])
{
int nCount = 0;
while (nCount < argc)
{
printf("%d\r\n", nCount);
nCount++;
}
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。maim函数的汇编代码如下。

image-20240719163303777

​ 循环的特点是会向低地址跳转。在while循环中出现的向低地址跳转的情况与do循环中的不同,while循环使用的是jmp跳转,while循环的jxx汇编指令需要取反。需要注意的是,while循环比do循环多一次f语句判断,因此性能上while循环不如do循环高。在Release版本中,编译器会把while循环优化成等价的do循环。

​ 特征识别:首先会有一个jmp向上跳转指令,且跳转的目的while_stat下面有jxx跳转指令。while代码也会执行while_end的代码。根据以上特征,把jxx指令取反后,即可还原while语句的代码,如图所示。

image-20240719163451739

​ 图形识别:因为if语句中有一个jmp向上跳转指令,所以会有一个向上的实线箭头;又因为跳转的目的while_start下面有条件跳转指令,所以实线箭头内部会有一个向下的虚线箭头。看到此图形即可判断其为while语句,虚线箭头之间的代码为while代码。在Release版中,while语句会被优化成if加do…..while语句,因此图形会变成在外部有一个向下的虚线箭头,在虚线箭头内部有一个向上的虚线箭头。

image-20240719163538860

​ (3)for循环

​ for语句由赋初值、循环条件、循环步长3条语句组成,示例如下。

1
2
3
4
5
6
7
8
9
10
11
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
for (int nCount = 0; nCount < argc; nCount++)
{
printf("%d\r\n", nCount);
}
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719163826309

​ 特征识别:for循环也会出现向上跳转的情况。与while循环不同的是,在这里前面多了一个jmp跳转。for循环的jxx汇编指令需要取反。根据以上特征,即可还原for循环语句的代码,如图所示。

image-20240719163934411

​ 图形识别:因为for语句前面比while语句多了一个jmp跳转,所以在图形中会比while语句多一个向下的实线箭头。在Release版中,while语句会被优化成if加do….while语句,因此图形会变成在外部有一个向下的虚线箭头,在虚线箭头内部有一个向上的虚线箭头。

image-20240719164012079

  1. 数学运算符

​ 计算机中的四则运算和数学中的四侧运算有些不同。四则运算符都有对应的汇编指令,这些指令在逆向分析过程中很容易识别。本节主要讨论在Release版本中由编译器优化后的四则运算。

​ (1)整数的加法与减法

加法与减法

​ 加法对应的指令为add,减法对应的指令为sub。编译器在优化时经常使用lea指令来优化加法和减法,以缩短指令的执行周期,示例如下。

1
2
3
4
5
6
7
8
9
10
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
printf("%d\r\n", argc + 3);
printf("%d\r\n", argc - 5);
printf("%d\r\n", argc + argc + 4);
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719164252464

常量折叠

​ 常量折叠优化是指当表达式中出现2个以上常量进行计算的情况时,编译器可以在编译期间算出结果,用计算结果替换表达式,这样在程序运行期间就不需要计算,从而提高了程序的性能,示例如下。

1
2
3
4
5
6
7
8
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
printf("%d\r\n", argc + 10 + 2 * 3);
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719164609847

​ 编译器在编译期间直接把10+2×3的结果计算出来了。

​ (2)整数的乘法

​ 乘法运算所对应的汇编指令分为有符号(imul)和无符号(mul)两种。乘法指令的执行周期较长,编译器在优化时经常使用Iea比例因子寻址来优化乘法指令,示例如下。

1
2
3
4
5
6
7
8
9
10
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
printf("%d\r\n", argc * 4);
printf("%d\r\n", argc * 7);
printf("%d\r\n", argc * 9);
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719164824197

​ (3)整数的除法

​ 除法指令的执行周期较长,因此编译器会尽可能使用其他汇编指令来代替除法指令,通常的优化方法是转换成等价移位运算或者乘法运算。但是,计算机中的除法和数学中的除法有些不同,计算机中的除法是取整除法,因此在移位时可能需要做一些修正。

有符号除法,除数为2^n

​ 当除数为2^n时,编译器一般会进行移位优化,示例如下。数学优化公式为:如果x≥0,则x/2^n=x>>n;如果x≥0,则x/2^n=(x+(2^n-1))>>n

1
2
3
4
5
6
7
8
9
10
#include "stdafx.h"

int _tmain(int argc, _TCHAR *argv[])
{
long long nNum;
scanf("%ld", &nNum);
printf("%d\r\n", argc / 4);
printf("%d\r\n", nNum / 8);
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719165407057

​ 当遇到包含以上公式的汇编指令时,根据公式,第1个除法的n为2,因此有argc/4;第2个除法的n为3,因此有nNum/8。

有符号除法,除数为-2^n

​ 当除数为-2^n时,与上一个示例相比多了求补的过程,示例如下。数学优化公式为:如果x≥0,则x/-2^n=-(x>>n);如果x≥0,则x/-2^n=-((x+(2^n-1))>>n)

1
2
3
4
5
6
7
8
9
10
11
#include "stdafx.h"


int _tmain(int argc, _TCHAR *argv[])
{
long long nNum;
scanf("%ld", &nNum);
printf("%d\r\n", argc / -2);
printf("%d\r\n", nNum / -8);
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719165909018

​ (4)整数的取模

​ 取模运算可以通过除法指令计算实现。但因为除法指令的执行周期较长,所以通常的优化方法是将其转换成等价的位运算或者除法运算,再由除法运算进行优化。

取模运算,除数为2^n

​ 对x%2”取模来说,有如下两种数学优化公式。

image-20240719170128770

1
2
3
4
5
6
7
8
9
#include "stdafx.h"
int _tmain(int argc, _TCHAR *argv[])
{
long long nNum;
scanf("%ld", &nNum);
printf("%d\r\n", argc % 8);
printf("%d\r\n", nNum % 32);
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719171506137

image-20240719171521713

取模运算,除数为非2^n

​ 对除数为非2的取模来说,编译器一般采用“余数=被除数-商×除数”的方法优化,数学优化公式为x%c=x-x/c*c,示例如下。

1
2
3
4
5
6
7
8
9
#include "stdafx.h"
int _tmain(int argc, _TCHAR *argv[])
{
long long nNum;
scanf("%ld", &nNum);
printf("%d\r\n", argc % 3);
printf("%d\r\n", nNum % 10);
return 0;
}

​ 编译后,用IDA Pro打开Release版程序。main函数的汇编代码如下。

image-20240719171746490

image-20240719171755036

  1. 虚函数

​ C++的三大核心机制是封装、继承、多态,而虚函数就是多态的一种体现。由于面向对象语言是供了强大的代码管理机制,越来越多的软件都采用了面向对象的程序设计。在软件逆向过程中,免会碰到使用面向对象思想设计的软件,而虚函数就是在实际软件逆向过程中的一种还原面向对代码的重要手段。本节将探讨编译器实现虚函数的原理。

​ (1)虚表

​ VC++实现虚函数功能的方式是做表,我们称这个表为虚表。什么时候会产生虚表呢?如果一个类至少有一个虚函数,那么编译器就会为这个类产生一个虚表。不同的类虚表不同,相同的类对象共享一个虚表。在实际逆向过程中如何识别虚表呢?我们先看一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "stdafx.h"


class CVirtual
{
public:
CVirtual()
{
m_nMember1 = 1;
m_nMember2 = 2;
printf("CVirtual()\r\n");
}
virtual ~CVirtual()
{
printf("~CVirtual()\r\n");
}
virtual void fun1()
{
printf("fun1()\r\n");
}
virtual void fun2()
{
printf("fun2()\r\n");
}
private:
int m_nMember1;
int m_nMember2;
};

int main(int argc, char *argv[])
{
CVirtual object;
object.fun1();
object.fun2();
return 0;
}

​ 编译后,用IDA Pro打开Debug版程序。main函数的汇编代码如下。

image-20240719172756707

image-20240719172812710

image-20240719172826850

image-20240719172905171

image-20240719172914758

image-20240719172925280

image-20240719172943174

image-20240719172951276

image-20240719173000030

image-20240719173037431

​ 虚表

image-20240719173557379

​ 首先,在main函数入口处申请了对象实例的内存空间,第1个call指令调用了构造函数。接下来,调用成员函数fun1和fum2。最后,调用析构函数。这些成员函数调用的第1个参数都是this指针,也就是rcx=this。C++语法规定,在实例化对象时会自动调用构造函数,对象作用域会自动调用析构函数。因此,这里的构造函数和析构函数的调用顺序符合C++的语法规定。
​ 在逆向过程中,如果一个对象在某个作用域内调用的是第1个函数,就可以怀疑是构造函数的调用;如果一个对象在某个作用域内调用的是最后一个函数,就可以怀疑是析构函数的调用。
​ 接下来分析构造函数的实现。在构造函数中,首先初始化虚表指针,然后初始化数据成员,构造函数完成,返回this指针。为什么要返回this指针呢?这是C++编译器为了判断一个构造是否被调用而设置的。在下一个例子中,我们会讲解这个返回值的应用。
​ 在逆向过程中,如果一个函数在人口处使用“lea reg,of_140007970”和“mov[reg],reg”特征初始化虚表,且返回值为this指针,就可以怀疑这个函数是一个构造函数。再来看看析构函数的实现(sub_14000100F)。在析构函数里,首先也赋值了虚表,最后也返回了this指针。为什么析构函数还要赋值虚表,构造函数不是赋值了吗?这是因为C++语法规定,析构函数需要调用虚函数的无多态性。

​ 在逆向过程中,如果一个函数在入口处使用“lea reg,off_140007970”和“mov [reg],reg”特征初始化虚表,并且返回值为this指针,就可以怀疑这个函数是一个析构函数。既然这个特征与构造函数是一致的,该如何区分呢?读者可根据调用的先后顺序确定。

​ 接下来看看虚表的结构(.rdata:0000000140007970)。因为这个类有虚函数,所以编译器为这个类产生了一个虚表,其存储区域在全局数据区。虚表的每一项都是8字节,其中存储的是成员函数的地址。在这里要注意的是:因为虚表的最后一项不一定以0结尾,所以虚表项的个数会根据其他信息来确定。
​ 虚表中的函数是按类中的成员函数声明顺序依次放人的。需要注意的是:函数分布顺序在某些情祝下不一定与声明顺序相同(例如虚函数重载),不过这个顺序对逆向还原代码没有影响。通过这个虚表,就可以还原这个类的虚函数个数及虚函数代码了。

​ 虚表特征总结如下。

​ ●如果一个类至少有一个虚函数,这个类就有一个指向虚表的指针。
​ ●不同的类虚表不同,相同的类对象共享一个虚表。
​ ●虚表指针存放在对象首地址处。
​ ●虚表地址在全局数据区中。
​ ●虚表的每个元素都指向一个类成员函数指针(8字节)。
​ ●虚表不一定以0结尾。
​ ●虚表的成员函数顺序,按照类声明的顺序排列。
​ ●虚表在构造函数中会被初始化。
​ ●虚表在析构函数中会被赋值。

​ 根据以上特征就可以判断一个地址处的内容是否是一个虚表,并根据虚表的项数还原这个类编写的所有虚函数了。

​ 对象内存布局总结如图所示。

image-20240719174922334