第十三章:C语言逆向(三):函数调用、递归、函数指针、回调函数的反汇编分析

好,咱们继续往下挖。前面聊了变量、循环、分支,今天轮到函数了。函数这东西,高级语言里看着挺规整,但到了汇编层面,其实就一套固定的“套路”。你只要摸清了这个套路,反汇编里看函数调用,就跟看剧本一样清晰。

我个人习惯,拿到一个二进制文件,先找函数边界。为什么?因为函数是代码组织的基本单元,搞清楚了函数怎么进、怎么出,整个程序的骨架就出来了。

13.1 函数调用的底层真相:call 与 ret

高级语言里写一句 func(a, b);,编译器背后干了三件事:

  1. 传参:把 a 和 b 放到约定好的位置(寄存器或栈)
  2. 跳转:保存返回地址,跳到 func 的入口
  3. 返回:func 执行完,跳回 call 的下一条指令

对应的汇编指令就是 callret。咱们看个例子:

// 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 就会跳到错误的地方——这就是栈溢出攻击的核心原理。

⚠ 我曾经在分析一个 IoT 固件时,发现某个函数返回后程序崩溃。跟踪了半天,发现是栈上的返回地址被一个缓冲区溢出覆盖了。嗯,从那以后,我每次逆向都会特别留意函数 prologue 和 epilogue 是否完整。

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,被调用者自己清理栈。

💡 一个小技巧:在 IDA Pro 或 Ghidra 里,看到 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 raxcall [rsp+...] 这种形式,十有八九是函数指针或虚函数调用。这时候你需要跟踪那个寄存器的值来自哪里,才能确定到底调用了哪个函数。

🔍 我个人习惯:遇到间接调用,先在 IDA 里给那个寄存器或内存地址加注释,然后回溯赋值链。很多时候,函数指针的值是在某个初始化函数里写死的,找到那里就真相大白了。

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 知识体系总览

下面这张图总结了函数调用相关的反汇编核心知识点,建议你保存下来对照学习:

函数调用反汇编知识体系 函数调用 调用约定 cdecl: 调用者清理栈 stdcall: 被调用者清理 fastcall: 寄存器传参 System V: x86-64标准 递归调用 自身调用自身 每次调用分配新栈帧 注意栈溢出风险 可优化为迭代 函数指针 存放代码地址的变量 间接调用: call rax 需回溯寄存器来源 常见于插件/驱动 回调函数 函数指针作为参数 事件驱动/异步编程 需追踪参数来源 逆向分析要点 1. 识别函数 prologue/epilogue 2. 区分直接调用 vs 间接调用 3. 回溯函数指针/回调的来源

13.7 实战建议:逆向函数调用的三步法

最后,我总结一个自己的逆向套路,遇到函数相关代码就这么干:

  1. 找边界:先看函数开头有没有 push rbp; mov rbp, rsp(或 sub rsp, ...),结尾有没有 leave; ret。这能帮你快速定位函数范围。
  2. 看调用方式:是 call 地址 还是 call 寄存器?直接调用直接跳转到目标函数分析;间接调用需要回溯寄存器的值来自哪里。
  3. 分析参数和返回值:根据调用约定,看参数是从寄存器传的还是从栈传的。返回值一般看 eax/rax。
💡 一个小工具推荐:用 objdump 或 IDA 的交叉引用功能(XREF),可以快速找到所有调用某个函数的地方。这在分析回调函数时特别有用——你只需要找到所有间接调用的位置,然后看它们引用了哪些函数地址。

好了,函数调用这块就聊到这儿。记住,汇编层面的函数调用就那么几个模式,看多了自然就熟了。下次你拿到一个二进制文件,先按我说的三步法走一遍,函数的脉络就清清楚楚了。


公众号:蓝海资料掘金营,微信deep3321