4、控制流平坦化原理:原始CFG到平坦化CFG的变换过程,分发器(dispatcher)、状态变量、真实块与预处理器(preprocessor)的识别

控制流平坦化(Control Flow Flattening,CFF)是OLLVM混淆家族中最具代表性的控制流混淆技术。其核心思想是将一个函数中原本由条件跳转、循环、switch等构成的复杂控制流图(CFG),彻底“压平”为一个由分发器(dispatcher)统一调度的顺序结构。理解这一变换过程,是后续进行去平坦化还原的第一步,也是最重要的一步。本章将从原始CFG到平坦化CFG的数学变换入手,深入剖析分发器、状态变量、真实块与预处理器四大核心组件的识别方法,并结合一个典型的switch-case案例进行全流程拆解。

原始CFG → 平坦化CFG 变换过程 原始 CFG(控制流图) Entry 条件判断 Block A Block B Block C Exit OLLVM 平坦化 平坦化 CFG(分发器模式) 预处理器 分发器 状态变量 真实块 A 真实块 B 真实块 C 返回块 预处理器 分发器 真实块

上图直观地展示了控制流平坦化的核心变换逻辑:左侧是原始的、具有清晰分支结构的CFG,右侧是经过OLLVM平坦化后的CFG。可以看到,右侧所有真实逻辑块(Block A/B/C)不再直接通过条件跳转连接,而是全部“悬挂”在一个中央分发器之下。所有块之间的跳转都必须经过分发器,由分发器根据状态变量的值决定下一步执行哪个块。这种变换彻底破坏了原始CFG的拓扑结构,使得静态分析时难以直接判断代码的执行路径。

4.1 核心原理:从结构化控制流到状态机模型

控制流平坦化的本质,是将一个函数从“结构化控制流”转换为“状态机模型”。在原始CFG中,控制流的转移依赖于条件表达式(如if-else、switch-case、循环条件),这些条件直接决定了跳转目标。而在平坦化CFG中,所有条件判断被统一抽象为一个状态变量(State Variable),控制流的转移完全由该变量的值驱动。

具体而言,OLLVM的flatten pass执行以下关键步骤:

  1. 收集所有基本块:将函数中的所有基本块(Basic Block)枚举出来,包括入口块、条件判断块、循环体、返回块等。
  2. 创建分发器:生成一个中央分发器块(dispatcher),通常是一个switch语句或if-else链,根据状态变量的值跳转到对应的真实块。
  3. 引入状态变量:在函数入口处分配一个整型变量(通常为32位),作为状态机的“当前状态”。
  4. 重写所有跳转:将原始CFG中所有的条件跳转和无条件跳转,全部替换为“修改状态变量 + 跳转到分发器”的模式。
  5. 插入预处理器:在分发器之前插入一个预处理器(preprocessor),用于初始化状态变量或处理循环回边。

变换完成后,原始CFG中的每个基本块都变成了一个“真实块”(real block),其末尾不再直接跳转到下一个真实块,而是将状态变量设置为下一个目标块的编号,然后无条件跳转到分发器。分发器读取状态变量,通过switch-case或跳转表(jump table)将控制流派发到对应的真实块。

关键理解:平坦化后的CFG在结构上呈现为“分发器 + 一组真实块”的星型拓扑。所有真实块都只与分发器相连,块与块之间没有直接边。这种结构使得传统的基于CFG的静态分析(如可达性分析、路径敏感分析)几乎失效,因为从CFG上无法看出哪个块在什么条件下会执行。

4.2 四大组件的识别方法

在进行去平坦化还原时,第一步就是在混淆后的二进制或中间代码中,准确识别出这四个关键组件。以下分别介绍它们的特征和识别技巧。

4.2.1 分发器(Dispatcher)的识别

