9、控制流图与函数识别:基本块与控制流图、函数边界识别、编译器优化对控制流的影响
各位同学,今天我们聊一个逆向工程里非常核心的话题——控制流图与函数识别。说白了,就是搞清楚程序是怎么跑的,以及编译器是怎么把代码“揉”成二进制文件的。
我个人习惯把逆向分析比作“考古”。你拿到的是一个已经编译好的二进制文件,就像挖到一块化石。你要做的,是通过这块化石还原出它活着时候的样子。而控制流图,就是这块化石的骨骼结构。
基本块与控制流图
先说说基本块。这个概念其实很简单:一段连续的指令序列,只有一个入口,一个出口。入口就是第一条指令,出口就是最后一条。中间没有跳转,没有分支,一条道走到黑。
举个例子,你看下面这段代码:
mov eax, [ebp+8]
add eax, 10
mov [ebp-4], eax
这三条指令,从上往下执行,没有跳转,没有条件判断。这就是一个基本块。
那控制流图是什么呢?就是把基本块当成节点,把跳转关系当成边,画出来的一张图。每个节点代表一段顺序执行的代码,每条边代表可能的执行路径。
核心要点:控制流图是逆向分析的骨架。没有它,你就是在代码的海洋里瞎游。
我刚开始做逆向的时候,拿到一个二进制文件就直接看反汇编,一行一行地读。结果呢?读了三页就晕了。后来我师父告诉我:先画控制流图,再读代码。从那以后,我的效率至少翻了一倍。
下面这张图展示了一个简单的控制流图结构:
你看,基本块A是入口,然后遇到条件判断。条件为真就走B,为假就走C。最后B和C都汇聚到D。这就是一个典型的if-else结构的控制流图。
函数边界识别
函数边界识别,说白了就是找到函数的开头和结尾。这听起来简单,但实际做起来坑很多。
为什么?因为编译器会做各种优化。函数内联、尾调用优化、代码重排……这些操作会把函数的边界搞得面目全非。
我常用的方法有几种:
- call-ret配对法:找到call指令,然后找对应的ret指令。这是最直观的方法,但遇到尾调用优化就失效了。
- 栈帧分析法:函数开头通常有push ebp; mov ebp, esp这样的序言。结尾有leave; ret这样的尾声。但优化过的代码可能没有这些。
- 控制流图连通性法:一个函数内的基本块应该是连通的。如果发现某个基本块孤立了,那可能就是另一个函数的入口。
小技巧:我个人习惯先用call-ret配对法粗筛一遍,再用控制流图连通性法做验证。两个方法互相印证,准确率能到90%以上。
举个例子,你看下面这段代码:
0x401000: push ebp
0x401001: mov ebp, esp
0x401003: sub esp, 0x10
; ... 函数体 ...
0x401020: leave
0x401021: ret
这个函数的边界就很清晰。0x401000是入口,0x401021是出口。但如果是优化过的代码呢?
0x401000: sub esp, 0x10
; ... 函数体 ...
0x401015: add esp, 0x10
0x401018: jmp [esp-4]
你看,没有push ebp,没有leave,没有ret。这就是优化后的代码。函数边界变得模糊了。
注意:遇到这种优化过的代码,千万别死磕传统的边界识别方法。要结合上下文,看调用约定,看栈的使用情况。我曾经在一个恶意软件分析中,因为函数边界识别错了,整整浪费了两天时间。
编译器优化对控制流的影响
编译器优化,是逆向工程师的噩梦,也是乐趣所在。为什么这么说?因为优化后的代码,控制流图会变得面目全非。
常见的优化手段有:
| 优化类型 | 对控制流的影响 | 应对策略 |
|---|---|---|
| 函数内联 | 函数边界消失,控制流图变大 | 通过代码模式识别内联函数 |
| 尾调用优化 | 用jmp代替call,函数边界模糊 | 检查jmp目标是否在函数外部 |
| 循环展开 | 循环体被复制多次,控制流图膨胀 | 识别重复的代码模式 |
| 代码重排 | 基本块顺序被打乱 | 依赖控制流图,不依赖地址顺序 |
| 死代码消除 | 部分基本块消失 | 检查可达性,标记不可达代码 |
我记得有一次分析一个加壳的恶意软件。壳解压后,代码被各种优化搞得乱七八糟。函数边界完全看不出来。我试了各种工具,IDA、Ghidra、Radare2,全都识别错了。
后来我怎么做呢?我手动画控制流图。从入口点开始,一条指令一条指令地跟。遇到跳转就画一条边。花了整整一天,画出了一张巨大的控制流图。然后我根据图的连通性,手动标记函数边界。
嗯,这个过程很痛苦。但做完之后,我对那个恶意软件的理解,比任何自动工具都要深。
核心观点:自动工具再强大,也替代不了人的判断。尤其是在面对编译器优化的时候,理解优化原理,比依赖工具更重要。
为什么会这样?因为编译器优化的本质,是在不改变程序语义的前提下,改变代码的结构。而控制流图,就是代码结构的可视化表示。优化改变了结构,控制流图自然就变了。
你想想看,一个循环展开之后,原来的循环体被复制了三次。控制流图上就多了三个基本块。如果你不知道这是循环展开的结果,你可能会以为这里有三个不同的逻辑分支。
所以我的建议是:
- 先理解编译器:不同的编译器(GCC、MSVC、Clang)有不同的优化策略。了解它们的习惯,能帮你更快地识别优化后的代码。
- 多画控制流图:不要只看反汇编。把控制流图画出来,很多问题就一目了然了。
- 善用工具但不依赖工具:IDA、Ghidra这些工具能帮你做很多事,但最终判断还是要靠你自己。
避坑指南:我曾经在分析一个GCC -O3编译的程序时,发现控制流图里有一个基本块没有任何入边。我以为是死代码,差点就跳过了。后来仔细一看,那个基本块是通过一个间接跳转到达的。嗯,这种坑,踩过一次就记住了。
最后说一句,控制流图分析是个手艺活。多练,多看,多踩坑,慢慢就有感觉了。别指望看几篇文章就能成为高手。动手,动手,再动手。
公众号:蓝海资料掘金营,微信deep3321