逆向分析技术

说明

将可执行程序反汇编,通过分析反汇编代码来理解其代码功能(例如各接口的数据结构等)然后用高级语言重新描述 这段代码,逆向分析原始软件的思路,这个过程就称作逆向工程(Reverse Engineering),有时也简单地称作逆向(Reversing).这是一项很重要的技能,需要扎实的编程功底和汇编知识逆向分析的首选工具是IDA,它的插件Hex-Rays Decompiler 能完成许多代码反编译工作。

32位软件逆向技术

环境VC6.0编译的32位程序。

启动函数

在编写Win32应用程序时,都必须在源码里实现一个WinMain函数。但Windows程序的执行并不是从WinMain函数开始的,首先被执行的启动函数的相关代码,这段代码是编译器生成的。在启动代码初始化进程完成后,才会调用WinMain函数。初始化。Visual C++配有C运行库的源代码,可以在crt\src\crt0.c文件中找到启动函数的源代码

用于控制台程序的启动代码存放在crt\src\wincmdln.c中。
所有C/C++程序运行时,启动函数的作用基本相同,包括检索指向新进程的命令行指针,检索指向新进程的环境变量指针、全局变量初始化和内存栈初始化等。当所有的初始化操作完成后,启动函数就会调用应用程序的进入函数(main 和 WinMain)调用WinMain函数的示例如下。

GetStartupInfo(&StartupInfo);
Int nMainRetval = WinMain(GetModuleHandle(NULL),NULL,pszCommandLineAnsi,\
                          StartupInfo,dwFlags&STARTF_USESHOWWINDOW)?StartupInfo.\
                           wShowWindow:SW_SHOWDEFAULT);
)

进入点返回时,启动函数便调用C运行库的exit函数,将返回值(nMainRetVal)传递给它,进行一些必要的处理,最后调用系统函数ExitProcess退出。
有一个用Visual C++编译的程序,其程序启动代码的汇编代码如下。

函数

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

函数的识别

程序通过调用程序来调用函数,在函数执行后又返回调用程序继续执行。函数如何知道要返回的地址?实际上,调用函数的代码中保存了一个返回地址,该地址会与参数一起传递给被调用的函数。有多种方法可以实现这个功能,在绝大数情况下,编译器都使用call和ret指令来调用函数及返回调用位置。
call指令与跳转指令功能类似。不同的是,call指令保存返回信息,即将其之后的指令地址压入栈的顶部,当遇到ret指令时返回这个地址.也就是说,call指令给出的地址就是被调用函数的起始地址。ret指令则用于结束函数的执行(当然,不是所有的ret指令都标志着函数的结束)。通过这一机制可以很容易地把函数调用和其他跳转指令区别开来。
因此,可以通过定位call机器指令或利用ret指令结束的标志来识别函数。call指令的操作数就是所调用函数的首地址.
int Add(int x ,int y);
int main()
{
  int a = 5;
  int b = 6;
  Add(a,b);
  return 0;
}
Add(int x, int y)
{
  return (x+y);
}

函数的参数

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

栈方式

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

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

不同类型约定的处理方式,我们来看一个例子。假设有调用函数test(Par1,Par2,Par3),按__cdecl、pascal和stdcall的调用约定其汇编代码如表


可以清楚地看到,__cdecl类型和stdcall类型先把右边的参数压入栈,pascal则相反。在栈平衡上,__cdecl类型由调用者调用"add esp,0c" 指令把12字节的参数空间清楚,pascal和stdcall类型则由子程序负责清楚。
函数对参数的存放即局部变量都是通过栈定义的,非优化编译器用一个专门的寄存器(通常是ebp)对参数进行寻址。

C、C++、pascal等高级语言的函数

  • 调用者将函数(子程序)执行完毕时应返回的地址、参数压入栈。
  • 子程序使用"ebp指针+偏移量"对栈中的参数进行寻址并取出,完成操作。
  • 子程序使用ret或retf指令返回。此时,CPU将eip置为栈中保存的地址,并继续执行它。
    栈是一个先进后出的区域,只有一个出口,即当前栈顶。栈操作的对象只能是双操作数(占4字节)。例如,按stdcall约定调用函数test(Par1,Par2)(有2个参数)

注意若函数要使用局部变量,则要在栈中留出一些空间

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

  • 此例函数中有2个参数,假设执行函数前栈指针的esp为K
  • 根据stdcall调用约定,先将参数Par2压进栈,此时esp为K-04h。
  • 将参数Par1压入栈,此时esp为K-05h。
  • 参数入栈后,程序开始执行call指令。call指令把返回地址压入栈,这时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加上操作数,完成同样的功能。

    此时,指令enter和leave可以帮助进行栈的维护。enter语句的作用就是"push ebp" "mov ebp,esp" "sub esp,xxx",而leave语句则完成"add esp,xxx" "pop ebp"的功能。所以,上面的程序可以改成如下形式。
enter xxxx ,0 ; 0表示创建xxxx空间放置局部变量
.....
leave ; 恢复现场
ret 8 ; 返回

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

push par2  ;参数2
push par1  ;参数1
call test2  ;调用子程序 test2
{
    mov eax,dword ptr[esp+04] ;调用参数1
    mov ecx,dword ptr[esp+08] ;调用参数2
......
ret 8
}

这时,程序就用esp来传递参数了。其栈建立情况时:

  • 假设执行函数前指针esp的值为K.
  • 根据stdcall调用约定,先将参数Par2压入栈,此时esp为K-04h。
  • 将Par1压入栈,此时esp为K-08h。
  • 参数入栈后,程序开始执行call指令。call指令把返回地址压入栈,这时esp为K-0Ch.
  • 现在已经在子程序中了,可以使用esp来存取参数了。

利用寄存器传递参数

寄存器传递参数的方式没有标准,所有与平台相关的方式都是由编译器开发人员制定的。尽管没有标准,但绝大多数编译器提供商都在不对兼容性进行声明的情况下遵循相应的规范,即Fastcall规范。Fastcall,顾名思义,特点就是快(因为它是考寄存器来传递参数的)。
不同编译器实现的Fastcall稍有不同。Microsoft Visual C++编译器在采用Fastcall规范传递参数时,左边的2不大于4个字节(DWORD)参数分别放在ecx和edx寄存器中,寄存器用完后就要使用栈,其余参数仍然按从右到左的顺序压入栈,被调用的函数在返回前清理传送参数的栈。浮点值、远指针和__int64类型总是通过栈来传递的。而Borland Delphi

posted @ 2022-04-24 10:59  不会笑的孩子  阅读(1206)  评论(0编辑  收藏  举报