控制流平坦化
控制流平坦化
本文适合
已经能读普通分支和循环,但遇到巨大 while-switch 状态机就迷路的逆向学习者。学完你能:识别控制流平坦化,记录状态变量和状态转移,用动态路径或脚本恢复关键业务逻辑。
控制流平坦化是一种混淆技术。它把原本清晰的分支和循环改造成一个调度循环,让反编译结果看起来像巨大的状态机。
一句话判断
如果一个函数被改造成 while(true) + switch(state),每个 case 只做一小段逻辑并更新状态变量,就要怀疑控制流平坦化。
分析这类题时,不要逐行读整个 switch,而要先找状态变量、入口状态和状态转移图。
题目中常见信号
- 反编译结果是一个巨大的无限循环,里面套一个 switch。
- switch case 数量很多,状态值是随机大整数或看似无意义常量。
- 每个 case 末尾都会给同一个变量赋新值,然后回到循环顶部。
- 有大量不可达 case 或看似有用但永远不走的分支。
- 原本简单的输入校验被拆成许多小块,成功失败路径不再直观。
- 动态运行时实际经过的 case 远少于静态看到的 case。
核心概念
控制流平坦化隐藏的是“原始程序结构”,不是程序语义。原始的 if/else、loop、顺序执行被拆成多个基本块,统一交给调度器按状态变量跳转。
要把问题拆成四个对象:
- 调度器:无限循环和 switch。
- 状态变量:决定下一个 case 的变量。
- 状态转移:每个 case 如何修改状态变量。
- 业务语义:case 内真正对输入、常量和结果做了什么。
完全恢复所有控制流不一定必要。CTF 中常常只需要恢复从入口到成功/失败相关的真实路径。
最小分析流程
- 找到调度器循环和 switch。
- 确认状态变量初始化位置和 switch 使用位置。
- 给每个 case 编号,记录它做什么、会转向哪些状态。
- 从入口状态做可达性分析,先删掉明显不可达的死代码。
- 用动态调试记录真实输入下的状态序列。
- 只对影响输入校验、比较和输出的 case 还原伪代码。
最小验证示例
在 GDB 里观察状态变量变化。假设状态变量在 [rbp-0x14]:
break *0x401280 # switch 前
run
x/wx $rbp-0x14
continue
x/wx $rbp-0x14也可以在每个 case 末尾给状态变量写入处下断点,记录序列:
0x4a31c901 -> 0x13371337 -> 0x77ab1200 -> 0xdead0001如果动态路径只经过少数 case,就优先还原这条路径,再决定是否需要恢复完整状态图。
常见利用 / 解题路线
路线总览:
- 状态图路线:静态提取每个 case 的后继状态,画出可达图。
- 动态路径路线:用断点或插桩记录真实状态序列,只分析实际走到的 case。
- 死代码裁剪路线:从入口状态 DFS,移除不可达 case,降低阅读噪声。
- 条件转移路线:把根据输入决定的状态更新还原成原始 if/else。
- 工具辅助路线:用 deflat、angr、IDA/Ghidra 脚本辅助提取状态转移。
- 局部还原路线:只还原影响 flag 校验的状态链,不追求恢复整个源码。
什么是控制流平坦化
控制流平坦化(Control Flow Flattening, CFF)的核心思想是:将程序中所有原本清晰的控制流结构(if/else 分支、for/while 循环、函数调用)全部打散,转换为一个统一的 while(true) { switch(state) { ... } } 形式的调度循环。
原始代码可能是这样的:
if (x > 0) {
y = x * 2;
} else {
y = -x;
}
result = y + 1;经过平坦化后变成:
state = 0;
while (true) {
switch (state) {
case 0: // 入口
if (x > 0) state = 1; else state = 2;
break;
case 1: // if 分支
y = x * 2; state = 3; break;
case 2: // else 分支
y = -x; state = 3; break;
case 3: // 合并点
result = y + 1; state = 4; break;
case 4: // 出口
return;
}
}原始的分支结构被隐藏在状态转移中。代码的语义完全没有改变,但可读性急剧下降。编译器的优化(如内联、常量传播)配合平坦化,会让反编译结果更加难以理解。
正常控制流
普通程序结构可能是:
if
for
while
switch
function call这些结构在反编译器中通常比较容易读。
控制流平坦化会把它们打散。
平坦化后的形态
常见形态是:
while (true) {
switch (state) {
case A: ...
case B: ...
}
}每个基本块执行后修改 state,决定下一个块。
真实逻辑被隐藏在状态转移里。
识别信号
一个巨大的循环。
一个核心状态变量。
大量 switch case。
每个 case 末尾修改状态。
反编译结果里分支结构异常重复。
这时不要按源码风格读,要先恢复状态图。
状态机的识别特征
在反编译器中识别控制流平坦化,有以下典型特征:
结构特征:整个函数体是一个 while(1) 无限循环,循环内只有一个 switch 语句,switch 的判断变量就是状态变量。每个 case 块执行完后都会给状态变量赋一个新值,然后 break 回到循环顶部。
数值特征:状态变量的值通常是大整数(如 0x12345678),而不是从 0 开始的连续编号。有些混淆工具会使用随机值作为状态标识,有些则使用某种哈希算法生成。
代码特征:每个 case 块的代码量通常不大(一个基本块的大小),但 case 数量可能非常多(几十到几百个)。部分 case 块是死代码(永远不会被执行),用于干扰分析。有些 case 块之间存在明显的数据依赖关系。
反编译器表现:IDA 的 F5 反编译结果会显示为一个巨大的 switch 嵌套在 while 中;Ghidra 的反编译结果类似,但可能包含更多的变量交叉引用。如果看到这种模式,基本可以确认是控制流平坦化。
还原思路
找状态变量。
记录每个 case 做什么。
记录每个 case 可能跳到哪些状态。
去掉无效块和死代码。
把状态图重新组织成原始分支。
动态调试可以帮助确认实际状态路径。
恢复原始控制流的基本思路
去平坦化的目标是从扁平的 switch(state) 结构中恢复出原始的 if/else/loop 逻辑。基本步骤如下:
第一步:识别调度器和状态变量。找到 while(1) 循环和其中的 switch 语句,确定状态变量是哪个寄存器或栈变量。状态变量通常在 switch 之前被读取,在每个 case 末尾被写入。
第二步:构建状态转移图。遍历每个 case 块,记录它将状态变量设置为什么值。这构成一个有向图:节点是状态值,边是转移关系。某些转移可能依赖条件判断(对应原始的 if/else),某些是无条件的(对应顺序执行)。
第三步:识别真实基本块和死代码。从入口状态开始做可达性分析,不可达的 case 块就是死代码,可以直接删除。有些混淆工具会插入大量永远不会执行的虚假 case。
第四步:重建控制流。将状态转移图中连续的无条件转移链合并为顺序执行的代码块。将有条件转移的分支点还原为 if/else 结构。将循环状态转移(A->B->...->A)还原为循环结构。这一步通常需要结合动态调试来验证。
工具辅助:deflat 等自动化工具可以完成前两步,但后两步往往需要人工判断。angr 的符号执行也可以辅助去平坦化:用符号执行探索所有路径,自动生成到达每个状态的输入,从而确认状态转移关系。
调度器循环识别
控制流平坦化的核心是调度器循环:
// 典型的调度器结构
while (1) {
switch (state_var) {
case 0x12345678:
// 基本块 A
state_var = 0x87654321;
break;
case 0x87654321:
// 基本块 B
state_var = 0xabcdef01;
break;
case 0xabcdef01:
// 基本块 C
state_var = 0x12345678;
break;
// ... 更多 case
}
}识别信号
# 在 IDA/Ghidra 中查找以下模式:
# 1. 一个大循环(while(1) 或 for(;;))
# 2. 一个 switch 语句
# 3. 状态变量在循环中不断更新
# 4. 每个 case 末尾都有类似 state_var = xxx 的赋值
# Ghidra 中搜索模式:
# 右键 -> Search -> Pattern
# 搜索 "switch" 和 "while"D-800 模式
D-800 是一种特定的平坦化混淆模式:
# D-800 特征:
# 1. 使用多个嵌套的 switch
# 2. 状态变量分散在多个变量中
# 3. 包含大量的无用 case
# 4. 使用复杂的计算来更新状态
# 识别方法:
# - 查找 switch 语句的特征
# - 分析状态变量的更新模式
# - 识别无用代码(dead code)deflat 工具使用
deflat 是一个自动去除平坦化的工具:
# 安装
git clone https://github.com/cq674350529/deflat
cd deflat
pip install -r requirements.txt
# 使用
python deflat.py -f ./vuln --addr 0x401000
# 参数说明:
# -f: 目标文件
# --addr: main 函数的起始地址
# -n: 分析深度(默认 1)
# 输出:
# 去除平坦化后的二进制文件deflat 工作原理
# 1. 识别调度器循环
# 2. 分析状态转移图
# 3. 识别真实的基本块
# 4. 删除无用代码
# 5. 重建控制流图
# 6. 生成去除平坦化的代码手动恢复技术
步骤 1:找到状态变量
# 在 IDA 中:
# 1. 找到 while(1) 循环
# 2. 找到 switch 语句
# 3. 确定状态变量(通常是第一个局部变量)
# 状态变量通常在:
# - 循环开始前初始化
# - 每个 case 结束时更新
# - switch 的判断条件中步骤 2:记录状态转移
# 创建状态转移表
state_transitions = {
0x12345678: {
'next': [0x87654321, 0xabcdef01], # 可能的下一状态
'code': '基本块 A 的代码',
'conditions': ['条件1', '条件2'] # 转移条件
},
0x87654321: {
'next': [0xabcdef01],
'code': '基本块 B 的代码'
},
# ...
}步骤 3:重建控制流
# 从状态转移图重建原始控制流
def build_cfg(transitions, start_state):
"""重建控制流图"""
cfg = {}
visited = set()
def dfs(state):
if state in visited:
return
visited.add(state)
cfg[state] = transitions[state]
for next_state in transitions[state]['next']:
dfs(next_state)
dfs(start_state)
return cfg
# 然后将 CFG 转换回 if-else 结构步骤 4:动态验证
# 使用调试器验证恢复的控制流
# 1. 在关键位置设断点
# 2. 跟踪实际执行路径
# 3. 对比恢复的控制流和实际路径使用 Ghidra 脚本辅助
# Ghidra Python 脚本:查找平坦化模式
from ghidra.program.model.listing import CodeUnit
from ghidra.program.model.pcode import PcodeOp
def find_flat_patterns():
"""查找控制流平坦化的模式"""
listing = currentProgram.getListing()
# 查找所有 switch 语句
switch_instructions = []
inst = listing.getInstructions(True)
while inst.hasNext():
i = inst.next()
if i.getMnemonicString() == "JMP":
# 检查是否是 switch 跳转
switch_instructions.append(i.getAddress())
# 分析每个 switch
for addr in switch_instructions:
# 检查是否包含 while(1) 循环
# 特征:向后跳转到自身或附近的地址
disasm = idc.GetDisasm(addr)
if 'jmp' in disasm:
target = idc.get_operand_value(addr, 0)
if target <= addr and addr - target < 0x20:
# 可能是 while(1) 循环
print(f"[+] Potential infinite loop at {addr:#x}")
# 检查状态变量更新模式
# 特征:mov [rbp-XX], eax 后跟 cmp/jmp
next_addr = idc.next_head(addr)
if next_addr != idc.BADADDR:
next_disasm = idc.GetDisasm(next_addr)
if 'mov' in next_disasm and 'eax' in next_disasm:
# 状态变量可能在 eax 中
print(f"[+] State variable update at {next_addr:#x}")
return switch_instructions常见平坦化变体
随机化状态值
# 状态值不是连续的,而是随机的
# 例如:0x12345678, 0xabcdef01, 0xdeadbeef
# 增加分析难度
# 应对:使用符号执行或动态分析多层嵌套
# switch 内部还有 switch
# 或者多个函数都有平坦化
# 应对:逐层分析,从外到内混合使用真实分支和虚假分支
# 有些 case 是真实的,有些是虚假的
# 虚假分支的代码看起来也很合理
# 应对:使用动态分析确认真实路径完整去平坦化示例
def remove_flattening(binary_path, func_addr):
"""
完整的去平坦化流程
"""
# 1. 分析二进制
p = angr.Project(binary_path)
# 2. 识别调度器
# 通常是包含 while(1) 和 switch 的函数
# 3. 提取状态变量
# 可能需要手动分析或使用启发式方法
# 4. 构建状态转移图
# 运行程序,记录每次状态变化
# 5. 识别真实路径
# 通过符号执行或动态分析
# 6. 重建控制流
# 将状态转移转换为 if-else
# 7. 生成新代码
# 使用 Ghidra/IDA 的脚本功能
return decompiled_code常见失败原因
- 逐行阅读整个 switch:先记录状态转移和入口路径,再看 case 内语义。
- 不记录状态转移:只凭记忆会很快混乱,建议做表格或脚本。
- 把无效混淆块当成真实逻辑:从入口状态做可达性分析,动态确认真实路径。
- 不结合动态路径:静态图很大时,用输入样例跑一遍,先看实际状态序列。
- 只依赖 deflat:工具能辅助还原结构,但业务语义仍要人工验证。
- 状态变量找错:确认它既被 switch 读取,又在 case 中被反复写入。
- 以为必须完全还原所有代码:CTF 多数只需恢复校验相关状态链。
迷你案例
一个函数反编译后是 80 个 case 的 while-switch。先定位状态变量 state,初始值为 0x51ab21ff。动态记录状态序列后发现输入 AAAA 只经过:
0x51ab21ff -> 0x1001 -> 0x1002 -> 0x2000 -> fail在 0x1001 和 0x1002 两个 case 内,程序分别检查长度和对输入第 0 字节异或。把这些 case 提出来重写成普通伪代码,再继续换输入验证,最终恢复完整校验。这个案例的闭环是:识别平坦化 -> 找状态变量 -> 动态记录路径 -> 只还原关键 case。