第12章 反汇编进阶:函数调用约定与栈帧结构
说实话,函数调用约定这玩意儿,刚入行时我觉得挺枯燥的。不就是参数怎么传、谁清理栈嘛,背下来就行了。直到有一次我在逆向一个被混淆的恶意软件时,发现它混用了多种调用约定,栈帧完全乱套——那才叫一个酸爽。从那以后,我每次分析二进制前都会先确认调用约定,这已经成了肌肉记忆。
今天咱们就把这四种常见的调用约定——cdecl、stdcall、fastcall、thiscall——彻底讲透。你想想看,搞逆向如果连栈帧都看不懂,那跟盲人摸象有啥区别?
12.1 调用约定到底在解决什么问题?
说白了,调用约定就是函数之间的一纸「契约」。它规定了三件事:
- 参数怎么传:走栈还是走寄存器?
- 参数谁清理:调用者负责还是被调用者负责?
- 返回值放哪:通常放EAX,但有些约定有例外
我个人的习惯是,拿到一个陌生二进制,先看函数开头和结尾的指令序列。光看这几条指令,就能猜出它用的是哪种约定。不信?咱们往下看。
核心观点:调用约定决定了栈帧的布局。搞懂约定,你就能在反汇编中准确追踪参数和局部变量。
12.2 cdecl——C语言的默认约定
cdecl是C/C++中最常见的调用约定,也是GCC和MSVC的默认选项。它的规则很简单:
- 参数从右向左压栈
- 调用者负责清理栈
- 返回值放在EAX
来看个例子。假设我们有这样一个C函数:
int add(int a, int b, int c) {
return a + b + c;
}
反汇编后大概长这样:
; 调用者视角
push c ; 最后一个参数先入栈
push b
push a ; 第一个参数最后入栈
call add
add esp, 12 ; 调用者清理栈(3个参数 × 4字节)
; 被调用者视角(add函数内部)
push ebp
mov ebp, esp
mov eax, [ebp+8] ; a
add eax, [ebp+12] ; b
add eax, [ebp+16] ; c
pop ebp
ret
注意看,调用者在call之后手动加了add esp, 12。这就是cdecl的标志——谁调用谁擦屁股。我在逆向一些老旧DLL时经常看到这种模式,尤其是那些用Borland C++编译的库。
实战技巧:cdecl支持可变参数(如printf),因为只有调用者才知道到底传了多少个参数,所以必须由调用者清理栈。如果你在反汇编中看到printf类的函数,那一定是cdecl。
12.3 stdcall——Windows API的标配
stdcall是Win32 API的标准约定。和cdecl最大的区别是:被调用者清理栈。
- 参数从右向左压栈
- 被调用者用
ret n清理栈 - 返回值放在EAX
同样的add函数,用stdcall编译后:
; 调用者视角
push c
push b
push a
call add ; 注意:调用后没有add esp, 12
; 被调用者视角(add函数内部)
push ebp
mov ebp, esp
mov eax, [ebp+8]
add eax, [ebp+12]
add eax, [ebp+16]
pop ebp
ret 12 ; 被调用者清理12字节
看到那个ret 12了吗?这就是stdcall的签名。我曾经在分析一个Rootkit时,发现它用stdcall但参数个数对不上,结果栈帧全乱了,程序直接崩溃。嗯,那家伙写代码时肯定没仔细看MSDN。
注意:stdcall不支持可变参数。因为被调用者不知道调用者传了多少参数,没法决定清理多少栈。如果你看到可变参数函数用了stdcall,那八成是编译器的bug或者人为的混淆。
12.4 fastcall——用寄存器加速
fastcall的初衷是减少栈操作,提升性能。它把前两个参数塞进寄存器(ECX和EDX),剩下的参数走栈。
- 前两个参数:ECX、EDX(MSVC风格)
- 剩余参数:从右向左压栈
- 被调用者清理栈
反汇编示例:
; 调用者视角
mov ecx, a ; 第一个参数进ECX
mov edx, b ; 第二个参数进EDX
push c ; 第三个参数压栈
call add
; 被调用者视角
push ebp
mov ebp, esp
; 注意:ECX和EDX的值可能已经被函数内部修改了
; 所以很多编译器会先把它们保存到栈上
mov [ebp-4], ecx ; 保存a
mov [ebp-8], edx ; 保存b
mov eax, [ebp-4]
add eax, [ebp-8]
add eax, [ebp+8] ; c在栈上
pop ebp
ret 4 ; 只清理c(4字节)
我个人觉得fastcall在逆向时最坑爹的地方在于:ECX和EDX在函数内部可能被当作临时变量使用,你很难一眼看出哪个是参数。我一般会先看函数开头有没有mov [ebp-xx], ecx这样的指令,如果有,那基本就是fastcall了。
避坑指南:不同编译器对fastcall的实现有细微差别。GCC的fastcall用ECX和EDX,但参数顺序可能和MSVC相反。我曾经在逆向一个跨平台程序时踩过这个坑,折腾了两天才发现是编译器差异导致的。
12.5 thiscall——C++成员函数的专属约定
thiscall是C++成员函数用的。它的核心是:this指针通过ECX传递。
- this指针放在ECX
- 其他参数从右向左压栈
- 被调用者清理栈(MSVC)或调用者清理(GCC)
来看一个简单的C++类:
class MyClass {
public:
int calc(int x, int y) {
return this->base + x + y;
}
private:
int base;
};
反汇编后:
; 调用者视角
lea ecx, [this] ; this指针放入ECX
push y
push x
call MyClass::calc
; 被调用者视角(calc函数内部)
push ebp
mov ebp, esp
push ecx ; 保存this指针到局部变量
mov eax, [ebp-4] ; 取出this
mov eax, [eax] ; 取出this->base
add eax, [ebp+8] ; + x
add eax, [ebp+12] ; + y
pop ebp
ret 8 ; 清理x和y(8字节)
注意那个push ecx——这是thiscall的典型特征。我在逆向一个大型C++游戏引擎时,发现到处都是这种模式。如果你看到ECX被当作指针使用,而且函数开头有保存ECX的操作,那十有八九是thiscall。
重要提醒:thiscall在GCC和MSVC下的实现不同。GCC会把this指针当作第一个参数(压栈),而MSVC用ECX。所以如果你在逆向Linux下的C++程序,看到的可能是cdecl风格的调用,但this指针在栈上。
12.6 栈帧结构——一张图看懂
说了这么多,咱们用一张SVG图把四种调用约定的栈帧结构总结一下。这张图我画了好一会儿,希望能帮你建立直观印象。
12.7 实战中如何快速识别?
好了,理论讲完了,咱们来点干货。在逆向实战中,我总结了一套快速识别调用约定的方法:
- 看函数结尾:
ret后面有没有数字?有就是stdcall或fastcall,没有就是cdecl。 - 看函数开头:有没有
push ecx或mov [ebp-xx], ecx?有就是fastcall或thiscall。 - 看调用处:call后面有没有
add esp, xx?有就是cdecl。 - 看this指针:如果ECX被当作指针使用(比如
mov eax, [ecx]),那就是thiscall。
我的经验:遇到不确定的情况,我会在IDA中看函数的调用次数。如果同一个函数被多处调用,而且调用后都有add esp, xx,那基本就是cdecl。如果调用后没有清理指令,那就是stdcall。这个方法在分析大型二进制时特别管用。
12.8 总结
函数调用约定和栈帧结构,是逆向工程的基石。你想想看,如果你连参数在哪、栈怎么清理都搞不清楚,那分析再复杂的算法也是白搭。
我个人建议,初学者可以先用一个小程序,分别用不同的调用约定编译,然后对比反汇编结果。亲手做一遍,比看十遍书都管用。我记得我第一次做这个练习时,发现同一个C代码编译出来的汇编竟然差别这么大,那种震撼至今难忘。
好了,这一章就到这里。记住:搞逆向,栈帧就是你的地图。地图看懂了,路就好走了。
公众号:蓝海资料掘金营,微信deep3321