[机翻] [ABD] 12_控制流平坦化与DSE
12. 控制流平坦化与 DSE(Control Flow Flattening & Dynamic Symbolic Execution)
⚠️ API 版本说明:本文档中的 Miasm API 代码示例已于 2026-07 更新至 Miasm 0.1.5+ 版本。课程原始材料使用的是 0.1.3 版本(2020年),部分 API 已发生 breaking change。更新处以
【更新】标注,旧版用法以【旧版 v0.1.3】标注。
本节涵盖两条主线:一是针对 O-LLVM 控制流平坦化(CFF) 的静态去混淆方法,二是基于 动态符号执行(DSE) 的自动化求解方法。后者是整个课程中最复杂、也最强大的技术。
CFF 探索
cff_explore.ipynb — CFF 分析
该 notebook 演示如何对 O-LLVM(Obfuscator-LLVM) 生成的控制流平坦化二进制 test-mod2-fla.bin 进行去混淆。
控制流平坦化(CFF)的原理
O-LLVM 的 fla(flattening)pass 会将函数的控制流"展平":
- 原始的基本块被拆散,放入一个大的
switch-case分发循环中。 - 一个分发器(dispatcher)变量(通常是状态变量)控制每个基本块执行完后跳转到哪个下一个基本块。
- 原始的顺序/分支/循环结构被完全隐藏在分发循环中。
这使得静态分析工具看到的 CFG 是一个扁平的"中心分发器 + 放射状基本块"结构,无法直接还原原始逻辑。
分析步骤
第 1 步:加载二进制并反汇编目标函数
from miasm.analysis.binary import Container
from miasm.analysis.machine import Machine
# 加载二进制
with open('test-mod2-fla.bin', 'rb') as f:
cont = Container.from_stream(f)
bs = cont.bin_stream
loc_db = cont.loc_db
# 初始化 x86_32 机器与 IR
machine = Machine("x86_32")
mdis = machine.dis_engine(bs, loc_db=loc_db)
# 【旧版 v0.1.3】 ir_arch = machine.ira(loc_db)
# 【更新 v0.1.5+】 ira() 重命名为 lifter_model_call();变量名 ir_arch 统一为 lifter
lifter = machine.lifter_model_call(loc_db)
# 反汇编目标函数 0x8048440
target_addr = 0x8048440
asmcfg = mdis.dis_multiblock(target_addr)
ircfg = lifter.new_ircfg()
lifter.add_asmblock_to_ircfg(asmcfg, ircfg)
第 2 步:应用 IRCFG 简化器
这是去混淆的关键步骤。使用 IRCFGSimplifierCommon 对 IR 控制流图执行一系列优化 pass,恢复原始控制流结构:
from miasm.analysis.simplifier import IRCFGSimplifierCommon
# 应用 IRCFG 简化器
# 【旧版 v0.1.3】 simplifier = IRCFGSimplifierCommon(ir_arch)
# 【更新 v0.1.5+】 变量名 ir_arch → lifter
simplifier = IRCFGSimplifierCommon(lifter)
simplified_ircfg = simplifier.simplify(ircfg, target_addr)
IRCFGSimplifierCommon 内部执行多个优化 pass,包括:
- 常量传播(Constant Propagation):传播分发器变量的已知值。当分发器变量在某路径上可确定为常量时,
switch-case分发被具体化为确定的跳转目标。 - 死代码消除(Dead Code Elimination):删除永远不会执行的分支。
- 常量折叠(Constant Folding):编译时计算常量表达式。
- 其他 IR 简化:表达式规范化、冗余赋值消除等。
通过这些 pass,平坦化的分发循环被"解开":每个基本块的真实后继被恢复,虚假的分发器逻辑被消除,CFG 逐渐还原为接近原始的控制流结构。
第 3 步:可视化 CFG
将简化的 CFG 导出为 .dot 文件,再用 pydotplus 渲染为 PNG 图片,便于直观对比去混淆前后:
import pydotplus
# 生成 .dot 文件
dot = simplified_ircfg.graph()
dot.write_dot('simplified_cfg.dot')
# 渲染为 PNG
graph = pydotplus.graph_from_dot_file('simplified_cfg.dot')
graph.write_png('simplified_cfg.png')
第 4 步:初始化符号状态并执行路径探索
from miasm.ir.symbexec import SymbolicExecutionEngine
# 初始化符号状态
symbols_init = {} # 可按需符号化特定寄存器
# 【旧版 v0.1.3】 sb = SymbolicExecutionEngine(ir_arch, symbols_init)
# 【更新 v0.1.5+】 构造函数签名变化:symbols 参数不再通过构造函数传入;变量名 ir_arch → lifter
sb = SymbolicExecutionEngine(lifter)
# 执行 explore() 进行路径探索
sb.run_at(simplified_ircfg, target_addr)
技术要点
CFF 将原始控制流打散为 switch-dispatch 循环,
IRCFGSimplifierCommon通过常量传播、死代码消除等 pass 恢复原始控制流结构。
这是"以编译器优化对抗混淆器"的经典思路:O-LLVM 的平坦化本质上是一种人为制造的"过度复杂的 IR",而标准的编译器优化 pass 恰好能将其简化回正常形态。
动态符号执行(DSE)
cff_dse.ipynb — 基于 DSE 的 CFF 分析
这是整个课程最复杂的 notebook,使用 Miasm 的 DSE(Dynamic Symbolic Execution) 框架对 O-LLVM 控制流平坦化二进制进行自动化求解。
DSE 的核心优势在于它同时维护具体执行和符号执行两条轨道:用具体执行(jitter)保证能"跑通"复杂代码(如库函数调用),用符号执行推导路径约束并求解新输入以探索未覆盖路径。这克服了纯符号执行在处理系统调用、库函数时的瓶颈。
下面按 6 个步骤详解。
1. 沙箱初始化
使用 Sandbox_Linux_x86_64 创建一个 Linux x86-64 沙箱环境,设置返回哨兵地址以捕获函数返回:
from miasm.analysis.sandbox import Sandbox_Linux_x86_64
# 创建沙箱
sb = Sandbox_Linux_x86_64(loc_db, options.filename, options, globals())
# 设置返回哨兵地址
ret_addr = 0x000000001337beef
def code_sentinelle(jitter):
"""到达返回哨兵时停止执行。"""
jitter.run = False
return True
sb.jitter.add_breakpoint(ret_addr, code_sentinelle)
# 将返回地址压栈,函数 ret 时会跳到 ret_addr 触发哨兵
sb.jitter.push_uint64_t(ret_addr)
# 从入口地址 0x1040 开始执行
sb.jitter.init_run(0x1040)
code_sentinelle:回调函数,当 jitter 执行到ret_addr时被调用,设置jitter.run = False停止执行。push_uint64_t(ret_addr):将返回地址压入栈顶。当被分析函数执行ret指令时,CPU 从栈顶弹出该地址并跳转,触发哨兵断点,实现优雅停止。
2. 模拟命令行参数
被分析的 CFF 程序从 argv[1] 读取输入。DSE 需要模拟命令行参数环境,设置 argc、argv 指针:
# 内存地址常量
MEM_ARGV_ADDR = 0x600000 # argv 数组地址
MEM_ARGV1_ADDR = 0x600100 # argv[1] 字符串缓冲区地址
# 模拟 argv 数组: [argv[0]指针, argv[1]指针, NULL]
# argv[0] 指向一个字符串 "BBBB..." (8字节填充)
sb.jitter.vm.add_memory_page(
MEM_ARGV_ADDR,
0x7, # 可读可写可执行
b'\x42' * 8 + # argv[0] 内容: "BBBBBBBB"
int(MEM_ARGV1_ADDR).to_bytes(8, 'little') + # argv[1] 指针
b'\x00' * 8 # argv 数组结尾 NULL
)
# argv[1] 字符串缓冲区: 8 字节,初始全零
sb.jitter.vm.add_memory_page(MEM_ARGV1_ADDR, 0x7, b'\x00' * 8)
# 设置寄存器: argc=2, argv 指针
sb.jitter.cpu.RDI = 0x2 # argc = 2 (程序名 + 1个参数)
sb.jitter.cpu.RSI = MEM_ARGV_ADDR # argv 指针
这里 RDI 和 RSI 对应 System V AMD64 调用约定中 main(int argc, char **argv) 的前两个参数。
3. DSE 实例创建与策略选择
创建 DSE 引擎并附加到具体执行引擎(jitter)上:
from miasm.analysis.dse import DSEPathConstraint
# 选择策略: 代码覆盖
strategy = "PRODUCE_SOLUTION_CODE_COV"
# 创建 DSE 实例
# 【旧版 v0.1.3】 dse = DSEPathConstraint(machine, produce_solution=strategy)
# 【更新 v0.1.5+】 新增 loc_db 必需参数
dse = DSEPathConstraint(machine, loc_db, produce_solution=strategy)
# 附加到 jitter(具体执行引擎)
dse.attach(sb.jitter)
# 从具体状态同步符号状态
dse.update_state_from_concrete()
# 添加库函数处理(libc 等)
dse.add_lib_handler(sb.libs, globals())
三种探索策略
| 策略常量 | 中文名称 | 说明 |
|---|---|---|
PRODUCE_SOLUTION_CODE_COV |
代码覆盖 | 优先探索未执行过的代码地址,目标是最大化代码覆盖率 |
PRODUCE_SOLUTION_BRANCH_COV |
分支覆盖 | 优先探索未执行过的分支方向(true/false),目标是最大化分支覆盖率 |
PRODUCE_SOLUTION_PATH_COV |
路径覆盖 | 优先探索未执行过的完整路径,目标是最大化路径覆盖率 |
dse.attach(jitter):将 DSE 引擎"挂载"到 jitter。此后每次 jitter 执行一条指令,DSE 会同步更新符号状态,实现 concolic(具体+符号)混合执行。dse.update_state_from_concrete():从 jitter 的具体寄存器/内存状态同步到 DSE 的符号状态,确保两者一致。dse.add_lib_handler():注册库函数处理逻辑,使 DSE 在遇到printf等库函数调用时能正确处理(而非卡在符号执行无法建模的库代码上)。
4. 符号化输入参数
将 argv[1] 的 8 个字节符号化,并添加约束使其为可打印 ASCII 字符:
from miasm.expression.expression import ExprId, ExprMem, ExprInt
argv1 = []
argv1_addr = MEM_ARGV1_ADDR
for i in range(8):
# 为 argv[1] 的每个字节创建符号变量
const = ExprId('Argv[1][%d]' % i, 8)
argv1.append(const)
# 添加约束: 可打印 ASCII (32 < byte < 127)
# 【旧版 v0.1.3】 dse.cur_solver.add(31 < const)
# 【更新 v0.1.5+】 属性名 cur_solver → z3_cur
dse.z3_cur.add(31 < const) # byte > 31 (大于空格)
dse.z3_cur.add(const < 127) # byte < 127 (小于 DEL)
# 将符号变量写入 DSE 符号状态的对应内存位置
dse.update_state({
ExprMem(ExprInt(argv1_addr + i, 64), 8): const
})
约束 31 < byte < 127 限制了输入为可打印 ASCII 字符,大幅缩小搜索空间,使求解更高效。
5. printf 符号化处理
被分析程序使用 printf 输出结果。printf 是库函数,符号执行无法自动建模其内部逻辑,因此需要手动定义其符号化行为:
from miasm.arch.x86.regs import RSP, RAX, RIP
from miasm.expression.expression import ExprInt
def xxx_printf_symb(dse):
"""printf 的符号化处理。
模拟 printf 的副作用:
- 消耗栈上参数(RSP += 8,模拟 ret)
- 设置返回地址 IRDst/RIP 为 ret_addr
- 设置返回值 RAX = 6(printf 返回打印的字符数,这里固定为6)
"""
dse.update_state({
# 模拟 ret: 栈指针恢复
regs.RSP: dse.symb.eval_expr(regs.RSP + ExprInt(8, 64)),
# 控制流返回到 ret_addr 哨兵
# 【旧版 v0.1.3】 dse.ir_arch.IRDst: ExprInt(ret_addr, 64),
# 【更新 v0.1.5+】 属性名 ir_arch → lifter
dse.lifter.IRDst: ExprInt(ret_addr, 64),
# 返回值
regs.RAX: ExprInt(6, 64),
# 程序计数器
regs.RIP: ExprInt(ret_addr, 64),
})
# 注册符号化库函数处理器
dse.lib_handler.set_handler('printf', xxx_printf_symb)
该函数告诉 DSE:遇到 printf 调用时,不要尝试符号执行 printf 的实现,而是直接将其效果建模为"栈恢复 + 返回到哨兵地址 + 返回值 6"。这样 DSE 就能"跳过"库函数,继续分析用户代码。
6. DSE 迭代求解循环(核心算法)
这是整个 DSE 分析的核心循环。它的工作原理是:
- 从待求解集合
todo中取一个具体输入。 - 恢复快照,用该输入执行程序。
- 检查是否找到正确答案(
RAX == 0)。 - 若未找到,从 DSE 收集的新解(新路径约束的模型)中提取新输入,加入
todo。 - 重复,直到找到答案或
todo耗尽。
# 初始待求解集合: 一个初始猜测输入
todo = set([(0x42, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41)])
done = set()
# 在循环开始前保存快照,避免每次都重新初始化
snapshot = dse.take_snapshot()
while todo:
arg_value = todo.pop()
done.add(arg_value)
# 恢复到初始快照,保留已知的解
dse.restore_snapshot(snapshot, keep_known_solutions=True)
# 设置具体输入到 jitter 内存
for i in range(8):
sb.jitter.eval_expr(
ExprAssign(ExprInt(argv1_addr + i, 64), ExprInt(arg_value[i], 8))
)
# 同步符号状态并执行
dse.update_state_from_concrete()
sb.jitter.continue_run()
# 检查是否找到正确答案
if sb.jitter.cpu.RAX == 0:
print('FOUND!!!')
print('正确输入: %s' % repr(bytes(arg_value)))
break
# 从 DSE 获取新解(探索未覆盖路径的输入)
for sol_ident, model in dse.new_solutions.items():
# 从 Z3 模型中提取 argv[1] 各字节的值
new_value = []
for i in range(8):
sol_value = model.eval(dse.z3_trans.from_expr(argv1[i])).as_long()
new_value.append(sol_value)
new_value = tuple(new_value)
if new_value not in done:
todo.add(new_value)
print('新候选输入: %s' % repr(bytes(new_value)))
算法逻辑详解
-
take_snapshot():在循环外保存一次初始快照。后续每次迭代都从这个快照恢复,避免重复初始化沙箱和 DSE,大幅提升效率。 -
restore_snapshot(keep_known_solutions=True):恢复快照。keep_known_solutions=True保留之前求解得到的路径解,避免重复探索相同路径。 -
sb.jitter.eval_expr(ExprAssign(...)):将具体输入值写入 jitter 的虚拟内存,使具体执行使用该输入。 -
sb.jitter.continue_run():继续具体执行。jitter 跑函数,DSE 同步跟踪符号状态。 -
RAX == 0判定:被分析程序在输入正确时返回 0(这是程序逻辑约定的成功标志)。 -
dse.new_solutions:DSE 在执行过程中,会从路径约束中求解出能触发未覆盖分支的输入模型。这些模型就是下一次迭代的候选输入。通过z3_trans.from_expr()将 Miasm 表达式翻译为 Z3 表达式,再用model.eval().as_long()提取具体数值。
DSE 技术要点
DSE 结合 Concolic Execution 的优势
-
DSE 结合 concolic execution(具体+符号混合执行)优势:每次执行同时维护具体状态(jitter)和符号状态(DSE),两条轨道互补。
-
每次执行同时维护具体和符号状态:具体执行保证程序能"跑通"——遇到系统调用、库函数、复杂内存操作时,具体执行照常进行,不会被符号执行的表达式爆炸卡住;符号执行则记录路径约束,用于求解新输入。
-
执行结束后从符号路径条件中求解新输入以探索未覆盖路径:每次执行结束,DSE 收集路径上的符号约束,用 Z3 求解"能走另一条分支的输入",从而系统性地探索更多路径。
-
比纯符号执行能处理更复杂代码(如库函数调用):纯符号执行遇到
printf、malloc等库函数时,要么需要完整建模其语义(极难),要么路径爆炸。DSE 用具体值"跳过"这些复杂点,只对用户关心的输入保持符号化。 -
通过快照机制避免重新初始化:
take_snapshot()/restore_snapshot()让每次迭代只需恢复状态而非重建整个沙箱,是性能优化的关键。
DSE 与纯符号执行的对比
| 特性 | 纯符号执行 | DSE(动态符号执行) |
|---|---|---|
| 执行方式 | 纯符号推导 | 具体+符号混合(concolic) |
| 库函数处理 | 需完整建模,易路径爆炸 | 用具体值跳过,鲁棒性强 |
| 路径探索 | 枚举所有路径,易爆炸 | 按策略选择路径,可控 |
| 状态管理 | 需维护多个符号状态 | 快照+恢复,高效 |
| 适用场景 | 简单/纯净代码 | 复杂/含库调用代码 |
Miasm DSE API 速查
| API | 用途 |
|---|---|
miasm.analysis.dse.DSEPathConstraint |
路径约束 DSE 引擎,核心类 |
dse.attach(jitter) |
将 DSE 附加到 jitter(具体执行引擎) |
dse.update_state_from_concrete() |
从 jitter 的具体状态同步到 DSE 符号状态 |
dse.update_state(dict) |
手动更新 DSE 符号状态(如符号化输入) |
dse.take_snapshot() |
保存当前 DSE 状态快照 |
dse.restore_snapshot(snap, keep_known_solutions=) |
恢复快照,可选保留已知解 |
dse.new_solutions |
字典,存储新求解的路径解(sol_ident → Z3 model) |
dse.z3_trans.from_expr(expr) |
将 Miasm 表达式翻译为 Z3 表达式 |
dse.cur_solver.add(constraint) |
【旧版 v0.1.3】向当前求解器添加约束 |
dse.z3_cur.add(constraint) |
【更新 v0.1.5+】向当前 Z3 求解器添加约束(cur_solver 已重命名为 z3_cur) |
dse.lib_handler.set_handler(name, func) |
注册符号化库函数处理器 |
dse.add_lib_handler(libs, globals()) |
批量添加库函数处理 |
关键代码模式汇总
# 1. 创建并附加 DSE
# 【旧版 v0.1.3】 dse = DSEPathConstraint(machine, produce_solution=strategy)
# 【更新 v0.1.5+】 新增 loc_db 必需参数
dse = DSEPathConstraint(machine, loc_db, produce_solution=strategy)
dse.attach(jitter)
dse.update_state_from_concrete()
# 2. 符号化输入
dse.update_state({ExprMem(addr, size): sym_var})
# 3. 添加约束
# 【旧版 v0.1.3】 dse.cur_solver.add(low < sym_var)
# 【更新 v0.1.5+】 cur_solver → z3_cur
dse.z3_cur.add(low < sym_var)
dse.z3_cur.add(sym_var < high)
# 4. 快照管理
snap = dse.take_snapshot()
# ... 执行 ...
dse.restore_snapshot(snap, keep_known_solutions=True)
# 5. 提取求解结果
for sol_ident, model in dse.new_solutions.items():
value = model.eval(dse.z3_trans.from_expr(sym_var)).as_long()
技术要点总结
-
CFF 静态去混淆的核心是编译器优化:
IRCFGSimplifierCommon通过常量传播解开分发器,用标准优化 pass 对抗 O-LLVM 的平坦化 pass,是"以魔法打败魔法"的典范。 -
DSE 是课程最复杂也最强大的技术:它融合了具体执行(鲁棒性)和符号执行(推导能力),能处理含库函数调用的真实复杂代码。
-
快照机制是 DSE 性能的关键:通过
take_snapshot()/restore_snapshot()避免重复初始化,使迭代求解循环高效运转。 -
符号化输入 + 约束求解是自动化求解的核心:将程序输入符号化,添加语义约束(如可打印 ASCII),通过路径约束求解新输入,系统性探索路径空间直至找到满足条件的解。
-
库函数符号化处理是 DSE 的必备技巧:对
printf等库函数手动建模副作用(栈恢复、返回值),使 DSE 能"跳过"复杂库代码,聚焦用户逻辑分析。

浙公网安备 33010602011771号