这份代码实现虽然年代较早,但其所阐述的原理依然经典。本文引用自 qtfreet00/llvm-obfuscator 中关于控制流平坦化的部分。
核心思想
平坦化(Flattening)的核心目标是将函数原本的、具有清晰层次结构的控制流图(CFG),转换成一个难以直接阅读的“扁平”结构。其主要手段是引入一个中心调度器(Dispatcher),所有原始基本块的执行都由这个调度器通过一个 switch 语句来分发。
原始: 平坦化后:
Entry Entry
| |
▼ ▼
Block A ┌─ Dispatcher (switch) ◄──┐
| │ │ │ │ │
▼ ▼ ▼ ▼ ▼ │
Block B ──► Block C A B C D │
| │ │ │ │ │
▼ └────┴────┴────┘ │
Block D 更新 switchVar ──────────┘
代码解析
实现流程通常包含以下步骤,这些步骤是许多编译时混淆技术的基础:
- 原函数中已有的
switch 会干扰后续操作,先转成 if-else
- 收集所有基本块,入口块单独处理
- 将入口块拆成2部分
- 创建 Switch 变量和 Dispatcher
- 将每个原始块挂到 switch 的 case 上
- 修改每个块的跳转,变成“更新 switchVar + 跳回 dispatcher”
为什么要拆分 Entry Block
入口块(Entry Block)扮演着特殊角色:它需要转变为 “调度器的入口”(负责分配 switch 变量并跳转到 dispatcher)。但如果原始入口块中已经包含了业务逻辑和条件跳转,就无法直接将其用作这个简单的入口。
假设 EntryBB 原本的结构如下:
┌─────────────────────────────┐
│ EntryBB │
│ │
│ %a = add %x, 1 │
│ %b = mul %a, 2 │
│ %cmp = icmp sgt %b, 10 │
│ br i1 %cmp, BB1, BB2 │ ← 条件跳转(2个后继)
└─────────┬──────────┬────────┘
│ │
▼ ▼
┌────────┐ ┌────────┐
│ BB1 │ │ BB2 │
│ doA() │ │ doB() │
└───┬────┘ └───┬────┘
│ │
▼ ▼
┌──────────────┐
│ BB3 │
│ doC() │
│ ret │
└──────────────┘
平坦化期望 EntryBB 变成这样:
EntryBB:
switchVar = 初始值
br label %dispatcher
但现在的 EntryBB 里既有业务代码(%a = add, %b = mul),又有条件跳转(br i1 %cmp),直接覆盖就会丢失这些逻辑。因此,必须进行拆分。
解决方案:在指定的指令处将一个基本块切割成两半。切割点及其之后的指令被移动到新创建的基本块中,而原始块会自动添加一条无条件跳转指令跳转到这个新块。
splitBasicBlock(terminator)
切这里
↓
┌─────────────────────────────────────┐
│ EntryBB │
│ │
│ %a = add %x, 1 │
│ %b = mul %a, 2 │ ← 这些留在 EntryBB
│ %cmp = icmp sgt %b, 10 │
│ ─ ─ ─ ─ ─ ─ ─ ✂️ 切这里 ─ ─ ─ ─ ─ │
│ br i1 %cmp, BB1, BB2 │ ← 这条移到新块
└─────────────────────────────────────┘
拆分后得到两个块:
┌─────────────────────────────┐
│ EntryBB │
│ │
│ %a = add %x, 1 │
│ %b = mul %a, 2 │
│ %cmp = icmp sgt %b, 10 │
│ br label %first │ ← 自动生成的无条件跳转
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ “first”(新块) │
│ │
│ br i1 %cmp, BB1, BB2 │ ← 原来的 terminator 搬到这里
└─────────┬──────────┬────────┘
│ │
▼ ▼
BB1 BB2
拆分完成后,就可以对 EntryBB 进行平坦化改造了:
把 EntryBB 的 “br label %first” 删掉,替换成:
┌─────────────────────────────┐
│ EntryBB │
│ │
│ %a = add %x, 1 │ ← 原有业务代码保留
│ %b = mul %a, 2 │
│ %cmp = icmp sgt %b, 10 │
│ │
│ %switchVar = alloca i32 │ ← 新增:分配 switch 变量
│ store FIRST_CASE_ID, │ ← 新增:初始值 = “first” 块的 case 编号
│ %switchVar │
│ br label %dispatcher │ ← 新增:跳到调度器
└─────────────┬───────────────┘
│
▼
dispatcher
其他所有基本块(包括新生成的 first 块)的跳转逻辑也需要被修改,变为“更新 switch 变量值 + 跳回 dispatcher”的模式:
┌──────────────────┐ ┌──────────────────┐
│ “first” │ │ BB1 │
│ (case 111) │ │ (case 222) │
│ │ │ │
│ 原: br i1 %cmp │ │ call doA() │
│ BB1, BB2 │ │ │
│ │ │ store 444, │
│ 改: %next = │ │ %switchVar│ ← 下一步去 BB3
│ select %cmp, │ │ br dispatcher │
│ 222, 333 │ └────────┬─────────┘
│ store %next, │ │
│ %switchVar│ │
│ br dispatcher │ ┌──────────────────┐
└────────┬─────────┘ │ BB2 │
│ │ (case 333) │
│ │ │
│ │ call doB() │
│ │ │
│ │ store 444, │
│ │ %switchVar│ ← 下一步也去 BB3
│ │ br dispatcher │
│ └────────┬─────────┘
│ │
▼ ▼
dispatcher ◄───────────────┘
最终,整个函数的结构将演变为一个中心化的调度循环:
┌────────────────────────────────┐
│ EntryBB │
│ │
│ %a = add %x, 1 │
│ %b = mul %a, 2 │
│ %cmp = icmp sgt %b, 10 │
│ %switchVar = alloca i32 │
│ store 111, %switchVar │
│ br label %dispatcher │
└───────────────┬────────────────┘
│
▼
┌────────────────────────────────┐ ◄─────────────────────┐
│ dispatcher │ │
│ │ │
│ %val = load %switchVar │ │
│ switch %val: │ │
│ case 111 → first │ │
│ case 222 → BB1 │ │
│ case 333 → BB2 │ │
│ case 444 → BB3 │ │
└──┬──────┬──────┬──────┬────────┘ │
│ │ │ │ │
▼ │ │ │ │
┌──────┐ │ │ │ │
│first │ │ │ │ │
│ │ │ │ │ │
│select│ │ │ │ │
│222/ │ │ │ │ │
│ 333 │ │ │ │ │
│store │──┼──────┼──────┼──►──────────────────────────────┤
└──────┘ │ │ │ │
▼ │ │ │
┌──────┐ │ │ │
│ BB1 │ │ │ │
│doA() │ │ │ │
│store │ │ │ │
│ 444 │────┼──────┼──►──────────────────────────────┤
└──────┘ │ │ │
▼ │ │
┌──────┐ │ │
│ BB2 │ │ │
│doB() │ │ │
│store │ │ │
│ 444 │────┼──►──────────────────────────────┤
└──────┘ │ │
▼ │
┌──────┐ │
│ BB3 │ │
│doC() │ │
│ ret │ ← 函数出口,不回 dispatcher │
└──────┘ │
在真实实现中,各个基本块对应的 case 值通常是随机生成的。
还原思路
既然平坦化是通过追踪 switchVar 的值来决定执行流程,那么理论上,只要跟随这个变量的值走完全程,就能还原出原始的控制流。
对于简单的线性流程,还原非常直接:
Entry: switchVar = 0xAAAA
│
▼
Dispatcher: switch(switchVar)
case 0xAAAA → Block A → switchVar = 0xBBBB → goto Dispatcher
case 0xBBBB → Block B → switchVar = 0xCCCC → goto Dispatcher
case 0xCCCC → Block C → switchVar = 0xDDDD → goto Dispatcher
case 0xDDDD → Block D → return
还原:A → B → C → D ✅
你从 Entry 块获得初始的 switchVar 值,在 dispatcher 的 switch 表中找到对应的第一个 case 块,执行完该块后,查看它将 switchVar 改成了什么值,再去查下一个 case……通过这种链式追踪就能还原出顺序。这种情况下,静态分析就能轻易解决,甚至写个简单脚本即可完成。
对于包含分支的情况,思路类似,只是稍微复杂,但仍可还原。
原始逻辑:
if (x > 0)
doA();
else
doB();
doC();
平坦化后可能的结构:
Block_if:
if (x > 0)
switchVar = 0x1111 // → doA
else
switchVar = 0x2222 // → doB
br dispatcher
Block_doA:
call doA()
switchVar = 0x3333 // → doC
br dispatcher
Block_doB:
call doB()
switchVar = 0x3333 // → doC
br dispatcher
Block_doC:
call doC()
ret
追踪与还原过程:
Block_if 有两条路径:
├─ x > 0 → switchVar = 0x1111 → Block_doA → switchVar = 0x3333 → Block_doC
└─ x <= 0 → switchVar = 0x2222 → Block_doB → switchVar = 0x3333 → Block_doC
还原:
Block_if
/ \
Block_doA Block_doB
\ /
Block_doC
完美还原 ✅
因此,现有反混淆工具的核心思路高度一致,都是围绕着追踪 switchVar 的赋值链,进而重建原始控制流图(CFG)。
工具 方法
─────────────────────────────────────────────────────
手动 IDA 脚本 静态追踪 switchVar 赋值
D-810 (IDA 插件) 模式匹配 + 符号执行
Miasm 符号执行追踪 switchVar
Binary Ninja + 脚本 数据流分析追踪常量传播
Triton 动态符号执行
LLVM opt Pass 在 IR 层做常量传播 + CFG 重建
它们的核心都是一样的:追踪 switchVar 的赋值链,重建 CFG。 当然,这只是最原始、基础的 OLLVM 平坦化,现代混淆技术在此基础上增加了更多对抗静态分析与符号执行的手段。
若你对这类底层软件安全技术感兴趣,欢迎在云栈社区的安全与逆向工程板块与其他开发者深入探讨。