分发器是平坦化CFG的“中枢神经”,其典型特征如下:

  • 入度极高:在CFG中,分发器块是所有真实块回跳的目标,因此其入度(in-degree)等于真实块的数量,通常远大于其他块。
  • 包含switch或跳转表:在LLVM IR中,分发器通常是一个switch指令,其条件操作数是状态变量;在汇编层面,则表现为基于索引的间接跳转(如jmp [table + eax*4])。
  • 结构简单:分发器本身不包含复杂的计算逻辑,只做“读状态变量 → 查表 → 跳转”这一件事。

在IDA Pro中,可以通过查看块的入度(按X键查看交叉引用)来快速定位分发器。如果一个块的入度远高于其他块,且其指令序列中包含间接跳转或switch跳转表,那么它极有可能就是分发器。

4.2.2 状态变量(State Variable)的识别

状态变量是连接分发器和真实块的“信使”。其识别特征包括:

  • 生命周期贯穿整个函数:该变量在函数入口处被初始化,在函数返回前一直被读写。
  • 赋值模式固定:在每个真实块的末尾,都会有一条对该变量的赋值指令(如mov [ebp+var_4], 5),随后紧跟一条跳转到分发器的指令。
  • 取值有限且连续:状态变量的取值通常是一组连续的整数(如0, 1, 2, 3...),每个值对应一个真实块。

在动态调试中,可以通过在分发器的switch跳转处下断点,观察状态变量的值变化规律。如果发现该变量的值总是在一个有限的集合内循环,且每个值对应不同的执行路径,那么它基本可以确定是状态变量。

4.2.3 真实块(Real Block)的识别

真实块是原始CFG中保留下来的逻辑块,承载着实际的业务计算。识别真实块的关键在于:

  • 包含有意义的计算指令:如算术运算、内存读写、函数调用等,而非单纯的控制流指令。
  • 出度为1:每个真实块在平坦化后,其末尾只有一条无条件跳转指令(跳回分发器),不再有分支。
  • 入度通常为1:除了分发器外,没有其他块会直接跳转到真实块(循环回边除外,但回边也会被重写为通过分发器)。

在识别真实块时,可以先将分发器及其跳转表标记出来,然后从跳转表的目标地址出发,逐个分析每个目标块。如果目标块中包含实质性的计算逻辑,且其末尾是跳回分发器的指令,那么该块就是一个真实块。

4.2.4 预处理器(Preprocessor)的识别

预处理器是平坦化变换中容易被忽视但非常重要的组件。它的作用是在进入分发器主循环之前,对状态变量进行初始化,或者处理循环的“回边”情况。其典型特征为:

  • 位于分发器之前:在CFG中,预处理器是分发器的直接前驱(predecessor)。
  • 包含状态变量的初始赋值:通常会将状态变量设置为第一个要执行的真实块的编号。
  • 可能包含循环计数器初始化:如果原始函数中有循环,预处理器可能负责初始化循环变量。

在IDA中,可以通过查看分发器的前驱来找到预处理器。如果分发器有多个前驱,其中一个是函数入口块,另一个是真实块的“返回块”,那么入口块通常就是预处理器。

组件名称 CFG特征 指令特征 识别技巧
分发器 入度极高,所有真实块回跳目标 switch指令或间接跳转(jmp [table+idx*4]) 查看块的交叉引用数量,找入度最高的块
状态变量 无特定CFG位置,贯穿函数 在真实块末尾被赋值,在分发器中被读取 在分发器跳转处下断点,观察变量值变化
真实块 入度/出度均为1,只与分发器相连 包含算术、内存、调用等实质计算指令 从跳转表目标出发,筛选含计算逻辑的块
预处理器 分发器的直接前驱,通常为入口块 包含状态变量的初始赋值(mov state, 0) 查看分发器的前驱,找含初始赋值的块

4.3 案例拆解:一个switch-case函数的平坦化全过程

为了更直观地理解上述原理,我们以一个包含switch-case的简单C函数为例,展示其经过OLLVM平坦化前后的变化。

原始C代码:

int func(int input) {
    int result = 0;
    switch (input) {
        case 1:
            result = input * 2;
            break;
        case 2:
            result = input + 10;
            break;
        case 3:
            result = input - 5;
            break;
        default:
            result = -1;
            break;
    }
    return result;
}

