针对 OLLVM 风格控制流平坦化 (Control Flow Flattening, CFF) 的 Binary Ninja 去混淆插件。
OLLVM -fla 把函数变成「dispatcher + 真实块」状态机。本插件用静态分析
识别 dispatcher 子图、前向模拟状态变量,把 CFF 还原成两种可读形态:
- 路径 B (synthesize_switch,推荐):保留 dispatcher,把它的 if-tree
替换为
MLIL_JUMP_TO,BN 4.1+ 的 HLIL Restructurer 自动渲染为switch-case,最贴近源码原貌 - 路径 A (deflate_hard):把 dispatcher 整体绕掉,每个
state = const直接接到对应真实块,输出 goto 链;块数最少 - 路径 auto (推荐入口):先尝试 B,B 拒绝时自动 fallback 到 A,用户 无需手动判断函数类型
实测 39 个 OLLVM CFF 函数 (arm64-v8a / libkste / libSeQing):
- 半数以上函数 HLIL 行数下降 20-59% (短路真实块到 handler 后 BN HLIL Restructurer 能识别 if/while)
- 0 副作用丢失,0 孤立跳转
把整个 MikuCffHelper 文件夹放到 Binary Ninja 的 plugins 目录下。
~/.binaryninja/plugins/MikuCffHelper/
依赖:Binary Ninja 4.1 或更高 (HLIL Restructurer 把 jump_to 渲染为 switch 依赖此版本)。
打开二进制后,右键想去混淆的函数 → Function Analysis,选其中一个 activity
启用 (互斥):
| activity | 行为 | 推荐场景 |
|---|---|---|
workflow_patch_mlil_auto |
首选:先 B,B 不动则 A 兜底 | 不确定函数特征时直接选这个 |
workflow_patch_mlil_switch |
只跑 B (synthesize_switch) | 只想要 switch 形态、能接受部分函数无变换 |
workflow_patch_mlil |
只跑 A (deflate_hard) | 函数已知不适合 switch、要最大压缩块数 |
启用后 BN 会自动重分析。HLIL 视图刷新后能看到 switch 或 goto 链形态。
tools/deflate_cli.py 不需要打开 BN UI,直接对二进制跑工作流并输出 HLIL:
# 单函数 (auto 模式,B 优先 / A 兜底)
python tools/deflate_cli.py example/arm64-v8a.so --addr 0x4259f4
# 二进制内所有 CFF 候选 (按 Blazytko 启发式自动找)
python tools/deflate_cli.py example/arm64-v8a.so --all-cff
# 指定路径模式
python tools/deflate_cli.py example/arm64-v8a.so --addr 0x4259f4 --mode switch
# 输出到文件
python tools/deflate_cli.py example/arm64-v8a.so --addr 0x4259f4 --out /tmp/out.c
# 输出去混淆前 HLIL (对照参考)
python tools/deflate_cli.py example/arm64-v8a.so --addr 0x4259f4 --before环境变量 BN_PYTHON 指向 BN 的 python 包目录 (默认
/home/ltlly/tools/binaryninja/python)。
import binaryninja as bn
bv = bn.load("/path/to/binary.so", update_analysis=True)
func = bv.get_function_at(0x4259f4)
settings = bn.Settings()
settings.set_string(
"analysis.workflows.functionWorkflow", "MikuCffHelper_workflow", func
)
wf = bn.Workflow("MikuCffHelper_workflow", object_handle=func.handle)
wf._machine.override_set("analysis.plugins.workflow_patch_mlil_auto", True)
bv.reanalyze()
bv.update_analysis_and_wait()
# 输出去混淆后的 HLIL
for instr in func.hlil.instructions:
print(instr)| Pass | 作用 |
|---|---|
pass_copy_common_block |
把多前驱的公共后继块复制成各自独占的块 (避免后续 SSA 拆分让状态变量丢失) |
pass_inline_if_cond |
LLIL flag 条件内联到 if 中,消除 flag 中转 |
pass_spilt_if_block |
让 if 指令独占基本块,方便后续识别 dispatcher |
| Pass | 作用 |
|---|---|
pass_clear |
折叠常量 if、串联 goto、合并块 |
pass_mov_state_define |
把状态常量赋值挪到块尾,方便从 define 处直接走 dispatcher |
pass_deflate_hard |
路径 A 核心:基于支配树识别 + 前向符号执行的去平坦化 |
pass_synthesize_switch |
路径 B 核心:识别 dispatcher 后写入 jump_to 让 HLIL 渲染为 switch |
suggest_stateVar 命令辅助分析时手动标记状态变量。
- CFF 检测 (Blazytko 支配树法):找
flattening_score(D) ≥ 0.3且 有 back-edge 的块 D 作为 dispatcher 候选;不满足判为非 CFF 函数直接跳过 - 状态变量识别:从 D 后继 BFS 收集"常量比较"的左操作数变量;要求 每个变量被赋予 ≥ 2 个 unique 常量 (过滤 SSA 拆解假阳性)
- 函数级 CFF 启发式:所有状态变量的 unique 常量数 ≥ 4 且值域跨度
≥
0x10000000(避免 Rust match / C++ stdlib 的小常量分发被误判) - dispatcher 子图识别 (Tarjan SCC + 副作用筛选):含 D 的最大 SCC 中 过滤出"纯 dispatcher"块(指令副作用仅限状态变量;禁止 call / store / ret / intrinsic)
- 多级别名链跟踪 (
_vars_aliased_to):从 primary 出发不动点迭代, 在 整个函数 范围内追alias = primary_or_alias链。范围扩大到全函数 是因为 OLLVM CFF 经常用 dispatcher SCC 之外的 alias 拷贝 (典型 sub_408b94x21_1 = lr_1在 dispatcher 块外部被设置,但 dispatcher 内大量if (x21_1 == K)比较)。这一步不影响 dispatcher_blocks 边界 (后者仍由严格 pure_dispatcher 判据决定),只让 case_values 收集 到完整的 cmp 数据 - 前向模拟 (整数解释器):从每个
state = const出发,在 dispatcher 子图内逐块模拟到真实块入口,构建{state_value → real_block_start}映射
P1 必须 全部 满足:
transitions ≥ 2个,distinct targets ≥ 2- fully_resolved:函数里所有
primary = const赋值都至少解析出一个 target - case_values ⊆ transitions:dispatcher 内每一个
(primary == const)比较的 const 都被覆盖。任一未覆盖意味着该 state 值进 jump_to 时 undefined,BN restructurer 会清掉对应 handler,函数语义被破坏 - case_values 非空:candidate 必须实际是 dispatch 变量 (避免 sub_408b94 上 lr_1 被误选)
满足 P1 时,dispatcher_entry 首指令直接被 jump_to(state, label_map)
替换,原 cmp-tree 被吃掉,HLIL 最干净。
P1 失败时启用:
- 在 MLIL 末尾追加 guard block:
jump_to(primary, {V_resolved: T_resolved, V_unresolved: dispatcher_entry}) - 所有 real block 末尾指向 dispatcher_entry 的 goto/if 重定向到 guard
- 原 cmp-tree 完整保留,未解析 case 通过
jump_to → dispatcher_entry → cmp-tree路径兜底 - HLIL 渲染为 switch + default
pass_synthesize_switch 多迭代识别嵌套 dispatcher。OLLVM CFF 经常分多层
平坦化 (内层每个 case body 又是一个状态机)。
iter 1 用默认 _FLATTENING_SCORE_THRESHOLD = 0.3 (Blazytko 经验阈值)
作为 CFF 门控,iter 2+ 用 _NESTED_FLATTENING_SCORE_THRESHOLD = 0.10:
- 内层 dispatcher 在外层 case body 内,支配子树占函数总块数比例自然小
- 0.30 阈值会漏掉所有内层;0.10 阈值能识别内层 (实测 sub_407368 内层 state machine 在 iter 1 后被识别处理)
- iter 2+ 已知函数是 CFF (iter 1 已确认),跳过
_function_looks_like_cff防止内层只有 3-4 个 state 值时被假阳性过滤
新建 dispatcher-like 块 (P2 guard、所有 mini-block) 自动加入
already_rewritten,否则 iter 2+ 的 detect 会把它们误识别为嵌套 dispatcher
导致漏检真正的内层。
P1/P2 安装完后立即跑 _shortcircuit_state_writes:对每个已解析 (V, T),
把函数中所有 primary = V SetVar 替换为 goto mini_block,mini-block 内
是 [primary = V; goto T] (path A 风格 mini-block)。
为什么需要这一步:
- 单纯安装 jump_to / guard 后,真实块仍然
state = V; goto dispatcher绕一圈到 jump_to / guard 才到 handler - BN HLIL Restructurer 看到的是「真实块 → dispatcher → switch → handler」, 没法把它看作自然 CFG,最终输出仍是状态机形态的 switch
- 短路后真实块直接
goto handler,dispatcher 几乎只在初始 state 设置 后被入口跳一次,restructurer 看到的是「真实块 → handler」干净 CFG, 能识别出 if / while / for 等结构,输出更接近 OLLVM 平坦化前的源码
实测效果: 13 个函数 HLIL 行数下降 20-59%。典型例子 sub_407368 的内层 状态机被还原为:
// 短路前 (只有 switch + 状态值切换)
case 0xad2b5e0d:
int32_t i = 0x3288bce9
while (true)
if (i == 0xdbb0e92f) ...
i = -0x214b583
...
// 短路后 (BN 识别出 do-while + 嵌套 while)
case 0xad2b5e0d:
int64_t x8_3 = *var_70
int32_t i = 0x3288bce9
while (i != 0xdbb0e92f) // 自然 while 循环
if (i == 0x3288bce9)
i = -0x214b583
if (i == 0xfdeb4a7d)
int32_t x8_4 = 0x73b2f1f1
while (true) // 嵌套循环
if (x8_4 == 0xde27171) break
if (x8_4 == 0xdd08aef8)
int32_t j = -0x1e9ba5f2
while (j != 0xc0835fbf) // 三层嵌套,原是状态机
if (j == 0x59207abb)
free(x8_3) // 关键 call 完整保留
...保留原 SetVar 副本到 mini-block,state 变量的写入语义不丢失。
参照 Chisel (OOPSLA 2024) 的 Control-Flow Extension (CFE) 形式化:去混淆 trace 是混淆 trace 的子序列,保留所有副作用,删去状态机内部状态写入与 分发判断。
具体到本插件:
- 真实块体未改 → call / store / return 完整保留
- 真实块之间的转移由整型模拟给出,与原状态机分发的具体执行结果一致
- 状态写入仍在 P1 (jump_to 之后) / P2 (guard 之前) 里执行 → 状态变量 在外部读取时数值正确
- 删去的只是 dispatcher 内部的状态比较跳转 → T' 是 T 的子序列
∴ 对外可见副作用集合等价 (CFE 反向)。
为了保证「任何场景下变换等价」,从 5 个独立角度检查:
| 角度 | 实现 | 位置 |
|---|---|---|
| MLIL 副作用集合 ⊇ | _collect_side_effect_signatures 收集 (op_id, address);_verify_no_side_effect_loss 比对 |
pass 内嵌 (pass_synthesize_switch、pass_deflate_hard 末尾),发现丢失 log_error |
| HLIL call 集合 ⊇ | _collect_hlil_side_effects 用 traverse() 递归收集所有 call (含内联在 expression 里的) |
tools/regression_test.py,回归测试时 fresh BV snapshot |
| HLIL store 集合 ⊇ | 同上,识别 *p = v 与 arr[i] = v 形式的 HLIL_ASSIGN |
同上 |
| HLIL return 集合 ⊇ | 同上,HLIL_RET / HLIL_NORET | 同上 |
| 无 ORPHAN 跳转 | HLIL 里搜 jump(0x 子串 (BN 把目标不在块入口的间接跳渲染成这个形式) |
同上 |
为什么需要 HLIL 层 verifier:MLIL 层只看「指令是否物理存在」(op_id+address), 但 BN HLIL Restructurer 偶尔在 jump_to / dispatcher 复杂度过高时把某些 call 从 HLIL 视图剔除 (MLIL 还在,HLIL 看不到)。HLIL 是 用户看到的输出层级, 所以这层验证才是真正"等价性"的最后一道关卡。
_SIDE_EFFECT_OPS 覆盖 23 种 MLIL 操作 (call / store / ret / intrinsic /
trap / syscall 等)。
实测 39 函数全部通过 5 个角度的检查:0 MLIL SE_LOST、0 HLIL call_lost、 0 HLIL store_lost、0 HLIL ret_lost、0 ORPHAN。
1. CFF 门控 (与 5.1 步骤 1-3 共享)
2. dispatcher 子图识别 (与 5.1 步骤 4 共享)
3. 块级前向模拟:
_walk_block_tail: 走完 define 所在块剩余指令 (要求只含 state SetVar/goto/if)
进入 dispatcher 后逐基本块走 _walk_dispatcher_block,
直到落到一个真实块入口
4. 安全约束:
走出 dispatcher 时落点必须 == basic_block.start,
否则放弃 patch (避免跳到块中段产生 jump(addr) 间接跳转)
5. Patch 形式:
把 state SetVar 替换为 [原赋值副本; goto target_real_block_start]
保留赋值副本以维持外部可见副作用
相同 (state_var, value, target) 的 patch 共享同一个 mini-block
- 整型解释器:覆盖 const / var / add / sub / mul / and / or / xor / shift / zx / sx 与全部 10 种比较,完全不依赖 z3
- 单 pass 时间预算 30 秒:超出停止保留已 patch 部分
- 复杂度:O(defines × dispatcher_depth),外层迭代上限 6
build_real_block_transition_graph 返回 {R: set(R')},对每个真实块 R
枚举它内部的状态赋值,forward_resolve 找出对应的下一个真实块 R'。
from MikuCffHelper.passes.mid.deflatHardPass import build_real_block_transition_graph
g = build_real_block_transition_graph(func.mlil)类似 Chisel 的 Control-Flow Skeleton (CFS) 概念,可作为:
- 失败诊断:哪些真实块之间的转移没被 patch
- 未来 synthesis 基础:在此骨架上做 program synthesis 直接生成新函数
clear → mov_state_define
→ synthesize_switch (返回 bool 是否变换)
→ 若 B 没变换:deflate_hard ×2
→ clear
实测 39 函数 (含短路 + 全函数 alias 跟踪):
- 30 函数 HLIL 含
switch关键字 - 7 函数 BN 把 switch 进一步还原为纯 if/while/goto 链 (因短路后真实块 脱离 dispatcher,自然 CFG 结构被识别出来)
- 2 函数仍无 switch / 无显著块数下降 (
sub_42a21cmulti-state 跨函数 引用 dispatcher;sub_45985cdispatcher 全用 CMP_NE/CMP_SGT 没 CMP_E) - 总变换率 37/39 (95%)
- 0 MLIL SE_LOST,0 HLIL call/store/ret 丢失,0 ORPHAN
tools/regression_test.py 把当前快照与 tools/baseline.json 对比,发现
回归非 0 退出:
# 与 baseline 对比 (默认)
python tools/regression_test.py
# 改 heuristic 后确认改进无误,更新 baseline
python tools/regression_test.py --update-baseline
# 只跑某个 binary
python tools/regression_test.py --only arm64-v8a.so
# 单函数调试
python tools/regression_test.py --func 0x4259f4 --bin arm64-v8a.so详见 tools/README.md。
所有关键决策点会输出到 BN Logger (channel MikuCffHelper):
[synth]synthesize_switch 的 P1/P2 选择、拒绝原因、transitions 计数[deflate]deflate_hard 的 dispatcher 检测、forward_resolve 解析失败[auto]auto workflow 的 B 成功 / fallback 到 A 的决策
UI 中 Log 面板按这些 prefix 过滤可快速定位 pass 行为。
- 状态变量识别启发式:依赖"被赋予 ≥ 2 unique 常量",对于使用单一加密 函数生成状态值的变种可能失效
- 未实现条件状态赋值的精确处理:
if (cond) state = A else state = B目前为各 SetVar 独立 patch,没有把分支条件直接落到原 if 上 - 整型解释器局限:状态转移含浮点 / 内存读 / 不支持的运算时会保守跳过
- 多 state 联合分发:dispatcher 用多个 state 变量联合分发时,只会选 unique 常量数最多的一个 primary,其余靠 BN 后续分析消化
- 跨函数 CFF:state 经全局 / 参数跨函数传递的样本不处理
- 极大函数 (>800 块):dispatcher 检测开销 + 多次外层迭代可能超过 BN
默认 60 秒单函数分析时间限制;可调高
analysis.limits.maxFunctionAnalysisTime
| 工作 | 关键贡献 | 我们的采纳 |
|---|---|---|
| Chisel (Mariano et al., OOPSLA 2024) [1] | Trace-informed compositional program synthesis;把 Control-Flow Extension (CFE) 形式化为"原 trace 是混淆 trace 的子序列" | 采纳 CFE 形式化作为等价性论证依据;因为没有 trace,改用支配树 + 状态变量 unique-value 启发式 |
| Blazytko 自动检测 flattening (synthesis.to, 2021) [2] | flattening_score = #{被 D 支配的块} / #{总块数};要求被 D 支配的块跳回 D | 直接作为 dispatcher 入口检测 + 函数级 CFF 门控 |
| D810 (eshard 博客) [3] | 基于 Hex-Rays microcode;MopTracker 反向追状态变量;多值时块复制 | 参考"状态变量反向追踪"思路 |
| CaDeCFF (Internetware 2022) [4] | forward DFA 找 useful blocks;selective symbolic execution 恢复 CFG | 启发"识别真实块"方向 |
| FlowSight (IEEE SEAI 2025) [5] | data-flow-aware 的 OO Block 概念 | 借用"区分 dispatcher 块与真实块"的二分思路 |
| DEBRA (Workshop on SURE 2025) [6] | 真实世界去混淆方法的 benchmark | 评测方法论参考 |
| ollvm-unflattener [7] | 开源工具,~83% 通过率 | 对比基线 |
| Zerotistic CFF Remover [8] | dispatcher 的 weighted scoring;3 阶段状态变量识别 | 参考多阶段验证 |
[1] Mariano, B., Wang, Z., Pailoor, S., Collberg, C., & Dillig, I. (2024). Control-Flow Deobfuscation Using Trace-Informed Compositional Program Synthesis. Proc. ACM Program. Lang. 8, OOPSLA2, Article 349.
[2] Blazytko, T. (2021). Automated Detection of Control-flow Flattening. synthesis.to blog.
[3] eshard. D810: A journey into control flow unflattening.
[4] CaDeCFF: Compiler-Agnostic Deobfuscator of Control Flow Flattening. Proceedings of the 13th Asia-Pacific Symposium on Internetware, 2022.
[5] FlowSight: A Data Flow-Aware Control Flow Flattening Deobfuscation Approach. IEEE 5th International Conference on Software Engineering and Artificial Intelligence (SEAI), 2025.
[6] DEBRA: A Real-World Benchmark For Evaluating Deobfuscation Methods. 2025 Workshop on Software Understanding and Reverse Engineering.
- 把
if (cond) state = A else state = B模式直接 rewrite 成if (cond) goto T_A else goto T_B - 跨函数 CFF:识别 state 变量的全局 / struct 偏移,跨调用图传递 forward_resolve 的环境
- 动态等价性 fuzzer:随机输入跑前后两个版本,比 trace (call sequence + 内存写 + 返回值),比静态副作用签名更可靠
- 多 state primary 联合分发:把 N 个 state var 合成 (N×bitwidth) 虚拟 var,jump_to 用合成 key