9、控制流图与函数识别:基本块与控制流图、函数边界识别、编译器优化对控制流的影响

各位同学,今天我们聊一个逆向工程里非常核心的话题——控制流图与函数识别。说白了,就是搞清楚程序是怎么跑的,以及编译器是怎么把代码“揉”成二进制文件的。

我个人习惯把逆向分析比作“考古”。你拿到的是一个已经编译好的二进制文件,就像挖到一块化石。你要做的,是通过这块化石还原出它活着时候的样子。而控制流图,就是这块化石的骨骼结构。

基本块与控制流图

先说说基本块。这个概念其实很简单:一段连续的指令序列,只有一个入口,一个出口。入口就是第一条指令,出口就是最后一条。中间没有跳转,没有分支,一条道走到黑。

举个例子,你看下面这段代码:

mov eax, [ebp+8]
add eax, 10
mov [ebp-4], eax

这三条指令,从上往下执行,没有跳转,没有条件判断。这就是一个基本块。

那控制流图是什么呢?就是把基本块当成节点,把跳转关系当成边,画出来的一张图。每个节点代表一段顺序执行的代码,每条边代表可能的执行路径。

核心要点:控制流图是逆向分析的骨架。没有它,你就是在代码的海洋里瞎游。

我刚开始做逆向的时候,拿到一个二进制文件就直接看反汇编,一行一行地读。结果呢?读了三页就晕了。后来我师父告诉我:先画控制流图,再读代码。从那以后,我的效率至少翻了一倍。

下面这张图展示了一个简单的控制流图结构:

基本块 A(入口) 条件判断 基本块 B 基本块 C 基本块 D(出口)

你看,基本块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,全都识别错了。

后来我怎么做呢?我手动画控制流图。从入口点开始,一条指令一条指令地跟。遇到跳转就画一条边。花了整整一天,画出了一张巨大的控制流图。然后我根据图的连通性,手动标记函数边界。

嗯,这个过程很痛苦。但做完之后,我对那个恶意软件的理解,比任何自动工具都要深。

核心观点:自动工具再强大,也替代不了人的判断。尤其是在面对编译器优化的时候,理解优化原理,比依赖工具更重要。

为什么会这样?因为编译器优化的本质,是在不改变程序语义的前提下,改变代码的结构。而控制流图,就是代码结构的可视化表示。优化改变了结构,控制流图自然就变了。

你想想看,一个循环展开之后,原来的循环体被复制了三次。控制流图上就多了三个基本块。如果你不知道这是循环展开的结果,你可能会以为这里有三个不同的逻辑分支。

所以我的建议是:

  1. 先理解编译器:不同的编译器(GCC、MSVC、Clang)有不同的优化策略。了解它们的习惯,能帮你更快地识别优化后的代码。
  2. 多画控制流图:不要只看反汇编。把控制流图画出来,很多问题就一目了然了。
  3. 善用工具但不依赖工具:IDA、Ghidra这些工具能帮你做很多事,但最终判断还是要靠你自己。

避坑指南:我曾经在分析一个GCC -O3编译的程序时,发现控制流图里有一个基本块没有任何入边。我以为是死代码,差点就跳过了。后来仔细一看,那个基本块是通过一个间接跳转到达的。嗯,这种坑,踩过一次就记住了。

最后说一句,控制流图分析是个手艺活。多练,多看,多踩坑,慢慢就有感觉了。别指望看几篇文章就能成为高手。动手,动手,再动手。


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