4、控制流分析:基本块与控制流图、循环与条件分支识别、函数调用约定与栈帧分析

控制流分析,说白了就是搞清楚程序是怎么跑的。从哪条指令开始,遇到分支怎么选,循环怎么转,函数调用怎么跳回来。我刚开始做逆向的时候,拿到一个二进制文件就盯着汇编看,看得头晕眼花。后来才明白,先画控制流图,再分析基本块,这才是正经路子。

你想想看,一个程序动辄几万条指令,一条条看下去,人早疯了。但如果你把它拆成基本块,再连成控制流图,整个程序的骨架就出来了。嗯,这就是我们今天要聊的核心。

4.1 基本块与控制流图

基本块是什么?就是一段顺序执行的指令序列。入口唯一,出口唯一,中间没有分支,没有跳转。说白了,只要进了这个块,就必须一条条执行到底,不会中途拐弯。

我个人习惯用IDA或者Ghidra来生成控制流图。但理解背后的原理更重要。基本块的划分规则其实很简单:

  • 入口点:程序入口、函数入口、跳转目标地址
  • 出口点:跳转指令、条件分支、函数调用、返回指令

举个例子,看这段x86汇编:

0x1000: mov eax, [ebp+8]
0x1003: cmp eax, 0
0x1006: jle 0x1015
0x1008: add eax, 10
0x100b: mov [ebp-4], eax
0x100e: push eax
0x100f: call printf
0x1014: ret
0x1015: mov eax, 0
0x101a: ret

这里的基本块划分是这样的:

  • 块A:0x1000-0x1006(入口0x1000,出口jle)
  • 块B:0x1008-0x1014(入口0x1008,出口ret)
  • 块C:0x1015-0x101a(入口0x1015,出口ret)

控制流图就是把这些块连起来。块A有两个后继:块B(条件不成立时)和块C(条件成立时)。块B和块C都没有后继(因为ret结束了)。

核心要点:控制流图是有向图,节点是基本块,边是控制流转移。每个函数对应一个控制流图,入口节点是函数的第一条指令。

我在项目中遇到过一个问题:有些混淆器会插入大量无用的跳转指令,导致基本块数量暴增。这时候就需要做基本块合并——如果两个块之间只有一条无条件跳转,而且没有其他块跳转到中间,就可以合并。

控制流图示例 基本块A mov eax, [ebp+8] cmp eax, 0 jle 0x1015 条件成立 (eax ≤ 0) 条件不成立 (eax > 0) 基本块B add eax, 10 call printf ret 基本块C mov eax, 0 ret 图例: 基本块(顺序执行) 条件不成立分支 条件成立分支

4.2 循环与条件分支识别

循环在控制流图上长什么样?说白了就是有环。一个基本块通过某种路径能回到自己,那就是循环。我刚开始学的时候,觉得循环识别很难,后来发现其实就是找后向边。

后向边是什么?在深度优先遍历中,从后代节点指向前辈节点的边。比如这样:

// 一个简单的循环结构
0x2000: mov ecx, 10        ; 循环计数器
0x2003: push ecx
0x2004: call some_func
0x2009: dec ecx
0x200a: jnz 0x2003         ; 后向边!跳回循环体
0x200c: ret

这里0x200a的jnz跳到了0x2003,而0x2003在控制流图中是0x200a的前驱节点。这就是典型的后向边,标志着循环的存在。

实战技巧:识别循环时,我习惯先找后向边,然后反向遍历找到循环入口(也叫循环头)。循环头是循环中所有路径都会经过的第一个节点。这个节点在符号执行中特别重要,因为循环展开往往从这里开始。

条件分支就更好理解了。jz、jnz、jg、jl这些指令,每个都对应两条出边。我曾经遇到过一个坑:有些编译器会优化条件分支,比如把if-else改成条件移动指令(cmov)。这时候控制流图上就看不到分支了,但语义上仍然是分支。嗯,这里要注意,控制流图反映的是静态结构,不是语义。

4.3 函数调用约定与栈帧分析

函数调用约定,说白了就是调用者和被调用者之间的「君子协定」。谁负责清理栈?参数怎么传?返回值放哪?不同平台、不同编译器,约定都不一样。

我整理了一份常见的调用约定对比表:

调用约定 参数传递 栈清理者 返回值 常见平台
cdecl 栈(从右到左压栈) 调用者 EAX x86 Windows/Linux
stdcall 栈(从右到左压栈) 被调用者 EAX x86 Windows API
fastcall ECX、EDX传前两个,其余栈 被调用者 EAX x86 Windows/Linux
System V AMD64 RDI、RSI、RDX、RCX、R8、R9,其余栈 调用者 RAX x86-64 Linux/macOS
Microsoft x64 RCX、RDX、R8、R9,其余栈 调用者 RAX x86-64 Windows

栈帧分析是逆向的核心技能。每次函数调用,都会在栈上分配一块区域,用来存局部变量、保存的寄存器、返回地址。典型的函数序言是这样的:

push ebp          ; 保存旧的基址指针
mov ebp, esp      ; 设置新的基址指针
sub esp, 0x20     ; 分配局部变量空间(32字节)

对应的函数尾声:

mov esp, ebp      ; 恢复栈指针
pop ebp           ; 恢复旧的基址指针
ret               ; 返回

避坑指南:我曾经在分析一个加了混淆的二进制时,发现函数序言被拆成了好几块,push ebp和mov ebp, esp之间插了十几条无关指令。这时候不能只看开头几条指令,要跟踪esp和ebp的变化,才能确定真正的栈帧边界。

栈帧分析中还有一个关键点:局部变量的位置。通常通过ebp的偏移来访问:

mov eax, [ebp-4]   ; 第一个局部变量
mov ecx, [ebp-8]   ; 第二个局部变量
mov edx, [ebp+8]   ; 第一个参数(注意是正偏移)

为什么参数是正偏移?因为调用者先压入参数,然后call指令压入返回地址,接着被调用者压入旧的ebp。所以栈布局从高地址到低地址是:参数、返回地址、旧ebp、局部变量。

我个人习惯在分析栈帧时,先画出栈的布局图。这样每个指令在操作哪个位置,一目了然。符号执行的时候,栈帧信息更是必不可少——你要知道每个符号变量对应的是哪个栈位置,才能正确建模。

总结一下:控制流分析是逆向工程的骨架,基本块和控制流图让你看清程序的结构,循环和条件分支让你理解程序的逻辑,函数调用约定和栈帧分析让你深入程序的运行时行为。这三块内容,是后续符号执行的基础。没有扎实的控制流分析,符号执行就是空中楼阁。


专注资料整理