C语言代码逆向基础-函数的识别

C语言代码逆向基础-函数的识别

当函数执行时,程序流转会转到函数体的实现地址处,只有遇到return或者”}”符号才返回到下一跳语句的地址处,并且很多高级语言在传递参数时会执行将实参复制给形参这一操作。下面我们来看看函数的具体操作。

函数工作原理

栈帧

栈在内存中是一块特殊的存储空间,存储原则是“先进后出”。汇编指令通常使用PUSH和POP指令对栈空间执行数据压入和数据出栈操作。使用esp(栈顶寄存器)和ebp(栈底寄存器)每4个字节的栈空间保存一个数据,并且栈通常是由高地址向低地址延伸。

栈平衡:在进入某个函数实现时,一般会预先保存栈底指针ebp,以便函数退出后还原到以前的栈顶。退出函数后会将ebp和esp进行对比,检测当前栈帧是否正确关闭,若不平衡则调用_chkesp函数弹窗提示错误。

简单C语言函数调用程序

方便介绍关于函数的识别,首先写一个简单的C语言程序,并通过编译软件进行编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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 nNum = test("hello", 6);
printf("%d \r\n", nNum);
return 0;
}

使用IDA打开其编译文件下生成的DEGUB文件夹下生成的可执行文件exe进行逆向分析

函数的逆向分析

IDA识别出跳转到main()函数的跳表项,切换到文本视图,可以找到main()的跳表项

但并不是每个程序都能被IDA识别跳转到main()函数的跳表项,我们选择exports窗口,这里显示的是程序的入口函数,可以看到start,这是有编译器插入的函数。是编译后的启动函数。

点击可以看到mainCRTStartup代码,在C语言中main()不是程序运行的第一个函数,而是程序员编写程序时的第一个函数,main()函数是由启动函数来调用的

从反汇编代码中可以看到,启动函数从004011D0地址处开始,通过调用一系列启动所需工作后(GetVersion()函数、GetCommandLineA()函数等),在004012B4处调用了_main.

在VC中,启动函数会依次调用GetVersion()、GetCommandLineA()、GetEnvironmentStringA()等函数这一明显特征,在调用完GetEnviro nmentStringA()函数后有三个PUSH操作

1
2
3
4
5
6
7
.text:004012A0                 mov     edx, envp
.text:004012A6 push edx ; envp
.text:004012A7 mov eax, argv
.text:004012AC push eax ; argv
.text:004012AD mov ecx, argc
.text:004012B3 push ecx ; argc
.text:004012B4 call _main

该反汇编代码对应的C代码如下

1
2
3
4
5
6
7
8
9
#ifdef WPRFLAG
​ __winitenv = _wenviron;
mainret = wmain(_argc,_wargv,_wenviron);
#else
__initenv = _environ;
mainret = main(_argc,_grav,_environ);
#endif


可以看到调用Main()函数时有三个参数,在三个PUSH操作后第一个CALL就是_main的函数地址。在004012C3地址处指令为 _exit,在判断程序是由VC6编译的,找到 _exit的调用,向上找到call指令就为 _main所对应的地址

双击反汇编的_main进入main函数的跳表,通过特征我们可以看到对sub_40100A、printf函数的调用。

函数入口部分地址如下:

1
2
3
4
5
6
7
8
9
10
.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,XXX的形式,完成了保存栈,并开辟当前函数的栈空间。push ebx/esi/edi是用来保存几个关键寄存器的值,以便函数返回后几个寄存器的值还能继续使用,lea edi, [ebp+var_44]到rep stosd的指令是将开辟的内存空间全部初始化为0XCC(int 3,调用3号断点产生软件中断),方便调试。上面代码固定形式,唯一改变的是sub esp,xxx指令,在VC6下使用Debug编译,若当前没有变量则为40h,有一个指令为44h,以此类推,函数编译时总是预留了40h字节的空间。

函数出口代码如下:

1
2
3
4
5
6
7
8
9
10
.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_0 endp

与入口代码相同,出口代码也是固定格式,使用pop指令将入口保存的关键寄存器值进行恢复,并且恢复esp指针的值,将临时开辟的栈空间释放掉,可以观察到开辟空间使用sub指令,释放空间使用add指令,由此判断栈的方向是由高向低地址沿伸的,然后使用retn返回上层函数。其中__chkesp函数是检测栈是否平衡,否则给出错误提示。