该函数的原始CFG包含一个入口块、一个switch判断块、四个case块(包括default)和一个返回块。经过OLLVM平坦化后,其LLVM IR结构大致如下(简化版):

define i32 @func(i32 %input) {
entry:
  %state = alloca i32, align 4
  store i32 0, i32* %state          ; 预处理器:初始化状态变量为0
  br label %dispatcher

dispatcher:                          ; 分发器
  %state_val = load i32, i32* %state
  switch i32 %state_val, label %default_block [
    i32 0, label %case1_block
    i32 1, label %case2_block
    i32 2, label %case3_block
    i32 3, label %default_block
  ]

case1_block:                         ; 真实块1
  %mul = mul i32 %input, 2
  store i32 %mul, i32* %result
  store i32 3, i32* %state          ; 设置下一个状态为3(default)
  br label %dispatcher

case2_block:                         ; 真实块2
  %add = add i32 %input, 10
  store i32 %add, i32* %result
  store i32 3, i32* %state
  br label %dispatcher

case3_block:                         ; 真实块3
  %sub = sub i32 %input, 5
  store i32 %sub, i32* %result
  store i32 3, i32* %state
  br label %dispatcher

default_block:                       ; 真实块4(default)
  store i32 -1, i32* %result
  store i32 4, i32* %state          ; 设置状态为4(退出)
  br label %dispatcher

exit_block:                          ; 返回块
  %ret_val = load i32, i32* %result
  ret i32 %ret_val
}

在这个例子中,我们可以清晰地看到:

  • 预处理器是entry块,它将状态变量初始化为0,对应第一个要执行的case1_block。
  • 分发器是dispatcher块,它读取状态变量,通过switch指令将控制流派发到对应的真实块。
  • 状态变量是%state,其值在0-4之间变化,每个值对应一个真实块或退出状态。
  • 真实块是case1_block、case2_block、case3_block和default_block,它们各自执行原始switch中的一个case逻辑,然后将状态变量设置为下一个目标块的值,并跳回分发器。

值得注意的是,原始代码中的break语句在平坦化后,变成了“将状态变量设置为default_block的编号(3)”。这意味着无论哪个case执行完毕,都会跳转到default_block,然后再从default_block跳转到exit_block。这种设计保证了所有路径最终都会汇聚到返回块。

实战提示:在逆向分析中,当遇到这种“所有case块都跳转到同一个块”的模式时,该块往往就是原始代码中的break汇聚点或函数返回前的公共出口。识别出这个汇聚点,有助于我们重建原始的控制流结构。

4.4 常见误区与总结复盘

在学习和实践控制流平坦化识别时,初学者常犯以下错误,需要特别注意:

误区一:将分发器误认为普通switch。普通的switch-case语句,其case块之间是互斥的,且每个case块执行完后会跳出switch。而平坦化后的分发器,其“case块”(即真实块)执行完后会跳回分发器本身,形成循环。这是区分两者的关键。

误区二:忽略预处理器的作用。有些分析者在定位到分发器后,直接尝试恢复控制流,却忽略了预处理器中的初始化逻辑。这会导致无法确定状态变量的初始值,从而无法确定第一个执行的真实块。

误区三:认为状态变量一定是单个变量。在某些高级混淆中,状态变量可能被拆分为多个变量,或者使用异或、加法等运算进行编码。此时,需要先识别出状态变量的“编码-解码”逻辑,才能正确还原。

总结复盘:控制流平坦化的识别,本质上是一个“找枢纽”的过程。分发器是枢纽,状态变量是枢纽的输入,真实块是枢纽的输出。只要在混淆代码中准确找到这个枢纽,并理清状态变量的取值与真实块的对应关系,就能为后续的去平坦化还原打下坚实基础。在实际操作中,建议采用“动态+静态”结合的方式:先用静态分析(IDA、Ghidra)定位分发器和跳转表,再用动态调试(OllyDbg、x64dbg)验证状态变量的变化规律,双管齐下,效率最高。