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结束了)。
核心要点:控制流图是有向图,节点是基本块,边是控制流转移。每个函数对应一个控制流图,入口节点是函数的第一条指令。
我在项目中遇到过一个问题:有些混淆器会插入大量无用的跳转指令,导致基本块数量暴增。这时候就需要做基本块合并——如果两个块之间只有一条无条件跳转,而且没有其他块跳转到中间,就可以合并。
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、局部变量。
我个人习惯在分析栈帧时,先画出栈的布局图。这样每个指令在操作哪个位置,一目了然。符号执行的时候,栈帧信息更是必不可少——你要知道每个符号变量对应的是哪个栈位置,才能正确建模。
总结一下:控制流分析是逆向工程的骨架,基本块和控制流图让你看清程序的结构,循环和条件分支让你理解程序的逻辑,函数调用约定和栈帧分析让你深入程序的运行时行为。这三块内容,是后续符号执行的基础。没有扎实的控制流分析,符号执行就是空中楼阁。