控制流平坦化在C++中无法通过标准语言特性直接实现,必须依赖编译器插件、LLVM IR改写或手动状态机模拟;普通switch+goto会被现代编译器优化还原,而真正有效的方案需依赖不可静态解析的状态值、禁用优化、LLVM Pass重写CFG或setjmp/longjmp伪造跳转,并彻底清理调试信息与符号。

控制流平坦化在C++中无法通过标准语言特性直接实现,必须依赖编译器插件、LLVM IR改写或手动状态机模拟——原生C++没有“跳转表驱动的统一调度循环”这种机制。
为什么不能用普通switch+goto简单模拟
表面看,写个大switch套所有基本块,用state变量跳转,似乎就“平坦”了。但实际会被现代编译器(GCC/Clang)自动识别并优化掉:启用-O2后,switch可能被转成跳转表、二分查找甚至内联展开;goto链会被SSA重建还原为原始CFG。IDA仍能恢复出接近源码的结构。
常见错误现象:
- 手动写的while (state) { switch(state) { case 1: ... state = 2; break; } }在反汇编里仍是清晰的块间跳转
- IDA的Flatten flow chart功能对这类代码无效,因为它只处理编译器生成的、带模糊间接跳转的IR模式
真正起作用的要点:
- 必须让控制流转移完全依赖一个不可静态解析的
uint64_t型状态值(非枚举、非常量传播路径) - 每个基本块结尾必须是
state = next_state_func(...),且next_state_func不能被内联或常量折叠 - 入口点需绕过编译器的CFG分析:例如用
reinterpret_cast跳进函数体中间,或通过__builtin_unpredictable干扰分支预测建模
LLVM Pass是最可靠的方式(Clang编译链)
手动改写IR比在C++层模拟更底层、更难逆向。Clang默认生成LLVM IR,此时插入自定义Pass可将CFG重写为单循环+跳转表+加密状态调度。
立即学习“C++免费学习笔记(深入)”;
关键操作:
- 遍历函数所有BasicBlock,提取指令序列,清空原BB内容
- 新建一个主循环BB:
while(1) { switch(state) { ... } },其中每个case对应原BB逻辑 - 把原BB中的phi节点、寄存器依赖转为显式
state参数和返回值(例如int calc(int a, int b) → struct { int res; uint64_t next_state; } calc_flat(uint64_t state, int a, int b)) - 禁用
-mllvm -enable-indvar=false -mllvm -disable-loop-vectorization防止优化器还原循环结构
示例片段(LLVM IR伪码):
%state = load i64, i64* %state_ptr<br> %sw.bb = select i1 %cond, i64 101, i64 102<br> store i64 %sw.bb, i64* %state_ptr ; 不是直接br,而是写state再loop顶部读
IDA看到的是连续的
mov rax, [rbp-8]; cmp rax, 101; je .L101,但所有.L*标签都在同一函数内,无call/ret嵌套,CFG图退化为星型。
运行时混淆:用setjmp/longjmp伪造非局部跳转
这是C++兼容性最高、无需修改编译器的方法,但性能差、栈帧不可控,且longjmp会跳过析构——仅适用于纯C风格函数或明确禁用异常的模块。
使用场景:
- 函数内无对象生命周期管理(即不构造/析构std::string等)
- 目标平台支持setjmp(Windows MSVC需/EHs-)
实操要点:
- 每个逻辑块开头调用
if (setjmp(env[i])) goto block_i;,共N个env数组 - 跳转时用
longjmp(env[j], 1),而非直接goto——IDA无法建立longjmp目标与源的静态关联 - 必须把所有
env数组声明为static jmp_buf env[256],避免栈上分配被优化掉 - 禁用
-fomit-frame-pointer,否则longjmp恢复栈指针失败
IDA会把每个setjmp识别为独立函数入口,longjmp显示为“unresolved indirect jump”,反编译伪码出现大量goto LABEL_XX;且LABEL位置随机散列。
容易被忽略的破坏点:调试信息与符号残留
即使CFG被成功平坦化,IDA仍可能通过.debug_line或.symtab重建原始逻辑顺序——尤其当使用-g编译时。
必须同步清理:
- 链接时加
--strip-all -x彻底删除符号和调试段 - 避免在平坦化代码中出现可识别的字符串(如
"error"、"failed"),它们会成为IDA交叉引用锚点 - 若用LLVM Pass,确保Pass也重写
!dbg元数据,否则opt -strip-debug之后IR仍带行号映射 - Windows下注意PDB文件:即使EXE剥离,单独PDB仍可加载恢复逻辑——发布前务必删掉
.pdb
最隐蔽的问题是:平坦化后的代码如果包含未初始化的std::vector或虚函数调用,RTTI信息(.rdata中的type_info)会暴露类层次和方法数量,间接泄露控制流分支数。这时候得配合vtable加密或直接用void*模拟多态。


















