C语言逆向之函数的识别

Posted 嵌入式ARM

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C语言逆向之函数的识别相关的知识,希望对你有一定的参考价值。

在学习编程的过程中,需要阅读大量的源代码才能提高自身的编程能力。同样,在做产品的时候也需要大量参考同行的软件才能改善自己产品的不足。如果发现某个软件的功能非常不错,是自己急需融入自己软件产品的功能,而此时又没有源代码可以参考,那么程序员唯一能做的只有通过逆向分析来了解其实现方式。除此之外,当使用的某个软件存在 Bug,而该软件已经不再更新时,程序员能做的并不是去寻找同类的其他软件,而是可以通过逆向分析来自行修正其软件的 Bug,从而很好地继续使用该软件。逆向分析程序的原因很多,有些情况不得不进行逆向分析,比如病毒分析、漏洞分析等。

可能病毒分析、漏洞分析等高深技术对于有些人来说目前还无法达到,但是其基础知识部分都离不开逆向知识。下面借助 IDA 来分析由VC6编译连接C语言的代码,从而来学习掌握逆向的基础知识。

1. 简单的C语言函数调用程序

为了方便介绍关于函数的识别,这里写一个简单的C语言程序,用VC6进行编译连接。C语言的代码如下:
  
    
    
  
#include <stdio.h> #include <windows.h> int test(char *szStr, int nNum) { printf("%s, %d \r\n", szStr, nNum);   MessageBox(NULL, szStr, NULL, MB_OK); return 5; } int main(int argc, char ** argv) {   int nNum = test("hello", 6);   printf("%d \r\n", nNum); return 0; }
在程序代码中,自定义函数test()由主函数main()所调用,test()函数的返回值为int类型。在test()函数中调用了printf()函数和MessageBox()函数。将代码在VC6下使用DEBUG方式进行编译连接来生成一个可执行文件,对该可执行文件通过 进行逆向分析。

以上代码的扩展名为“.c”,而不是“.cpp”。这里用来进行逆向分析的例子均使用DEBUG方式在VC6下进行编译连接。

2. 函数逆向分析

大多数情况下程序员都是针对自己比较感兴趣的程序部分进行逆向分析,分析部分功能或者部分关键函数。因此,确定函数的开始位置和结束位置非常重要。不过通常情况下,函数的起始位置和结束位置都可以通过反汇编工具自动识别,只有在代码被刻意改变后才需要程序员自己进行识别。 IDA 可以很好地识别函数的起始位置和结束位置,如果在逆向分析的过程中发现有分析不准确的时候,可以通过Alt + P快捷键打开“Edit function”(编辑函数)对话框来调整函数的起始位置和结束位置。“Edit function”对话框的界面如图1所示。在图1中,被选中的部分可以设定函数的起始地址和结束地址。
C语言逆向之函数的识别
图1  “Edit function”对话框

打开VC6编译好的程序,在打开的时候, 会有一个提示,如图2所示。该图询问是否使用PDB文件。PDB文件是程序数据库文件,是编译器生成的一个文件,方便程序调试使用。PDB包含函数地址、全局变量的名字和地址、参数和局部变量的名字和在堆栈的偏移量等很多信息。这里选择“Yes”按钮。
C语言逆向之函数的识别
图2  提示是否使用PDB文件

在分析其他程序的时候,通常没有PDB文件,那么这里会选择“No”按钮。在有PDB和无PDB文件时, IDA 的分析结果是截然不同的。请大家在自己分析时,尝试对比不加载编译器生成的PDB文件和加载了PDB文件 IDA 生成的反汇编代码的差异。

IDA 完成对程序的分析后, IDA 直接找到了main()函数的跳表项,如图3所示。
C语言逆向之函数的识别
图3  main()函数的跳表

所谓main()函数的跳表项,意思是这里并不是main()函数的真正的起始位置,而是该位置是一个跳表,用来统一管理各个函数的地址。从图3中看到,有一条jmp _main的汇编代码,这条代码用来跳向真正的main()函数的地址。在 IDA 中查看图3上下位置,可能只能找到这么一条跳转指令。在图3的靠下部分有一句注释为“[00000005 BYTES: COLLAPSED FUNCTION j__test. PRESS KEYPAD "+" TO EXPAND]”。这里是可以展开的,在该注释上单击右键,出现右键菜单后选择“Unhide”项,则可以看到被隐藏的跳表项,如图4所示。
C语言逆向之函数的识别
图4  展开后的跳表

