C语言逆向(1)函数调用

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风格函数的入口会有如下代码:

1
2
push ebp
mov ebp, esp

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 ;开辟64字节的局部变量空间
push ebx
push esi
push edi ;保存现场
lea edi, dword ptr ds:[ebp - 0x40]
mov eax, 0xcccccccc
mov ecx, 0x10
rep stosd ;将开辟出的64字节空间全部用0xcc填充

函数尾声

在函数即将结束时,会销毁开辟的栈空间,并将帧指针与帧基指针还原到函数调用时的状态。

形如下面格式:

1
2
3
4
5
6
pop edi
pop esi
pop ebx
mov esp, ebp ;销毁栈空间
pop ebp ;还原帧基指针
ret ;变形即 pop eip

这样的模板代码也被称为函数尾声,需要注意的是,与函数序言相同,并不是所有函数都严格遵守这个形式。

栈平衡

由于参数通过栈结构传递,因此函数的调用需要保证:

函数调用前后的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
//结果保存在eax, pop eip
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 xxx
push xxx
push xxx
call main
add 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 ;压栈了3个参数,栈抬高0xc字节
00401299 add esp,C ;降低0xc字节栈,平衡了3个参数

步入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 | 返回值保存在局部变量 a
00401140 | 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 ;参数3
0019FED8 00000006 ;参数4
0019FEDC 00000007 ;参数5

之前怀疑该函数遵守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这三个子函数的逻辑,我们先记录下子函数地址:

  1. callingconvention.40101E ; func1
  2. callingconvention.401005 ; func2
  3. callingconvention.401005 ; func3

发现func2与func3函数地址相同,实际上是同一个函数的多次调用而已。

callingconvention.40101E func1 分析

步入func1,记录此时栈空间分布:

1
2
3
4
0019FE60                 004010D1 ; 返回004010D1,mov dword ptr ss:[ebp-C],eax
0019FE64 00000001 ; 参数1
0019FE68 00000003 ; 参数2
0019FE6C 00000004 ; 参数3

同样删减掉函数尾声与序言,观察核心逻辑:

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 ; 返回004010E1,add esp,8
0019FE68 00000001 ; 参数1
0019FE6C 00000003 ; 参数2

同样删减掉函数尾声与序言,观察核心逻辑:

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;
}

检查输出结果与示例程序输出是否一致:

至此,逆向分析结束。

写在最后

示例程序虽然简单,但是使用到的分析技巧却是真实逆向工程中常用的分析技术。

读者应自行下载示例程序并动手逆向,在过程中不断思考才是提升自身逆向水平的唯一道路。


C语言逆向(1)函数调用
http://dubhehub.github.io/blogs/202312081021542315.html
Author
Sin
Posted on
December 8, 2023
Licensed under