第十三章:C语言逆向(三):函数调用、递归、函数指针、回调函数的反汇编分析
好,咱们继续往下挖。前面聊了变量、循环、分支,今天轮到函数了。函数这东西,高级语言里看着挺规整,但到了汇编层面,其实就一套固定的“套路”。你只要摸清了这个套路,反汇编里看函数调用,就跟看剧本一样清晰。
我个人习惯,拿到一个二进制文件,先找函数边界。为什么?因为函数是代码组织的基本单元,搞清楚了函数怎么进、怎么出,整个程序的骨架就出来了。
13.1 函数调用的底层真相:call 与 ret
高级语言里写一句 func(a, b);,编译器背后干了三件事:
- 传参:把 a 和 b 放到约定好的位置(寄存器或栈)
- 跳转:保存返回地址,跳到 func 的入口
- 返回:func 执行完,跳回 call 的下一条指令
对应的汇编指令就是 call 和 ret。咱们看个例子:
// C 代码
int add(int x, int y) {
return x + y;
}
void main() {
int sum = add(3, 4);
}
反汇编后大概长这样(x86-64,GCC 编译,未优化):
; main 函数中调用 add
mov edi, 3 ; 第一个参数放到 edi
mov esi, 4 ; 第二个参数放到 esi
call add ; call 指令:push 返回地址,然后 jmp 到 add
mov dword [rbp-4], eax ; 返回值在 eax 中
; add 函数内部
add:
push rbp
mov rbp, rsp
mov dword [rbp-4], edi ; 保存参数
mov dword [rbp-8], esi
mov eax, dword [rbp-4]
add eax, dword [rbp-8] ; x + y
pop rbp
ret ; ret 指令:pop 返回地址,然后 jmp 过去
这里有个细节:call 其实等价于 push 返回地址; jmp 目标地址。而 ret 等价于 pop 返回地址; jmp 到该地址。你想想看,如果栈被破坏了,ret 就会跳到错误的地方——这就是栈溢出攻击的核心原理。
13.2 函数调用约定:谁负责清理栈?
不同编译器、不同平台,传参和清理栈的方式不一样。常见的就这几种:
| 调用约定 | 传参方式 | 栈清理者 | 常见平台 |
|---|---|---|---|
| cdecl | 从右向左压栈 | 调用者 | x86 Windows/Linux |
| stdcall | 从右向左压栈 | 被调用者 | x86 Windows API |
| fastcall | 前两个参数用 ecx/edx,其余压栈 | 被调用者 | x86 优化模式 |
| System V AMD64 | 前6个参数用 rdi/rsi/rdx/rcx/r8/r9 | 调用者 | x86-64 Linux/macOS |
我个人建议,逆向时先看函数开头有没有 push rbp; mov rbp, rsp,结尾有没有 leave; ret。如果有,大概率是 cdecl 或 System V 风格。如果函数结尾直接 ret 8 这种带数字的,那就是 stdcall,被调用者自己清理栈。
retn 0Ch 这种指令,说明函数自己弹掉了 12 字节的参数。这在逆向 Windows API 时特别常见。
13.3 递归的反汇编:自己调用自己
递归函数在汇编层面,其实就是函数内部又调用了自己。但有个关键点:每次调用都会在栈上分配新的栈帧,所以递归深度太深会导致栈溢出。
看个经典的阶乘递归:
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
反汇编后(简化版):
factorial:
push rbp
mov rbp, rsp
sub rsp, 16
mov dword [rbp-4], edi ; 保存 n
cmp dword [rbp-4], 1
jle .base_case ; n <= 1 时跳转
mov eax, dword [rbp-4]
sub eax, 1
mov edi, eax
call factorial ; 递归调用自己
imul eax, dword [rbp-4] ; n * factorial(n-1)
jmp .return
.base_case:
mov eax, 1
.return:
leave
ret
注意看:每次 call factorial 都会在栈上压入返回地址和旧的 rbp,然后分配新的局部变量空间。如果 n=10000,栈上就会有 10000 个栈帧——不出事才怪。
我在项目中遇到过一段递归代码,逆向时发现它递归深度只有几十层,但每次递归都分配了很大的局部数组。结果栈空间直接爆了,程序崩溃。后来我改成迭代实现,问题解决。所以逆向时看到递归,一定要留意栈帧大小和递归深度。
13.4 函数指针:代码里的“变量”
函数指针在高级语言里是个抽象概念,但在汇编层面,它就是一个存放代码地址的变量。说白了,就是内存里某个位置存了一个地址,这个地址指向一段可执行代码。
看个例子:
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
void main() {
int (*op)(int, int); // 函数指针
op = add;
int result = op(5, 3);
}
反汇编后:
; 将 add 函数的地址存入 op
lea rax, [add] ; 取 add 函数的地址
mov qword [rbp-8], rax ; 存入局部变量 op
; 通过 op 调用函数
mov rdi, 5
mov rsi, 3
call qword [rbp-8] ; 间接调用!不是直接 call add
mov dword [rbp-12], eax
关键区别:call qword [rbp-8] 是间接调用,它从内存中读取地址,然后跳转。而 call add 是直接调用,地址在指令中写死。
逆向时,如果你看到 call rax 或 call [rsp+...] 这种形式,十有八九是函数指针或虚函数调用。这时候你需要跟踪那个寄存器的值来自哪里,才能确定到底调用了哪个函数。
13.5 回调函数:把函数当参数传
回调函数本质上就是函数指针作为参数传递给另一个函数。这在事件驱动、异步编程里用得特别多。
看个简单的回调例子:
void callback(int x) {
printf("Value: %d\n", x);
}
void repeat(int n, void (*cb)(int)) {
for (int i = 0; i < n; i++) {
cb(i); // 调用回调
}
}
void main() {
repeat(5, callback);
}
反汇编中,repeat 函数内部会看到类似这样的代码:
; repeat 函数内部
; rdi = n, rsi = 函数指针 cb
.loop:
mov edi, dword [rbp-4] ; i
call qword [rbp-16] ; 调用回调函数 cb(i)
; ... 循环控制 ...
jmp .loop
这里 call qword [rbp-16] 就是回调调用的位置。逆向时,你需要找到 repeat 被调用时第二个参数传的是什么——那就是回调函数的地址。
我记得有一次逆向一个恶意软件,它用回调函数实现了一个定时任务。我一开始没注意那个间接调用,以为是普通函数。后来发现它每隔几秒就调用一次 call [rsp+...],才意识到这是个定时回调。顺着参数回溯,找到了真正的恶意行为函数。
13.6 知识体系总览
下面这张图总结了函数调用相关的反汇编核心知识点,建议你保存下来对照学习:
13.7 实战建议:逆向函数调用的三步法
最后,我总结一个自己的逆向套路,遇到函数相关代码就这么干:
- 找边界:先看函数开头有没有
push rbp; mov rbp, rsp(或sub rsp, ...),结尾有没有leave; ret。这能帮你快速定位函数范围。 - 看调用方式:是
call 地址还是call 寄存器?直接调用直接跳转到目标函数分析;间接调用需要回溯寄存器的值来自哪里。 - 分析参数和返回值:根据调用约定,看参数是从寄存器传的还是从栈传的。返回值一般看 eax/rax。
好了,函数调用这块就聊到这儿。记住,汇编层面的函数调用就那么几个模式,看多了自然就熟了。下次你拿到一个二进制文件,先按我说的三步法走一遍,函数的脉络就清清楚楚了。
公众号:蓝海资料掘金营,微信deep3321