在实际的反汇编代码时,jmp _main和jmp _test是紧挨着的两条指令,而且jmp后面是两个地址。这里的显示函数形式、_main和_test是由IDA进行处理的。在OD下观察跳表的形式,如图5所示。
C语言逆向之函数的识别
图5  OD中跳表的指令位置

并不是每个程序都能被 识别出跳转到main()函数的跳表项,而且程序的入口点也并非main()函数。首先来看一下程序的入口函数位置。在 上单击窗口选项卡,选择“Exports”窗口(Exports窗口是导出窗口,用于查看导出函数的地址,但是对于EXE程序来说通常是没有导出函数的,这里将显示EXE程序的入口函数),在“Exports”窗口中可以看到_mainCRTStartup,如图6所示。
C语言逆向之函数的识别
图6  Exports窗口

双击_mainCRTStartup就可以到达启动函数的位置了。在C语言中,main()不是程序运行的第一个函数,而是程序员编写程序时的第一个函数,main()函数是由启动函数来调用的。现在看一下_mainCRTStartup函数的部分反汇编代码:
  
    
    
  
.text:004011D0 public _mainCRTStartup .text:004011D0 _mainCRTStartup proc near .text:004011D0 .text:004011D0 Code = dword ptr -1Ch .text:004011D0 var_18 = dword ptr -18h .text:004011D0 var_4 = dword ptr -4 .text:004011D0 .text:004011D0 push ebp .text:004011D1 mov ebp, esp .text:004011D3 push 0FFFFFFFFh .text:004011D5 push offset stru_422148 .text:004011DA push offset __except_handler3 .text:004011DF mov eax, large fs:0 .text:004011E5 push eax .text:004011E6 mov large fs:0, esp .text:004011ED add esp, 0FFFFFFF0h .text:004011F0 push ebx .text:004011F1 push esi .text:004011F2 push edi .text:004011F3 mov [ebp+var_18], esp .text:004011F6 call ds:__imp__GetVersion@0 ; GetVersion() .text:004011FC mov __osver, eax .text:00401201 mov eax, __osver .text:00401206 shr eax, 8 .text:00401209 and eax, 0FFh .text:0040120E mov __winminor, eax .text:00401213 mov ecx, __osver .text:00401219 and ecx, 0FFh .text:0040121F mov __winmajor, ecx .text:00401225 mov edx, __winmajor .text:0040122B shl edx, 8 .text:0040122E add edx, __winminor .text:00401234 mov __winver, edx .text:0040123A mov eax, __osver .text:0040123F shr eax, 10h .text:00401242 and eax, 0FFFFh .text:00401247 mov __osver, eax .text:0040124C push 0 .text:0040124E call __heap_init .text:00401253 add esp, 4 .text:00401256 test eax, eax .text:00401258 jnz short loc_401264 .text:0040125A push 1Ch .text:0040125C call fast_error_exit .text:00401261; ------------------------------------------------ .text:00401261 add esp, 4 .text:00401264 .text:00401264 loc_401264: ; CODE XREF: _mainCRTStartup+88j .text:00401264 mov [ebp+var_4], 0 .text:0040126B call __ioinit .text:00401270 call ds:__imp__GetCommandLineA@0 ; GetCommandLineA() .text:00401276 mov __acmdln, eax .text:0040127B call ___crtGetEnvironmentStringsA .text:00401280 mov __aenvptr, eax .text:00401285 call __setargv .text:0040128A call __setenvp .text:0040128F call __cinit .text:00401294 mov ecx, __environ .text:0040129A mov ___initenv, ecx .text:004012A0 mov edx, __environ .text:004012A6 push edx .text:004012A7 mov eax, ___argv .text:004012AC push eax .text:004012AD mov ecx, ___argc .text:004012B3 push ecx .text:004012B4 call _main_0 .text:004012B9 add esp, 0Ch .text:004012BC mov [ebp+Code], eax .text:004012BF mov edx, [ebp+Code] .text:004012C2 push edx ; Code .text:004012C3 call _exit .text:004012C3 _mainCRTStartup endp
从反汇编代码中可以看到,main()函数的调用在004012B4位置处。启动函数从004011D0地址处开始,期间调用GetVersion()函数获得了系统版本号、调用__heap_init函数初始化了程序所使用的堆空间、调用GetCommandLineA()函数获取了命令行参数、调用___crtGetEnviro nmentStringsA函数获得了环境变量字符串……在完成一系列启动所需的工作后,终于在004012B4处调用了_main_0。由于这里使用的是调试版且有PDB文件,因此在反汇编代码中直接显示出程序中的符号,在分析其他程序时是没有PDB文件的,这样_main_0就会显示为一个地址,而不是一个符号。不过依然可以通过规律来找到_main_0所在的位置。

