Windows 平台搞 LLVM
LLVM 在 Linux 和 Windows 平台下的具体区分不是很大,困难在如果需要对驱动或者 shellcode 加密,需要调用 MSVC 的链接器,在编译的时候很麻烦
编译
下载比较新的 llvm-project,虽然只用得到的 clang 和 llvm
|
|
在项目根目录下新建Directory.build.props
|
|
打开 VS 2022 的命令行
|
|
等待编译完成即可
修改 CMakeLists.txt 和部分源码
因为基本上是迁移和重写 Pluto-Obfuscator 的工作,所以就不直接从 OLLVM 搬了
在llvm/include/llvm/Transforms
以及llvm/lib/Transforms
目录下创建Obfuscation
文件夹
修改llvm/lib/Transforms
下的CMakeLists.txt
,添加
|
|
以字符串混淆为例,在新创建的Obfuscation
中分别添加StringObfuscation.h
和StringObfuscation.cpp
然后在lib
目录下的Obfuscation
中添加CMakeLists.txt
,内容如下
|
|
然后注册宏,需要先在StringObfuscation.h
中编写好 PassManager 继承类
|
|
然后在llvm/lib/Tranforms/Passes
中,对PassBuilder.cpp
、PassRegistry.def
和CMakeLists.txt
进行修改
-
在
PassBuilder.cpp
中添加对StringObfuscation.h
头文件的引用 -
在
PassRegistry.def
中对应的 Pass 中添加对应内容,比如字符串混淆是 MODULE 层的 Pass,则添加MODULE_PASS("string-obfus", StringObfuscationPass())
,如果是 FUNCTION 层的 Pass,添加对应的FUNCTION_PASS
即可- 这里可能会因为名字过短而无法实现使用?不太清楚
-
在
CMakeLists.txt
中添加对Obfuscation
的链接1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
add_llvm_component_library(LLVMPasses OptimizationLevel.cpp PassBuilder.cpp PassBuilderBindings.cpp PassBuilderPipelines.cpp PassPlugin.cpp StandardInstrumentations.cpp ADDITIONAL_HEADER_DIRS ${LLVM_MAIN_INCLUDE_DIR}/llvm ${LLVM_MAIN_INCLUDE_DIR}/llvm/Passes DEPENDS intrinsics_gen LINK_COMPONENTS AggressiveInstCombine Analysis ... Obfuscation # add here )
最后在StringObfuscation.cpp
编写 Pass,完成之后ninja
编译即可,一个简单的例子如下
|
|
可以通过opt.exe --help
来查看优化选项,如果成功注册可以看到选项中多出了刚写的 Pass
如果需要用到 OLLVM 中的CryptoUtils.cpp
,需要修改CryptoUtils.h
中的一个宏定义
|
|
在 VS2022 中使用
因为编译 PE 文件,需要调用 MSVC 的链接器,这里需要一个插件 LLVM2019,在项目属性中选择 LLVM
然后在新增的 LLVM 中将Use lld-link
关闭
关闭优化,关闭 C/C++ 命令行中的从父级或项目默认设计继承,如果使用 Plugin 来加载 Pass ,前面 clang-cl.exe 的目录设置为编译 Plugin 的 LLVM 目录
这样在编译导出的dll插件时就能顺利编译通过了
混淆
其实这些在以前的博客中也写过,单独拿出来再写一次(
字符串混淆
Pass 处理在 IR 中要找到所有的字符串常量是比较容易的,直接遍历 module->globals 即可,但是问题的关键是,加密范围,解密时机和解密存储位置,目前来说开源方案的几种方案:
- 加密范围:
- 指令中直接引用的 CDS
- 指令中间接引用的 CDS
- 解密时机:
- ctor 解密
- Function 入口解密
- Instruction 前解密
- 解密存储位置:
- 解密到段上
- 解密到栈上
这里采取的是 ctor 解密,尽管 Windows 的 PE 加载与 ELF 不同,但在进入代码的入口函数之前都要经过 CRT 的全局初始化,所以将解密函数插入到初始化的过程中就能实现解密了
这样实现的就是编译期加密,运行期解密的字符串混淆,只能对抗静态分析,动态运行之后无法对抗 DUMP 手段,优点是编写方便且不容易导致程序崩溃,并且对性能的损耗很小
(暂时还不支持std::string
,会报错,很迷惑)
虚假控制流
虚假控制流将正常的控制流拆分,通过不透明谓词的特性,使得静态分析无法通过程序分析来计算条件的真假
一个典型的虚假控制流如下图,Opaque 表示不透明谓词,是一个永真式
Opaque 是永真式,通常是某个不能被静态程序分析得到的条件式(谓词),从上面的控制流图来看,真正的控制流只有 EntryBlock’->Body->End,而 Altered 永远不会跳转到,也就不会影响正常的程序执行
整个 Pass 根据某个概率阈值确定是否对 BasicBlock 进行拆分,然后根据混淆次数对整个 Function 中的 BasicBlocks 进行不断拆分,从而实现虚假控制流混淆
这里用到的不透明谓词有两个,第一个是 prime1 * (((x & 0x3FF) | any1)^2) != prime2 * (((y & 0x3FF) | any2)^2)
,其中x
和y
分别是程序中可用的变量,prime1
和prime2
是随机素数,any1
和any2
是随机整数
opt
优化第二个是y < 10 || x * (x + 1) % 2 == 0
,x
和y
均为全局变量
控制流平坦化
控制流平坦化同样也是修改正常的控制流,将正常的控制流修改为一个如下伪代码和控制流图所示的结构
|
|
根据原本控制流的基本块跳转顺序来设定Var
,从而确定switch
结构下一个要跳转的case
,即下一个需要跳转的 BasicBlock
Pass 为每一个 BasicBlock 设置一个随机的SwitchVar
,并在结尾根据后继基本块的跳转在基本块的末尾设置对应的SwitchVar
,进入 Return 块之后跳转到 Dispatch 块,Dispatch 块中根据当前SwitchVar
的值选择对应的case
多项式 MBA 指令替换
简单来说,Mixed Boolean-Arithmetic(MBA) 是将普通的计算表达式替换成由布尔运算构成的等价计算表达式,形如
而一个线性 MBA 构造形如
其中 $e_i(x_1, …, x_t)$ 表示的是由变量 $(x_1, …, x_t)$ 构成的布尔运算表达式,$a_i$ 表示的是系数
先从线性 MBA 开始,如果我们想混淆一个x + y
表达式,可以计算出相对应的混合布尔运算表达式(x ^ y) + 2 * (x & y)
,通过 z3 可以得到验证
我们可以通过以下的方式来构造一个线性 MBA 表达式:
对正整数 $n, s, t$ , $n$ 为整数位数, $s$ 为布尔表达式个数, $t$ 为变量个数, $x_i, i \in [1,t]$ 为 $B^n$ 上的变量, $e_j, j \in [0, s-1]$ 为 $x_i$ 组成的按位运算表达式,得到线性 MBA 表达式
$$ E = \sum^{s - 1}_{j = 0} a_j e_j $$
共有 $2^t - 1$ 种状态,令
$$ f_j = (v_{0, j}, …, v_{i, j}, v_{2^t - 1, j})^{\top} $$
表示真值表的列向量,令 $A = (v_{i, j})_{2^t \times s}$ 为 $\mathrm{Z}/2^n$ 环上的 0-1 真值表矩阵
若要 $E = 0$ 有解,当且仅当 $AY = 0$ 在 $\mathrm{Z}/2^n$ 环上有解
Proof:
若 $E = 0$ ,$(a_0, a_1, …, a_{s-1})^{\top}$ 为 $e = 0$ 的一种解
假设解存在,令 $z_{j,i}$ 表示 $e_j$ 的二进制位值,即
则 0-1 真值表矩阵的行的第 i 位向量可表示为 $(z_{0, i}, …, z_{s - 1, i})$
通过上面的假设可以得到,
因此有
得证
通过上面的数学推导,我们只需要构造出一个 0-1 真值表矩阵 $A$ ,并在 $\mathrm{Z}/2^n$ 环上求出 $AY = 0$ 的解,最后再加上原本表达式的操作数,就能得到一个等价的、经过混淆的线性 MBA 表达式
比如现在有五个布尔表达式, $f_0(x,y) = x$ 、 $f_1(x,y) = y$ 、 $f_2(x,y) = x \lor y$ 、 $f_3(x,y) = \neg(x \land y)$ 、 $f_4(x,y) = -1$
得到 0-1 真值表矩阵:
相当于
x | y | x ∨ y | ¬(x∧y) | -1 |
---|---|---|---|---|
0 | 0 | 0 | 1 | 1 |
0 | 1 | 1 | 1 | 1 |
1 | 0 | 1 | 1 | 1 |
1 | 1 | 1 | 0 | 1 |
得到 $Y = (1, 1, -1, 1, -1)^\top$ ,即 $x + y - (x \lor y) + (\neg(x \land y)) - (-1) = 0$
得到线性 MBA 表达式后,可以进一步扩展到多项式上
定义群 $(P_m(\mathrm{Z}/2^n), \circ)$ , $\circ$ 为函数复合运算,即 $f(x) \circ g(x) = f(g(x))$ ,由群的性质可得,在群 $(P_m(\mathrm{Z}/2^n), \circ)$ 上一定存在 $g(x)$ 使得 $f(g(x)) = x$
如果我们构造两个互为逆元的多项式 $f(x)$ 、 $f^{-1}(x)$ ,可以得到 $f(x) \circ f^{-1}(x) = x$ ,即 $f(f^{-1}(x)) = x$ ,将 $x$ 替换为需要混淆的线性 MBA 表达式,就能得到一个等价的多项式 MBA 表达式
为了简化编写过程,令多项式为 $f(x) = a \cdot x + b$ ,则 $f^{-1}(x) = a^{-1} \cdot x + (- a^{-1} \cdot b)$ ,替换 $x$ 为上面得到的线性 MBA 表达式即可
混淆间接调用
通过枚举每个函数中直接调用指令,对收集到的每个直接调用(CallInst
、InvokeInst
)生成一个间接调用数组,比如对于一份源码
|
|
在main()
中调用了foo()
、scanf()
、bar(input)
,生成一个指针数组(IndGV = &GV[EncKey]
),内容是对应调用地址+随机数
|
|
然后再次遍历一遍当前函数中所有的直接调用指令,将指令替换为上述指针数组的值-随机数(GV = IndGV[-EncKey]
),一加一减就相当于直接调用函数并且修改成了间接调用