OLLVM笔记_2—Flat学习
控制流平坦化(Control Flow Flattening)是OLLVM中最重要的混淆技术之一。它的核心思想是将原本清晰的多分支控制流转换为一个基于switch语句的扁平循环结构,通过状态变量来控制程序的执行流程,从而大大增加逆向分析的难度。
原理
控制流平坦化的本质是将程序的控制流转换为状态机模式。在原程序中,控制流直接通过跳转指令(如jcc、jmp)来实现,这种结构在静态分析时过于暴露原逻辑。而平坦化技术将这种直接的控制流转换为间接的状态转换机制。
具体来说,平坦化过程将每个基本块(bb)视为状态机中的一个状态,程序的执行变成了状态之间的转换。所有的状态转换都通过一个统一的分发器(switch语句)来实现,而分发的依据是一个动态计算的状态变量。这样,原本可以通过静态分析直接看出的控制流路径,现在必须通过运行时的状态计算才能确定。
这种转换的巧妙之处在于,它保持了程序的功能完全不变,但将控制流的决策过程从编译时转移到了运行时,并且通过哈希计算等复杂操作来掩盖真实的状态转换逻辑,从而达到混淆的目的。
比如一个原始的程序如下:

经过flat之后就变成了:

源码分析
对应实现代码路径:lib/Transforms/Obfuscate/Flattening.cpp

从继承的类为FunctionPass可以看到,Flat的处理单元为函数,实际处理逻辑在flatten中。
收集BB
第一步是收集函数中所有的bb,排除异常处理和过小的函数。

InvokeInst在LLVM-IR中并不是单纯的call指令,而是类似带有三元语法的call指令。%result = invoke i32 @function(i32 %arg) to label %normal unwind label %exception,如果函数正常返回,跳转到%normal标签,否则跳转到%exception标签进行异常处理。
入口预处理

这一部分主要是为了处理下边这种类型的入口:
1 | |
因为在flat下需要在入口bb处加入switchVar和hashVar的初始化代码,这里文字比较抽象,直接用图展示:

这样就保证了逻辑冲突,保证了入口bb提供干净的插入点。
状态分配
为每个bb分配唯一的状态标识,后续通过这些标识来控制程序流程

此时,每个bb就对应着一个随机索引。

这一步之后实际上就能得到接近于下边的逻辑:
1 | |
创建主循环结构

switchVar:存储当前状态,初始值经过加密扰乱。loopEntry:主循环入口,加载switchVar并进行switch分发。loopEnd:循环结尾,所有bb执行完后跳转到这里,然后回到loopEntry。
其中AllocaInst意为在栈上创建变量,ConstantInt::get 获取指定类型的常量数据,用于给分配的变量赋初值。另外这里有个细节需要讲解一下:
1 | |
因为创建的两个bb都是插入在insert前,因此这两句执行完毕后,loopEntry、loopEnd、insert的关系是这样的loopEntry->loopEnd->insert,通过前边的代码逻辑可知,insert为第一个bb,当前的关系显然不合理,因此需要调整。
1 | |
这样关系就变成了insert->loopEntry->loopEnd。最后再通过BranchInst::Create创建分支即可,最后得到如下结构:

接下来就是对分发器(执行体)进行实现。
switch分发器
首先预创建一个bb,然后将loopEnd连接到swDefault得到关系:loopEnd->swDefault。

对预创建的bb(swDefault)创建switch结构,设置条件为load(这个变量为上边的switchVar),并插入在loopEntry之后。

然后将f->begin()去除结尾bb后,将loopEntry连接在其后。这里f->begin()实际上与insert相同,而且我个人认为这里是重复操作。因为上边已经得到了insert->loopEntry->loopEnd这层关系,这里实际上就是把insert和loopEntry断开后又重新连接。

这里就是将所有的bb在switch中添加分支,将随机的编号和对应的基本块对应起来。

但是注意,这里如同注释所说,只仅仅是添加,并没有去做逻辑处理,因此此时的代码执行顺序是未知的。
分发逻辑修复

这一片就是处理bb之间的逻辑,llvm将处理分为三种情况:retBB(函数返回)、jmpBB(无条件跳转)、jccBB(条件跳转)。
retBB
该块是以ret结尾的结束块。这种块因为不需要重新回到分发器,所以不用做任何处理,直接进行continue。
jmp
通过后继结点是否为1进行判断是否为jmpBB。

然后获取后继结点的numCase(case的条件)。

重新赋值switchVar后,将当前bb连接到loopEnd,重新进入分发器。

这一块处理就变成了。
1 | |
jcc
通过后继结点是否为2进行判断是否为jccBB。

首先获取两个bb的numCase。

然后直接创建一个SelectInst语句,根据条件动态选择目标case值。

将当前bb连接到loopEnd,重新进入分发器。

这一部分经过处理后变为:
1 | |
修复后,基本上就可以将原始代码转为开头图片的效果。
FixStack
平坦化改变了程序的控制流结构后,会产生两个关键问题:PHI节点失效和寄存器值逃逸。由于将原始程序进行分割为不同bb后,会导致每个bb中部分变量或者寄存器的值和来源无法确定。

llvm对于这种处理思路是直接用栈来平替。

首先是收集所有的PHI Node和逃逸寄存器。

对于PHI Node节点通过函数DemotePHIToStack进行处理;逃逸寄存器通过DemoteRegToStack进行处理。这两个函数原理比较复杂,本人目前比较菜无法进行剖析。
总结
Flattening相对BCF复杂很多,但核心思路很巧妙,通过状态机+加密扰乱就能把控制流彻底打乱。自己琢磨了一些魔改思路:
- 可以搞多层状态机嵌套,第一层switch分发到子状态机,第二层再分发到具体bb
- 动态更新加密密钥,在某些bb里改
scrambling_key,让后面的状态值用新密钥加密 - 注入虚假状态,在switch里加永远不会执行的case,类似BCF的虚假分支
- 把SelectInst换成复杂的算术运算来计算下一状态
对抗上感觉比BCF难多了,静态分析要破解加密+重建控制流,动态分析要跟踪switchVar变化。实战中肯定是BCF+Fla组合拳,单独用一种没啥意义。