OLLVM笔记_2—Flat学习

控制流平坦化(Control Flow Flattening)是OLLVM中最重要的混淆技术之一。它的核心思想是将原本清晰的多分支控制流转换为一个基于switch语句的扁平循环结构,通过状态变量来控制程序的执行流程,从而大大增加逆向分析的难度。

原理

控制流平坦化的本质是将程序的控制流转换为状态机模式。在原程序中,控制流直接通过跳转指令(如jccjmp)来实现,这种结构在静态分析时过于暴露原逻辑。而平坦化技术将这种直接的控制流转换为间接的状态转换机制

具体来说,平坦化过程将每个基本块(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
2
3
4
5
6
7
8
void func() {
int x = 10; // 入口bb的指令
if (condition) { // 入口bb的terminator
goto BB1;
} else {
goto BB2;
}
}

因为在flat下需要在入口bb处加入switchVarhashVar的初始化代码,这里文字比较抽象,直接用图展示:

这样就保证了逻辑冲突,保证了入口bb提供干净的插入点。

状态分配

为每个bb分配唯一的状态标识,后续通过这些标识来控制程序流程

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

这一步之后实际上就能得到接近于下边的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
while(true) 
{
switch(var)
{
case 0x1234:
BB1; break;
case 0x5678:
BB2; break;
case 0x9ABC:
BB2; break;
.....
}
}

创建主循环结构

  • switchVar:存储当前状态,初始值经过加密扰乱。
  • loopEntry:主循环入口,加载switchVar并进行switch分发。
  • loopEnd:循环结尾,所有bb执行完后跳转到这里,然后回到loopEntry。

其中AllocaInst意为在栈上创建变量,ConstantInt::get 获取指定类型的常量数据,用于给分配的变量赋初值。另外这里有个细节需要讲解一下:

1
2
3
4
5
6
7
8
9
10
11
  /// \brief Creates a new BasicBlock.
  ///
  /// If the Parent parameter is specified, the basic block is automatically
  /// inserted at either the end of the function (if InsertBefore is 0), or
  /// before the specified basic block.
  static BasicBlock *Create(LLVMContext &Context, const Twine &Name = "",
                            Function *Parent = nullptr,
                            BasicBlock *InsertBefore = nullptr);
                           
  loopEntry = BasicBlock::Create(f->getContext(), "loopEntry", f, insert);
  loopEnd = BasicBlock::Create(f->getContext(), "loopEnd", f, insert);

因为创建的两个bb都是插入在insert前,因此这两句执行完毕后,loopEntryloopEndinsert的关系是这样的loopEntry->loopEnd->insert,通过前边的代码逻辑可知,insert为第一个bb,当前的关系显然不合理,因此需要调整。

1
insert->moveBefore(loopEntry);

这样关系就变成了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这层关系,这里实际上就是把insertloopEntry断开后又重新连接。

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

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

分发逻辑修复

这一片就是处理bb之间的逻辑,llvm将处理分为三种情况:retBB(函数返回)、jmpBB(无条件跳转)、jccBB(条件跳转)。

retBB

该块是以ret结尾的结束块。这种块因为不需要重新回到分发器,所以不用做任何处理,直接进行continue

jmp

通过后继结点是否为1进行判断是否为jmpBB

然后获取后继结点的numCasecase的条件)。

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

这一块处理就变成了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
; before flat
BB1: br label %BB3
BB2: br label %BB1
BB3: ret

; after flat
loopEntry:
%val = load i32, %switchVar
switch i32 %val [
i32 case1, label %BB1
i32 case2, label %BB2
i32 case3, label %BB3
]

BB1:
store case3, %switchVar ; update switchVar
br label %loopEnd

BB2:
store case1, %switchVar ; update switchVar
br label %loopEnd

BB3:
ret

jcc

通过后继结点是否为2进行判断是否为jccBB

首先获取两个bb的numCase

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

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

这一部分经过处理后变为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
; before flat
BB1: br i1 %cond, label %BB2, label %BB3
BB2: br label %BB4
BB3: br label %BB4
BB4: ret

; after flat
loopEntry:
%val = load i32, %switchVar
switch i32 %val [
i32 case1, label %BB1
i32 case2, label %BB2
i32 case3, label %BB3
i32 case4, label %BB4
]

BB1:
; origin:br i1 %cond, label %BB2, label %BB3
%next = select i1 %cond, i32 case2, i32 case3
store %next, %switchVar ; update switchVar
br label %loopEnd

BB2:
; origin:br label %BB4
store case4, %switchVar ; update switchVar
br label %loopEnd

BB3:
; origin:br label %BB4
store case4, %switchVar ; update switchVar
br label %loopEnd

BB4:
ret

loopEnd:
br label %loopEntry

修复后,基本上就可以将原始代码转为开头图片的效果。

FixStack

平坦化改变了程序的控制流结构后,会产生两个关键问题:PHI节点失效寄存器值逃逸。由于将原始程序进行分割为不同bb后,会导致每个bb中部分变量或者寄存器的值和来源无法确定。

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

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

对于PHI Node节点通过函数DemotePHIToStack进行处理;逃逸寄存器通过DemoteRegToStack进行处理。这两个函数原理比较复杂,本人目前比较菜无法进行剖析。

总结

Flattening相对BCF复杂很多,但核心思路很巧妙,通过状态机+加密扰乱就能把控制流彻底打乱。自己琢磨了一些魔改思路:

  1. 可以搞多层状态机嵌套,第一层switch分发到子状态机,第二层再分发到具体bb
  2. 动态更新加密密钥,在某些bb里改scrambling_key,让后面的状态值用新密钥加密
  3. 注入虚假状态,在switch里加永远不会执行的case,类似BCF的虚假分支
  4. 把SelectInst换成复杂的算术运算来计算下一状态

对抗上感觉比BCF难多了,静态分析要破解加密+重建控制流,动态分析要跟踪switchVar变化。实战中肯定是BCF+Fla组合拳,单独用一种没啥意义。

参考

OLLVM 之控制流平坦化源码学习

[原创]基于LLVM Pass实现控制流平坦化