没有PDB文件,如何找到_main_0所在的位置呢?在VC6中,启动函数会依次调用GetVersion()、GetCommandLineA()、GetEnvironmentStringsA()等函数,而这一系列函数即是一串明显的特征。在调用完GetEnvironmentStringsA()后,不远处会有3个push操作,分别是main()函数的3个参数,代码如下:
  
    
    
  
.text:004012A0 mov edx, __environ .text:004012A6 push edx .text:004012A7 mov eax, ___argv .text:004012AC push eax .text:004012AD mov ecx, ___argc .text:004012B3 push ecx .text:004012B4 call _main_0
该反汇编代码对应的C代码如下:
  
    
    
  
#ifdef WPRFLAG __winitenv = _wenviron; mainret = wmain(__argc, __wargv, _wenviron); #else /* WPRFLAG */ __initenv = _environ; mainret = main(__argc, __argv, _environ); #endif /* WPRFLAG */
该部分代码是从CRT0.C中得到的,可以看到启动函数在调用main()函数时有3个参数。

接着上面的内容,在3个push操作后的第1个call处,即是_main_0函数的地址。往_main_0下面看,_main_0后地址为004012C3的指令为call _exit。确定了程序是由VC6编写的,那么找到对_exit的调用后,往上找一个call指令就找到_main_0所对应的地址。大家可以依照该方法进行测试。

在顺利找到_main_0函数后,直接双击反汇编的_main_0,到达函数跳转表处。在跳转表中双击_main,即可到真正的_main函数的反汇编代码处。_main函数的返汇编代码如下:
  
    
    
  
.text:004010A0 _main proc near ; CODE XREF: _main_0j .text:004010A0 .text:004010A0 var_44 = byte ptr -44h .text:004010A0 var_4 = dword ptr -4 .text:004010A0 .text:004010A0 push ebp .text:004010A1 mov ebp, esp .text:004010A3 sub esp, 44h .text:004010A6 push ebx .text:004010A7 push esi .text:004010A8 push edi .text:004010A9 lea edi, [ebp+var_44] .text:004010AC mov ecx, 11h .text:004010B1 mov eax, 0CCCCCCCCh .text:004010B6 rep stosd .text:004010B8 push 6 .text:004010BA push offset aHello ; "hello" .text:004010BF call j__test .text:004010C4 add esp, 8 .text:004010C7 mov [ebp+var_4], eax .text:004010CA mov eax, [ebp+var_4] .text:004010CD push eax .text:004010CE push offset aD ; "%d \r\n" .text:004010D3 call _printf .text:004010D8 add esp, 8 .text:004010DB xor eax, eax .text:004010DD pop edi .text:004010DE pop esi .text:004010DF pop ebx .text:004010E0 add esp, 44h .text:004010E3 cmp ebp, esp .text:004010E5 call __chkesp .text:004010EA mov esp, ebp .text:004010EC pop ebp .text:004010ED retn .text:004010ED _main endp
短短几行C语言代码,在编译连接生成可执行文件后,再进行反汇编竟然生成了比C语言代码多很多的代码。仔细观察上面的反汇编代码,通过特征可以确定这是写的主函数,首先代码中有一个对test()函数的调用在004010BF地址处,其次有一个对printf()函数的调用在004010D3地址处。_main函数的入口部分代码如下:
  
    
    
  
.text:004010A0 push ebp .text:004010A1 mov ebp, esp .text:004010A3 sub esp, 44h .text:004010A6 push ebx .text:004010A7 push esi .text:004010A8 push edi .text:004010A9 lea edi, [ebp+var_44] .text:004010AC mov ecx, 11h .text:004010B1 mov eax, 0CCCCCCCCh .text:004010B6 rep stosd
大多数函数的入口处都是push ebp/mov ebp, esp/sub esp, ×××这样的形式,这几句代码完成了保存栈帧,并开辟了当前函数所需的栈空间。push ebx/push esi/push edi是用来保存几个关键寄存器的值,以便函数返回后这几个寄存器中的值还能在调用函数处继续使用而没有被破坏掉。lea edi, [ebp + var_44]/mov ecx, 11h/move ax , 0CCCCCCCCh/rep stosd,这几句代码是开辟的内存空间,全部初始化为0xCC。0xCC被当作机器码来解释时,其对应的汇编指令为int 3,也就是调用3号断点中断来产生一个软件中断。将新开辟的栈空间初始化为0xCC,这样做的好处是方便调试,尤其是给指针变量的调试带来了方便。