前面的函数入口代码和出口代码在每个函数中都是类似的,我们再来看下剩余的反汇编代码,如下:

1
2
3
4
5
6
7
8
9
10
11
.text:004010B8                 push    6               ; int
.text:004010BA push offset Text ; "hello"
.text:004010BF call sub_40100A
.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开始到mov [ebp+var_4],eax这一串指令是主函数对test函数的调用。在VC中,默认使用cddel函数对于参数的传递依靠栈内存依次从右往左送入栈中。在C代码中,我们对test()函数的调用形式如下:

1
int nNum = test("hello", 6);

所以第一个指令是PUSH 6,然后才是PUSH offset.接下来的add esp,8指令是将esp恢复到调用前的值,这里的8是将前面的PUSH操作释放内存空间。

函数返回值一般保存在eax寄存器中,mov [ebp+var_4],eax将test函数返回值保存在在[ebp+var_4]中,相当于C语言中的nNum变量。xor eax,eax指令将eax进行清零,即main()函数返回值为0。

接下来,我们来看下test函数,test函数跳表如下:

除去入口和出口代码,中间的代码主要为printf()函数和MessageBoxA()函数的反汇编代码。

调用printf()函数的反汇编代码如下:

1
2
3
4
5
6
7
.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()函数的反汇编代码如下:

1
2
3
4
5
6
.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:MessageBoxA

其中两个代码中间还有个add esp,oCh,是将esp恢复到函数调用前的值。而MessageBoxA()函数为Windows系统下的API函数,使用stdcall调用约定,参数的平栈是在API函数里面进行的。

这两个函数调用指令也存在区别,由于printf()函数是属于C语言的静态库,在连接时会将其代码二进制文件中,而MessageBoxA函数是在user32.dll这个动态连接库中,只保留了其入口地址,没有具体代码连接,而其代码在数据节中,使用”ds:”前缀

总结

在逆向分析函数时

  • 首先确定函数起始位置,通常由IDA自动识别
  • 掌握函数的调用约定和确定函数的参数个数,通过平栈的方式和平栈时对esp操作的值来进行判断
  • 最后观察函数的返回值,通常关注esp的值,可以确定返回值的类型,然后进一步考虑函数调用方下一步的操作。

启动函数

1
2
3
4
5
6
7
8
9
10
11
GetVersion(): 获取当前运行平台的版本号
_heap_init(): 用于初始化堆空间,使用heapcreate申请堆空间,申请空间由_heap_init_传递的参数决定,_sbh_heap_init()用于初始化堆结构信息
GetCommandLineA(): 获取命令行参数信息的首地址
_crtGetenvironmentStringA(): 获取环境变量信息的首地址
_setargv(): 将获取的命令行参数进行分析,将分离出的参数个数保存在全局变量_argc中,将命令行参数首地址存放在全局变量_argv中
_setenvp(): 获取环境变量信息的首地址,将环境变量首地址放在全局变量env中

_argc、_argv、env这三个全局变量作为参数

_cinit(): 用于全局数据和浮点寄存器的

调用流程

  • 参数传递 。

    通过栈或者寄存器方式传递参数

  • 函数调用,返回地址压栈。

    使用call指令调用参数,将返回地址压入栈中

  • 保存栈底。

    使用栈底空间保存调用方式的栈底寄存器ebp

  • 申请栈空间和保存寄存器环境。

    根据函数内局部变量的大小抬高栈顶让出对应的栈空间,并且将修改的寄存器保存在栈内

  • 函数实现代码

​ 函数实现过程的代码

  • 还原环境

    还原栈中保存的寄存器信息

  • 平衡栈空间

    平衡局部变量使用的栈空间

  • ret返回,结束函数调用

    在栈顶取出保存的返回地址,更新EIP

    在非_cdecl调用方式下,平衡参数占用栈空间

  • 调整esp,平衡栈顶

    此处为_cdecl特有的方式,用于平衡参数占用的栈顶

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2021-2024 John Doe
  • 访问人数: | 浏览次数:

让我给大家分享喜悦吧!

微信