Last updated on December 9, 2023 pm
C风格函数 在正式开始前,我们先讨论在C语言中实现各类逻辑功能的载体:函数。
一个标准的C语言程序“HelloWorld.c”可能会写作如下结构:
1 2 3 4 5 #include <stdio.h> int main (int argNums, char * args[], void * envir) { printf ("%s" , "Hello World!" ); return 0 ; }
这段程序以main 函数作为程序的入口,展开自己的逻辑:输出“Hello World!”
那么函数在汇编层面是怎么表示的呢?如何将参数传递给函数,函数又如何将返回值传递给调用者?
这就需要了解函数的调用约定。
函数调用约定 在无特别说明的情况下,32位编译器会将C代码按cdecl约定进行编译。
在32位汇编层面,我们有如下函数调用约定:
约定
参数传递
栈平衡
cdecl
从右到左依次压栈
外平栈(调用者负责平衡栈)
stdcall
从右到左依次压栈
内平栈(函数内部平衡栈,无需调用者参与)
fastcall
前两个参数依次放入ECX,EDX寄存器,其他参数从右到左依次压栈
内平栈
在x64汇编环境下,仅有fastcall一种调用约定,前四个参数依次放入RCX,RDX,R8,R9四个寄存器,其他参数从右到左依次压栈,函数内部平栈。
汇编程序通过 栈 结构将参数传递给函数,实际上函数内部使用的局部变量也存储在栈空间上。
Windows系统的栈设计 栈空间的总量由系统分配,从高内存向低内存扩展,即内存减少为栈抬高的方向。
通俗来说:
执行压栈指令push 时,栈顶指向的内存地址将减少;
执行弹栈指令 pop 时,栈顶指向的内存地址将增加。
函数序言 多数C风格函数的入口会有如下代码:
ebp存储了栈底空间地址,此代码在函数入口将ebp压栈,保存了之前的栈底,确保栈帧之间的连续性。
这段代码也被称为函数序言 , 需要注意的是,不一定所有的C语言函数都严格遵守这个规则。
栈空间 栈帧指针 esp寄存器在不进行人为干涉的情况下,始终存储栈顶地址。
使用pop,push,call,ret等指令时,都会影响esp寄存器的值。
esp寄存器也被称为栈帧指针
帧基指针 由于ebp寄存器始终存储栈底,即栈帧的基地址,所以ebp寄存器也被称为帧基指针 。
常见的使用方式:
1 2 3 4 mov eax , dword ptr ds :[ebp ] mov eax , dword ptr ds :[ebp +0x04 ] mov eax , dword ptr ds :[ebp +0x08 ] mov eax , dword ptr ds :[ebp -0x04 ]
局部变量缓冲区 在函数序言结束后,如果函数内部需要使用局部变量,一般会在栈上开辟局部变量的空间。
部分编译器(如微软的msvs)在debug模式下编译C语言代码时,会在局部变量空间内填充0xCC,即int 3中断。
形如下面格式:
1 2 3 4 5 6 7 8 sub esp , 0x40 push ebx push esi push edi lea edi , dword ptr ds :[ebp - 0x40 ]mov eax , 0xcccccccc mov ecx , 0x10 rep stosd
函数尾声 在函数即将结束时,会销毁开辟的栈空间,并将帧指针与帧基指针还原到函数调用时的状态。
形如下面格式:
1 2 3 4 5 6 pop edi pop esi pop ebx mov esp , ebp pop ebp ret
这样的模板代码也被称为函数尾声 ,需要注意的是,与函数序言 相同,并不是所有函数都严格遵守这个形式。
栈平衡 由于参数通过栈结构传递,因此函数的调用需要保证:
函数调用前后的esp/ebp寄存器值相同,即栈平衡。
cdecl 风格的函数一般定义如下:
1 void __cdecl func (int i) ;
该函数由调用者平衡栈,即:
1 2 3 4 mov ebx , dword ptr ds :[ebp - 0x04 ]push ebx call func add esp , 0x04
空函数与裸函数区别 在C语言中,空函数与裸函数对编译器而言完全不同,如下形式的两个函数:
1 2 3 4 5 6 7 void __declspec(naked) naked_func(int i){ }void empty_func (int i) { }
被 __declspec(naked) 修饰的函数 naked_func 在编译阶段不会生成任何汇编代码,所有工作都需要编码者自行实现。
而空函数 empty_func 中虽然也没有写任何程序逻辑,但是编译器会为其生成 函数序言 、 函数尾声 等模板代码。
这意味着调用一个空函数不会引发异常,但是调用一个未被编码的裸函数时,会发生程序异常(裸函数无法产生返回,debug模式下程序会走入“int 3 海洋”)。
自实现裸函数示例 如下形式的C语言函数:
1 2 3 4 5 6 7 int plus (int x, int y, int z) { int a = 2 ; int b = 3 ; int c = 4 ; return a + b + c + x + y + z; }
改为用裸函数实现:
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 int __declspec(naked) plus(int x, int y, int z) { __asm { push ebp mov ebp, esp sub esp, 0x0c push ebx push esi push edi lea edi, dword ptr ds:[ebp - 0x0c ] mov eax, 0xcccccccc mov ecx, 0x03 rep stosd mov dword ptr ds:[ebp - 0x04 ], 2 mov dword ptr ds:[ebp - 0x08 ], 3 mov dword ptr ds:[ebp - 0x0c ], 4 mov eax, dword ptr ds:[ebp + 0x08 ] add eax, dword ptr ds:[ebp + 0x0c ] add eax, dword ptr ds:[ebp + 0x10 ] add eax, dword ptr ds:[ebp - 0x04 ] add eax, dword ptr ds : [ebp - 0x08 ] add eax, dword ptr ds : [ebp - 0x0c ] pop edi pop esi pop ebx mov esp, ebp pop ebp ret } }
编写代码测试,结果如下:
真正的程序入口 编程中我们会习惯将int main()函数作为程序入口,在 Windows MFC 程序中会将WinMain 函数作为程序入口。
然而这些“入口”都只是逻辑入口,并不是程序启动的真正入口。
接下来以 示例程序 CallingConvention.exe 进行调试演示,论证这一观点。
逆向示例 启动调试器挂载目标程序,看到当前运行在系统领空:
输入快捷键ALT+F9 运行至用户代码,可以看到此时来到了EntryPoint
此时才是程序真正的入口,而不是我们的逻辑入口main 函数
寻找main函数 已知C代码中main 函数的原始定义如下:
1 int main (int argNum, void * args[], void * envir)
即函数声明了三个参数,根据我们之前对cdecl 风格的调用约定的猜想,调用main 函数大致应该如下:
1 2 3 4 5 push xxxpush xxxpush xxxcall mainadd esp , 0x0c
按照此猜想开始单步调试程序,看到如下代码片段,相似度很高:
观察此段代码:
1 2 3 4 5 6 7 8 00401280 mov edx ,dword ptr ds :[4225FC]00401286 push edx 00401287 mov eax ,dword ptr ds :[4225F4] 0040128C push eax 0040128D mov ecx ,dword ptr ds :[4225F0]00401293 push ecx 00401294 call callingconvention.401014 00401299 add esp ,C
步入0x00401014处观察,正是main函数无疑:
核心逻辑分析 接下来我们开始逐行分析程序行为,首先看到熟悉的函数序言 :
1 2 3 4 5 6 7 8 9 10 00401110 | push ebp 00401111 | mov ebp ,esp 00401113 | sub esp ,44 00401116 | push ebx 00401117 | push esi 00401118 | push edi 00401119 | lea edi ,dword ptr ss :[ebp -44 ] 0040111C | mov ecx ,11 00401121 | mov eax ,CCCCCCCC 00401126 | rep stosd
这个函数的代码逻辑并不复杂,因为我们发现代码并未运行很长段落,就遇到了函数尾声 。
函数内又涉及调用其他函数,根据逆向的经验法则,我们并不急于追踪每一个函数调用,先分析在main 函数中的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 00401128 | push 7 | 0040112A | push 6 | 0040112C | push 4 | 0040112E | mov edx ,3 |00401133 | mov ecx ,1 |00401138 | call callingconvention.401019 | 函数调用,可能是fastcall约定0040113D | mov dword ptr ss :[ebp -4 ],eax | 返回值保存在局部变量 a00401140 | mov eax ,dword ptr ss :[ebp -4 ] | 00401143 | push eax |00401144 | push callingconvention. 41F10C | 使用 a 和"%d" 作为入参,猜测是printf函数00401149 | call callingconvention. 40B7E0 | 打印计算结果? 0040114E | add esp ,8 | 外平栈,猜测是cdecl约定00401151 | xor eax ,eax | main函数返回0 ,正常退出00401153 | pop edi | 还原现场00401154 | pop esi |00401155 | pop ebx |00401156 | add esp ,44 | 销毁栈空间00401159 | cmp ebp ,esp | 0040115B | call callingconvention.401170 | 编译器栈平衡检测00401160 | mov esp ,ebp | 00401162 | pop ebp | 还原帧基指针00401163 | ret | main函数结束
根据这一轮分析,我们猜测main函数中的核心逻辑就是 callingconvention.401019
核心逻辑大致脉络 进入核心逻辑函数,此时栈空间分布情况:
1 2 3 4 0019FED0 0040113D 0019FED4 00000004 0019FED8 00000006 0019FEDC 00000007
之前怀疑该函数遵守fastcall 调用约定,因此记录下ecx、edx寄存器的值:
寄存器
值
ecx
00000001
edx
00000003
将函数的序言部分、尾声部分删减掉不看,直接观察函数的运行逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 004010BA | mov dword ptr ss :[ebp -8 ],edx | 第二个参数保存为局部变量b 004010BD | mov dword ptr ss :[ebp -4 ],ecx | 第一个参数保存为局部变量a 004010C0 | mov eax ,dword ptr ss :[ebp +8 ] | eax = 参数3 004010C3 | push eax | 004010C4 | mov ecx ,dword ptr ss :[ebp -8 ] | ecx = b 004010C7 | push ecx | 004010C8 | mov edx ,dword ptr ss :[ebp -4 ] | edx = a 004010CB | push edx | 004010CC | call callingconvention. 40101E | 猜测:func1(a, b, 参数3 ) 004010D1 | mov dword ptr ss :[ebp -C],eax | 返回值保存为局部变量c 004010D4 | mov eax ,dword ptr ss :[ebp -8 ] | eax = b 004010D7 | push eax | 004010D8 | mov ecx ,dword ptr ss :[ebp -4 ] | ecx = a 004010DB | push ecx | 004010DC | call callingconvention.401005 | 猜测:func2(a, b) 004010E1 | add esp ,8 | 004010E4 | mov dword ptr ss :[ebp -10 ],eax | 返回值保存为局部变量d 004010E7 | mov edx ,dword ptr ss :[ebp -10 ] | edx = d 004010EA | push edx | 004010EB | mov eax ,dword ptr ss :[ebp -C] | eax = c 004010EE | push eax | 004010EF | call callingconvention.401005 | 猜测:func3(c, d) 004010F4 | add esp ,8 |
此时需要分别逆向func1\func2\func3这三个子函数的逻辑,我们先记录下子函数地址:
callingconvention.40101E ; func1
callingconvention.401005 ; func2
callingconvention.401005 ; func3
发现func2与func3函数地址相同,实际上是同一个函数的多次调用而已。
callingconvention.40101E func1 分析 步入func1,记录此时栈空间分布:
1 2 3 4 0019FE60 004010D1 0019FE64 00000001 0019FE68 00000003 0019FE6C 00000004
同样删减掉函数尾声与序言,观察核心逻辑:
1 2 3 00401078 | mov eax ,dword ptr ss :[ebp +8 ] | eax = 参数1 0040107B | add eax ,dword ptr ss :[ebp +C] | eax += 参数2 0040107E | add eax ,dword ptr ss :[ebp +10 ] | eax += 参数3
可知func1是个累加函数,将3个参数相加后返回,观察其函数尾声,可知是遵循stdcall 调用约定, 即参数从右到左入栈、内部平栈:
1 2 3 00401084 | mov esp ,ebp |00401086 | pop ebp |00401087 | ret C | 内平栈
预期当次调用返回时eax的值等于0x08,观测结果与预期一致:
callingconvention.401005 func2 分析 步入func1,记录此时栈空间分布:
1 2 3 0019FE64 004010E1 0019FE68 00000001 0019FE6C 00000003
同样删减掉函数尾声与序言,观察核心逻辑:
1 2 00401048 | mov eax ,dword ptr ss :[ebp +8 ] | eax = 参数1 0040104B | add eax ,dword ptr ss :[ebp +C] | eax += 参数2
可知func2也只是个累加函数,根据其尾声与入参方式,可知是遵循cdecl约定:
1 2 3 00401051 | mov esp ,ebp |00401053 | pop ebp |00401054 | ret | 外平栈
预期当次调用返回时eax的值等于0x04,观测结果与预期一致:
回到核心逻辑 我们来看看核心逻辑剩余未被分析的部分:
1 2 3 4 5 6 7 004010E4 | mov dword ptr ss :[ebp -10 ],eax | 返回值保存为局部变量d=4 004010E7 | mov edx ,dword ptr ss :[ebp -10 ] | edx = d = 4 004010EA | push edx | 004010EB | mov eax ,dword ptr ss :[ebp -C] | eax = c = 8 004010EE | push eax | 004010EF | call callingconvention.401005 | 累加(c, d) 004010F4 | add esp ,8 | 预期 eax = 0xc
直接执行,观测预期与结果一致:
此时函数核心逻辑运行结束,观测函数尾声,验证我们之前的猜想,这个函数遵循fastcall约定:
1 2 3 4 5 6 7 8 9 004010F7 | pop edi | 004010F8 | pop esi | 004010F9 | pop ebx | 004010FA | add esp ,50 | 004010FD | cmp ebp ,esp | 004010FF | call callingconvention.401170 |00401104 | mov esp ,ebp |00401106 | pop ebp |00401107 | ret C | 内平栈,降低12 字节
该函数使用ecx、edx寄存器,并在尾声中平栈12字节,因此可以确定是遵循fastcall约定的函数
逆向分析结束 main函数接下来打印了计算结果,与我们分析的一致,使用了printf 函数输出结果:12
程序运行至此结束,我们完成了对这个示例程序的逆向分析。
接下来就要根据分析结论进行代码还原了。
反汇编还原为C代码 根据逆向分析结果,尝试还原C代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #include <stdio.h> int __stdcall func1 (int x, int x2, int x3) { return x + x2 + x3; }int __cdecl func2 (int x, int x2) { return x + x2; }int __fastcall plus (int x, int x2, int x3, int x4, int x5) { int a = x; int b = x2; int c = func1(a, b, x3); int d = func2(a, b); return func2(c, d); }int main (int numbs, char * args[]) { int a = plus(1 , 3 , 4 , 6 , 7 ); printf ("%d\r\n" , a); return 0 ; }
检查输出结果与示例程序输出是否一致:
至此,逆向分析结束。
写在最后 示例程序虽然简单,但是使用到的分析技巧却是真实逆向工程中常用的分析技术。
读者应自行下载示例程序并动手逆向,在过程中不断思考才是提升自身逆向水平的唯一道路。