以上反汇编代码是一个固定的形式,唯一会发生变化的是sub esp, ×××部分,在当前反汇编代码处是sub esp, 44h。在VC6下使用Debug方式编译,如果当前函数没有变量,那么该句代码是sub esp, 40h;如果有一个变量,其代码是sub esp, 44h;有两个变量时,为sub esp, 48h。也就是说,通过Debug方式编译时,函数分配栈空间总是开辟了局部变量的空间后又预留了40h字节的空间。局部变量都在栈空间中,栈空间是在进入函数后临时开辟的空间,因此局部变量在函数结束后就不复存在了。与函数入口代码对应的代码当然是出口代码,其代码如下:
  
    
    
  
.text:004010DD pop edi .text:004010DE pop esi .text:004010DF pop ebx .text:004010E0 add esp, 44h .text:004010E3 cmp ebp, esp .text:004010E5 call __chkesp .text:004010EA mov esp, ebp .text:004010EC pop ebp .text:004010ED retn .text:004010ED _main endp
函数的出口部分(或者是函数返回时的部分)也属于固定格式,这个格式跟入口的格式基本是对应的。首先是pop edi/pop esi/pop ebx,这里是将入口部分保存的几个关键寄存器的值进行恢复。push和pop是对堆栈进行操作的指令。堆栈结构的特点是后进先出,或先进后出。因此,在函数的入口部分的入栈顺序是push ebx/push esi/push edi,出栈顺序则是倒序pop edi/pop esi/pop ebx。恢复完寄存器的值后,需要恢复esp指针的位置,这里的指令是add esp, 44h,将临时开辟的栈空间释放掉(这里的释放只是改变寄存器的值,其中的数据并未清除掉),其中44h也是与入口处的44h对应的。从入口和出口改变esp寄存器的情况可以看出,栈的方向是由高地址向低地址方向延伸的,开辟空间是将esp做减法操作。mov esp, ebp/pop ebp是恢复栈帧,retn就返回上层函数了。在该反汇编代码中还有一步没有讲到,也就是cmp ebp, esp/call __chkesp,这两句是对__chkesp函数的一个调用。在Debug方式下编译,对几乎所有的函数调用完成后都会调用一次__chkesp。该函数的功能是用来检查栈是否平衡,以保证程序的正确性。如果栈不平,会给出错误提示。这里做个简单的测试,在主函数的return语句前加一条内联汇编__asm push ebx(只要是改变esp或ebp寄存器值的操作都可以达到效果),然后编译连接运行,在输出后会看到一个错误的提示,如图7所示。
C语言逆向之函数的识别
图7  调用__chkesp后对栈平衡进行检查后的出错提示

图7就是__chkesp函数在检测到ebp与esp不平时给出的提示框。该功能只在DEBUG版本中存在。

