O-MVLL代码混淆方式
在介绍O-MVLL之前,首先介绍什么是代码混淆以及基于LLVM的代码混淆,O-MVLL项目正是基于此而开发来的。
有关O-MVLL的概括介绍以及安装和基本使用方式,可参见另一篇随笔
https://www.cnblogs.com/level5uiharu/p/16912019.html
基于LLVM的代码混淆
代码混淆是将代码转换成另一种功能上等价,但更难以阅读的形式,是一种对抗逆向工程的手段,也是一种保护源代码和程序的手段。
例如修改各种函数、变量名称以消除其语义,使用非正常逻辑实现功能、使指令复杂化等等。代码混淆不能从根源上对抗逆向工程,只能增加逆向工程的分析成本,因此还需要结合其他手段来获得更强的安全性。同时,常见的代码混淆方式往往会引入大量无关指令,或者用于复杂化程序的指令,尽管引入指令越多安全性越强,但通常还会增加程序体积并降低运行效率。因此在使用代码混淆时,要平衡好效率和安全性。
目前代码混淆仍是一个小众方向,相关研究和进展不多。此外,代码混淆还可以应用于恶意代码检测领域,对恶意程序进行混淆从而生成更多的恶意代码样本,一方面扩充了模型训练的数据集,另一方面代码混淆对恶意代码进行的修改可能会隐藏其某些特征。
那么什么是基于LLVM的代码混淆呢?
代码混淆有多种实现途径,根据目标编程语言、架构等不同有不同方式。最初的代码混淆是直接在源代码上进行修改然后编译,这样虽然保护了源代码,但也增加了调试和开发者自己理解源码的成本。Java则由于其字节码的存在,通常是对存储在class文件中的字节码进行混淆,这样就不必修改源码,但仍能得到一份混淆后的可执行文件,将可执行文件发行即可。
基于LLVM的代码混淆正是采用了和Java混淆类似的思路,LLVM编译框架大致分为前端、中端、后端三段:
前端:进行词法分析、语法分析、语义分析等,生成中间代码IR
中端:优化器,会在此处对中间代码IR进行修改优化,中端会有名为Pass的文件,每一个Pass都会依照自身的逻辑对IR进行修改完成优化
后端:完成连接、汇编、生成目标文件的工作
如上图所示,对于不同的编程语言,都会在前端转换成格式相同的IR文件后交由中端优化器处理。同时,LLVM提供了Pass开发的API,可以根据自身需求开发特定功能的Pass。
因此基于LLVM的代码混淆实际上是通过开发Pass的方式,在中端优化器中混淆IR文件,再讲混淆后的IR文件连接汇编,从而得到混淆的可执行文件的。
O-MVLL代码混淆器
O-MVLL项目灵感源自OLLVM,后者则是最著名的基于LLVM的代码混淆器之一,实现了指令替代、控制流平坦化、虚假控制流这三种代码混淆方式。O-MVLL在OLLVM的基础上增强了这三种代码混淆方式,同时新增了一些代码混淆方式(当然,以Python API的形式调用代码混淆也是其创新和特点,在上一篇文章中有所介绍)
下面介绍O-MVLL中使用的代码混淆方式
对抗挂钩Anti_Hooking
使用方式:重写anti_hooking方法
def anti_hooking(self, mod: omvll.Module, func: omvll.Function) -> omvll.AntiHookOpt: if func.name in ["encrypt", "has_secure_enclave"]: return True return False
以上的代码能够将这种代码混淆方式作用于函数名为encrypt、has_secure_enclave的函数。
对抗hook技术是另一门值得深入研究的学问,因此O-MVLL对此进行的保护适用范围有限,安全性也有限。
该方式只适用于对抗frida,这跟它的设计有关。通常来说,hook框架需要使用几个临时的寄存器来重新定位或访问当前函数的原数据,对于frida来说,它需要使用x16,x17两个寄存器之一。这一点可以在frida项目的文件gumarm64relocator.c中分析出来:
if (available_scratch_reg != NULL) { gboolean x16_used, x17_used; guint insn_index; x16_used = FALSE; x17_used = FALSE; ... if (!x16_used) *available_scratch_reg = ARM64_REG_X16; else if (!x17_used) *available_scratch_reg = ARM64_REG_X17; else *available_scratch_reg = ARM64_REG_INVALID; }
因此如果在函数的序言开始的地方插入指令,占用x16,x17这两个寄存器,就能够让frida抛出错误。O-MVLL也正是这样做的,具体做法为在函数的开头插入
mov x17,x17;mov x16,x16或者mov x16,x16;mov x17,x17两条语句,插入哪组指令则是由随机数随机选择
可以参见O-MVLL/src/passes/anti-hook/AntiHook.cpp中的定义
static const std::vector<PrologueInfoTy> ANTI_FRIDA_PROLOGUES = { {R"delim( mov x17, x17; mov x16, x16; )delim", 2}, {R"delim( mov x16, x16; mov x17, x17; )delim", 2} };
运算混淆Arithmetic Obfuscation
使用方法:重写obfuscate_arithmetic方法
def obfuscate_arithmetic(self, mod: omvll.Module, fun: omvll.Function) -> omvll.ArithmeticOpt: if func.name == "encode": return omvll.ArithmeticOpt(8)
上述配置会将该代码混淆方式应用于encode函数,并且迭代混淆8次
这种方式是将运算指令复杂化,能够被复杂化的运算指令包括加、减、与、或、异或,乘法和除法由于其运算的复杂性和溢出、借位等操作难以实现。
具体来说,它会将这些运算使用混合布尔算术(MBA)构造的等价式替代,这些等价式和原本的运算指令之间的映射关系如下
迭代混淆会在上一轮混淆的基础上再次调用该混淆方式,多次的迭代将产生大量的混淆代码,因此一定要考虑安全性和运行效率的平衡。
以下是混淆前后的对比,迭代次数为1
使用这种方式混淆出来的特征明显,且由于每种运算指令和替代式一一对应,因此每一种特定的混淆形式都能唯一确定相应的运算指令,也能由混淆形式还原。这种混淆方式往往需要和其他代码混淆方式结合使用才能发挥更大的威力。
不透明常量Opaque Constants
使用方式:重写obfuscate_constants方法
def obfuscate_constants(self, mod: omvll.Module, func: omvll.Function): # Logic goes here
在函数的返回值方面,作者进行了设计,目前提供以下几个返回值的处理:
1.BOOL:返回true时启动混淆,false时不启动
2.返回一个整型常量的list:混淆list中出现的常量
3.返回omvll.OpaqueConstantsLowerLimit(n),混淆不小于n的常量
与运算混淆类似,这里则是使用构造的复杂等价式替换掉程序中出现的常量,这对于一些加密算法的特征常量(例如AES的S盒)的保护效果很好。
对于用来替代常量的复杂等价式的构造,作者采用以下三个方式:
1.0的构造
0 = MBA(X ^ Y) - (X ^ Y)
0 = (X | Y) - (X & Y) - (X ^ Y)
2.1的构造
LSB = 当前栈顶地址 Odd = 随机生成的奇数 1 = (LSB + Odd) % 2
由于栈地址一定要满足对齐的条件因此低位一定是0,即栈地址是一个偶数,这样就能保证LSB + Odd一定等于一个奇数
3.其他值的构造
Split = random(1,min(255,var)) LHS = var - Split + 0 RHS = Split + 0
var = LHS + RHS
通过上述构造的替换,逆向工程时能看到的原常量var被替换成LHS + RHS。此外LHS和RHS都分别加上了0,这个0会被之前提到的0的构造替换。并且整个Opaque Constants会默认启用运算混淆,迭代次数为1,其中相应的运算指令也会被复杂化。
下图为混淆前后的对比图,展示的是0的构造替代。
可以看到尽管没有在配置中启用运算混淆,但是常量混淆的混淆代码中有明显运算混淆的特点。
控制流破坏Control-Flow Breaking
使用方式:重写break_control_flow方法
def break_control_flow(self, mod: omvll.Module, func: omvll.Function): if func.name == "break_control_flow": return True return False
上述配置会将该混淆方式应用于break_control_flow函数
该混淆方式破坏控制流,准确地说是破坏函数调用的控制流。但本质上并没有改变函数调用的流程图,而是将被保护的函数中的指令复制到另一个函数当中,再删除原函数的指令并添加混淆指令,最后使用隐含的方式跳转到复制函数中以保证功能不变。
具体而言,它做了三件事:
1.clone克隆
克隆原函数的所有指令,并记录克隆函数的地址。
2.插入混淆指令
删除原函数的指令,并插入混淆指令,这些混淆指令包含类似于ldr x0,#offset的指令,pc+#offset处则是要保护的函数的地址,添加这种指令会让反汇编器认为这个偏移处的内容可能不是指令而是数据,然而实际上它就是指令。
此外还会添加一些运算,运算结果为克隆函数地址,将地址保存到局部变量中,并使用运算混淆和不透明常量进行联合保护。
3.添加跳转
在原函数的结尾处插入跳转指令,跳转到保存了原函数正常功能的克隆函数处,以完成正常功能。
首先会从局部变量中取出克隆函数的地址到寄存器中,再使用BLR指令跳转到寄存器中保存的地址。
以这样的方式跳转,克隆函数不会出现在函数调用的流程图当中,相比于硬编码使用函数地址完成跳转来说是一种隐含的函数调用
下图是该混淆方式工作的模式图
以下是混淆前后函数的对比
混淆后函数原先的代码被存放在了克隆函数sub_18D8当中,并通过最后一条指令跳转到克隆函数,克隆函数当中的内容,与未混淆前原函数的内容相同
控制流平坦化Control-Flow Flattening
使用方式:重写flatten_cfg函数
def flatten_cfg(self, mod: omvll.Module, func: omvll.Function): if func.name == "check_password": return True return False
如上所示,将会对函数check_password使用该混淆方式。
控制流平坦化是对程序中出现的分支和跳转进行修改,全部转换为switch的形式,从控制流程图的角度看,就像是把控制流程图给压平了,如下图所示
那么如何保证执行的顺序和分支条件不发生变化呢?
假设switch语句根据变量var来进行跳转,那么首先为每个基本块打上标签,从基本块2到基本块5分别为a,b,c,d,e,这五个标签为生成的随机数。
之后在每一个基本块结束的时候对var进行赋值,将var赋值为下一个基本块对应的标签,然后再跳转到分发块,例如基本块2的分支可能跳转到基本块3和基本块4,那么根据判断条件,将var赋值为b或者c,然后跳转到分支块switch,switch语句就会根据基本块2对var的修改来进行相应的跳转。
以上是OLLVM实现的控制流平坦化,O-MVLL对其进行了增强
1.对var进行编码处理
在OLLVM中,可以根据基本块最后的赋值来判断下一个基本块是哪个,你可能能够看到如下的伪代码:
switch(var){ case a: var = b; case b: ; ... }
可以很明显地根据对var的赋值判断出基本块a的下一个基本块是基本块b。
但在O-MVLL中,赋值给var的值实际上是经过编码后的值,也就是如下的伪代码:
switch(encode(var)){ case a: var = c; case b: ; ... }
而encode(c)= b,这保证了流程的正常执行,但仅仅通过分析switch处的代码,无法得知基本块a的下一个基本块是基本块b,还需要对编码的算法进行分析和破解。
2.在default中填充垃圾代码
由控制流平坦化而来的switch语句中,default所指示的代码块是永远不会被执行的(否则原程序的控制流程被混淆后就发生改变了),因此O-MVLL在default指示的代码块中插入了一些垃圾代码,这些代码不会被执行,但仍会出现在控制流程图中混淆视线。
这里添加的垃圾代码,就像Control-Flow Breaking中添加的混淆指令一样。
以下是混淆前后函数的控制流程图:
不透明字段访问Opaque Fields Access
使用方法:重写obfuscate_struct_access函数
def obfuscate_struct_access(self, _: omvll.Module, __: omvll.Function, struct: omvll.Struct):if struct.name == "class.SecretString": return True return False
如上所示,将会对名字为SecretString的类进行混淆(该混淆方式也可以应用于结构体)
这种方式能够增加分析结构体和类的难度,在逆向工程时更难分析出类和结构体内部的成员和类型。
通常,对于结构体和类的访问采用
ldr x0, [x1, #offset]
这样的形式,而offset的组成是由局部变量和栈顶的偏移组成的,因此无法直接对#offset进行混淆。
O-MVLL的做法是将上述指令转换如下:
$var := #offset + 0 ldr x0, [x1, $var]
这样就将偏移保存在变量当中,再通过变量进行寻址。此时就可以对var进行混淆了,混淆的方式采用了之前提到的运算混淆和不透明常量两种方式结合。
以下是混淆前后的对比图
字符串加密Strings Encoding
使用方式:重写obfuscate_string方法
def obfuscate_string(self, _, __, string: bytes): if b'debug.cpp' in string:
return 'REMOVED'
该方法支持多种返回值,具体如下:
1.返回一个字符串:将字符串替换为返回的字符串,如果用于替换的字符串长度比原字符串长,则超出部分会被截断。返回空字符串时表示删除
2.返回omvll.StringEncOptGlobal(),加密字符串并存储为全局变量,在.data段可见,一旦程序被加载,该字符串就可以被搜索到
3.返回omvll.StringEncOptStack(),加密字符串并存储在当前栈上
4.返回omvll.StringEncOptStack(loopThreshold=0),加密字符串并存储在当前栈上,解密流程相比3更简单,代码量减少
上述四种方式的实现细节如下:
omvll.StringEncOptGlobal()
这种方式加密后,字符串被保存在.data段上
对应的解密函数为sub_1818,该解密函数被存放在.init_array当中,也就是说它是在程序加载后的初始化当中被调用,因此一旦程序完成加载初始化,字符串就会被还原
omvll.StringEncOptStack()
这种方式会将加密后的字符串作为局部变量保存在函数的栈上,使用该字符串之前的解密步骤也是在使用该字符串的函数中完成
在解密算法调用的时候,也都默认调用了运算混淆和不透明常量两种混淆方式。
没有启用loopThreshould=0时,解密的算法大致如下所示
char OMVLL_DECODED[6]; OMVLL_DECODED[1] = ENC_OMVLL[1] ^ 0xd7; OMVLL_DECODED[5] = ENC_OMVLL[5] ^ 0x02; OMVLL_DECODED[2] = ENC_OMVLL[2] ^ 0x77; OMVLL_DECODED[0] = ENC_OMVLL[0] ^ 0x55; OMVLL_DECODED[4] = ENC_OMVLL[4] ^ 0x7b; OMVLL_DECODED[3] = ENC_OMVLL[3] ^ 0x35;
这种方式产生大量指令,但好在打乱了秘钥流,增强了安全性。当字符串很长时,这种方式会非常耗费空间。
因此启用了loopThreshould=0时,解密算法大致可以如下表示:
char OMVLL_DECODED[6]; for (size_t i = 0; i < 6; ++i) { OMVLL_DECODED[i] = ENC_OMVLL[i] ^ KEY[i]; }