[机翻] [ABD] 12_控制流平坦化与DSE

12. 控制流平坦化与 DSE(Control Flow Flattening & Dynamic Symbolic Execution)

https://github.com/malrev/ABD

⚠️ 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 需要模拟命令行参数环境,设置 argcargv 指针:

# 内存地址常量
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 指针

这里 RDIRSI 对应 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 分析的核心循环。它的工作原理是:

  1. 从待求解集合 todo 中取一个具体输入。
  2. 恢复快照,用该输入执行程序。
  3. 检查是否找到正确答案(RAX == 0)。
  4. 若未找到,从 DSE 收集的新解(新路径约束的模型)中提取新输入,加入 todo
  5. 重复,直到找到答案或 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)))

算法逻辑详解

  1. take_snapshot():在循环外保存一次初始快照。后续每次迭代都从这个快照恢复,避免重复初始化沙箱和 DSE,大幅提升效率。

  2. restore_snapshot(keep_known_solutions=True):恢复快照。keep_known_solutions=True 保留之前求解得到的路径解,避免重复探索相同路径。

  3. sb.jitter.eval_expr(ExprAssign(...)):将具体输入值写入 jitter 的虚拟内存,使具体执行使用该输入。

  4. sb.jitter.continue_run():继续具体执行。jitter 跑函数,DSE 同步跟踪符号状态。

  5. RAX == 0 判定:被分析程序在输入正确时返回 0(这是程序逻辑约定的成功标志)。

  6. 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 求解"能走另一条分支的输入",从而系统性地探索更多路径。

  • 比纯符号执行能处理更复杂代码(如库函数调用):纯符号执行遇到 printfmalloc 等库函数时,要么需要完整建模其语义(极难),要么路径爆炸。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()

技术要点总结

  1. CFF 静态去混淆的核心是编译器优化IRCFGSimplifierCommon 通过常量传播解开分发器,用标准优化 pass 对抗 O-LLVM 的平坦化 pass,是"以魔法打败魔法"的典范。

  2. DSE 是课程最复杂也最强大的技术:它融合了具体执行(鲁棒性)和符号执行(推导能力),能处理含库函数调用的真实复杂代码。

  3. 快照机制是 DSE 性能的关键:通过 take_snapshot() / restore_snapshot() 避免重复初始化,使迭代求解循环高效运转。

  4. 符号化输入 + 约束求解是自动化求解的核心:将程序输入符号化,添加语义约束(如可打印 ASCII),通过路径约束求解新输入,系统性探索路径空间直至找到满足条件的解。

  5. 库函数符号化处理是 DSE 的必备技巧:对 printf 等库函数手动建模副作用(栈恢复、返回值),使 DSE 能"跳过"复杂库代码,聚焦用户逻辑分析。

posted @ 2026-08-05 15:56  DirWangK  阅读(10)  评论(0)    收藏  举报