主函数的反汇编代码中还有一部分没有介绍,反汇编代码如下:
  
    
    
  
.text:004010B8 push 6 .text:004010BA push offset aHello ; "hello" .text:004010BF call j__test .text:004010C4 add esp, 8 .text:004010C7 mov [ebp+var_4], eax .text:004010CA mov eax, [ebp+var_4] .text:004010CD push eax .text:004010CE push offset aD ; "%d \r\n" .text:004010D3 call _printf .text:004010D8 add esp, 8 .text:004010DB xor eax, eax
首先几条反汇编代码是push 6/push offset aHello/call j_test/add esp, 8/mov [ebp+var_ 4], eax,这几条反汇编代码是主函数对test()函数的调用。函数参数的传递可以选择寄存器或者内存。由于寄存器数量有限,几乎大部分函数调用都是通过内存进行传递的。当参数使用完成后,需要把参数所使用的内存进行回收。对于VC开发环境而言,其默认的调用约定方式是cdecl。这种函数调用约定对参数的传递依靠栈内存,在调用函数前,会通过压栈操作将参数从右往左依次送入栈中。在C代码中,对test()函数的调用形式如下:
  
    
    
  
int nNum = test("hello", 6);
而对应的反汇编代码为push 6 / push offset aHello / call j_test。从压栈操作的push指令来看,参数是从右往左依次入栈的。当函数返回时,需要将参数使用的空间回收。这里的回收,指的是恢复esp寄存器的值到函数调用前的值。而对于cdecl调用方式而言,平衡堆栈的操作是由函数调用方来做的。从上面的反汇编代码中可以看到反汇编代码add esp, 8,它是用于平衡堆栈的。该代码对应的语言为调用函数前的两个push操作,即函数参数入栈的操作。

函数的返回值通常保存在eax寄存器中,这里的返回值是以return语句来完成的返回值,并非以参数接收的返回值。004010C7地址处的反汇编代码mov [ebp+var_4], eax是将对j_test调用后的返回值保存在[ebp + var_4]中,这里的[ebp + var_4]就相当于C语言代码中的nNum变量。逆向分析时,可以在 中通过快捷键N来完成对var_4的重命名。

在对j_test调用完成并将返回值保存在var_4中后,紧接着push eax/push offset aD/call _printf/add esp, 8的反汇编代码应该就不陌生了。而最后面的xor eax, eax这句代码是将eax进行清0。因为在C语言代码中,main()函数的返回值为0,即return 0;,因此这里对eax进行了清0操作。

双击004010BF地址处的call j__test,会移到j_test的函数跳表处,反汇编代码如下:
  
    
    
  
.text:0040100A j__test proc near ; CODE XREF: _main+1Fp .text:0040100A jmp _test .text:0040100A j__test endp
双击跳表中的_test,到如下反汇编处:
  
    
    
  
.text:00401020 ; int __cdecl test(LPCSTR lpText, int) .text:00401020 _test proc near ; CODE XREF: j__testj .text:00401020 .text:00401020 var_40 = byte ptr -40h .text:00401020 lpText = dword ptr 8 .text:00401020 arg_4 = dword ptr 0Ch .text:00401020 .text:00401020 push ebp .text:00401021 mov ebp, esp .text:00401023 sub esp, 40h .text:00401026 push ebx .text:00401027 push esi .text:00401028 push edi .text:00401029 lea edi, [ebp+var_40] .text:0040102C mov ecx, 10h .text:00401031 mov eax, 0CCCCCCCCh .text:00401036 rep stosd .text:00401038 mov eax, [ebp+arg_4] .text:0040103B push eax .text:0040103C mov ecx, [ebp+lpText] .text:0040103F push ecx .text:00401040 push offset Format ; "%s, %d \r\n" .text:00401045 call _printf .text:0040104A add esp, 0Ch .text:0040104D mov esi, esp .text:0040104F push 0 ; uType .text:00401051 push 0 ; lpCaption .text:00401053 mov edx, [ebp+lpText] .text:00401056 push edx ; lpText .text:00401057 push 0 ; hWnd .text:00401059 call ds:__imp__MessageBoxA@16 ; MessageBoxA(x,x,x,x) .text:0040105F cmp esi, esp .text:00401061 call __chkesp .text:00401066 mov eax, 5 .text:0040106B pop edi .text:0040106C pop esi .text:0040106D pop ebx .text:0040106E add esp, 40h .text:00401071 cmp ebp, esp .text:00401073 call __chkesp .text:00401078 mov esp, ebp .text:0040107A pop ebp .text:0040107B retn .text:0040107B _test endp
该反汇编代码的开头部分和结尾部分,这里不再重复,主要看一下中间的反汇编代码部分。中间的部分主要是printf()函数和MessageBoxA()函数的反汇编代码。

调用printf()函数的反汇编代码如下:
  
    
    
  
.text:00401038 mov eax, [ebp+arg_4] .text:0040103B push eax .text:0040103C mov ecx, [ebp+lpText] .text:0040103F push ecx .text:00401040 push offset Format ; "%s, %d \r\n" .text:00401045 call _printf .text:0040104A add esp, 0Ch
调用MessageBoxA()函数的反汇编代码如下:
  
    
    
  
.text:0040104F push 0 ; uType .text:00401051 push 0 ; lpCaption .text:00401053 mov edx, [ebp+lpText] .text:00401056 push edx ; lpText .text:00401057 push 0 ; hWnd .text:00401059 call ds:__imp__MessageBoxA@16 ; MessageBoxA(x,x,x,x)
比较以上简单的两段代码会发现很多不同之处,首先在调用完_printf后会有add esp, 0Ch的代码进行平衡堆栈,而调用MessageBoxA后没有。为什么对MessageBoxA函数的调用则没有呢?原因在于,在Windows系统下,对API函数的调用都遵循的函数调用约定是stdcall。对于stdcall这种调用约定而言,参数依然是从右往左依次被送入堆栈,而参数的平栈是在API函数内完成的,而不是在函数的调用方完成的。在 中看一下MessageBoxA函数在返回时的平栈方式,如图8所示。
C语言逆向之函数的识别
图8  MessageBoxA函数的平栈操作

从图8中可以看出,MessageBoxA函数在调用retn指令后跟了一个10。这里的10是一个16进制数,16进制的10等于10进制的16。而在为MessageBoxA传递参数时,每个参数是4字节,4个参数等于16字节,因此retn 10除了有返回的作用外,还包含了add esp, 10的作用。

上面两段反汇编代码中除了平衡堆栈的不同外,还有另外一个明显的区别。在调用printf时的指令为call _printf,而调用MessageBoxA时的指令为call ds:__imp__MessageBoxA@16。printf()函数在stdio.h头文件中,该函数属于C语言的静态库,在连接时会将其代码连接入二进制文件中。而MessageBoxA函数的实现在user32.dll这个动态连接库中。在代码中,这里只留了进入MessageBoxA函数的一个地址,并没有具体的代码。MessageBoxA的具体地址存放在数据节中,因此在反汇编代码中给出了提示,使用了前缀“ds:”。“__imp__”表示导入函数。MessageBoxA后面的“@16”表示该API函数有4个参数,即16 / 4 = 4。

多参的API函数仍然在调用方进行平栈,比如wsprintf()函数。原因在于,被调用的函数无法具体明确调用方会传递几个参数,因此多参函数无法在函数内完成参数的堆栈平衡工作。

stdcall是Windows下的标准函数调用约定。Windows提供的应用层及内核层函数均使用stdcall的调用约定方式。cdecl是C语言的调用函数约定方式。

3. 结语

在逆向分析函数时,首先需要确定函数的起始位置,这通常会由 IDA 自动进行识别(识别不准确的话,就只能手动识别了);其次需要掌握函数的调用约定和确定函数的参数个数,确定函数的调用约定和参数个数都是通过平栈的方式和平栈时对esp操作的值来进行判断的;最后就是观察函数的返回值,这部分通常就是观察eax的值,由于return通常只返回布尔类型、数值类型相关的值,因此通过观察eax的值可以确定返回值的类型,确定了返回值的类型后,可以进一步考虑函数调用方下一步的动作。

参考文献:C++ 黑客编程揭秘与防范(第3版)


END

来源:计算机与网络安全

版权归原作者所有,如有侵权,请联系删除。

推荐阅读



→点关注,不迷路←