首页
论坛
课程
招聘
[软件保护] [原创]利用angr符号执行去除虚假控制流
2021-2-10 18:31 3666

[软件保护] [原创]利用angr符号执行去除虚假控制流

2021-2-10 18:31
3666

接触OLLVM也有好长一段时间了,但一直停留在应用层面,接下来一段时间打算从进一步研究OLLVM。俗话说柿子先挑软的捏,如果说OLLVM提供的几种混淆方式有辈分之分,那么虚假控制流(Bogus Control Flow)跟它的兄弟控制流平坦化(Control Flow Flattening)比起来就是弟中弟,我们就从去除OLLVM虚假控制流混淆开始吧!

 

GitHub仓库:bluesadi/debogus

0x00. 虚假控制流初探

虚假控制流混淆通过加入包含不透明谓词的条件跳转(也就是跳转与否在运行之前就已经确定的跳转,但IDA无法分析)和不可达的基本块,来干扰IDA的控制流分析和F5反汇编。
我们先用一个简单的例子来看看OLLVM虚假控制流混淆的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <string.h>
 
int main(){
    char name[100];
    scanf("%s", name);
    if (strcmp(name, "Alice") == 0) {
        printf("hello, %s.\n", name) ;
    } else if (strcmp(name, "Bob") == 0) {
        printf ("hello, %s\n", name);
    } else {
        printf("no permission.\n") ;
    }
}

正常编译,在IDA中查看程序的CFG,还是比较清晰的:
图片描述
加上-ollvm -bcf选项后编译,可以看到整个程序的流程图变得十分复杂:
图片描述
F5反汇编可以看到程序多出了一些莫名其妙的跳转和循环:
图片描述
这些跳转中的xy位于.bss段,并且通过交叉引用发现没有被修改过,也就是说x和y在运行过程中一直为0。这里的xy被称为不透明谓词,所谓不透明,就是IDA难以推断其在运行时的值,但我们都知道它就是0:
图片描述

 

在图中y >= 10 && ((((_BYTE)x - 1) * (_BYTE)x) & 1) != 0是一个恒为false的条件,而y < 10 || ((((_BYTE)x - 1) * (_BYTE)x) & 1) == 0是一个恒为true的条件。
因此下图中用框起来的代码块永远不会被执行,那些永远不会执行到的代码块,就叫做不可达的基本块
图片描述
这些跳转和不可达基本块并不会影响程序原有的逻辑,但会干扰我们的分析,这就是虚假控制流混淆达到的效果。

0x01. 利用angr符号执行去除不可达的基本块

利用符号执行去混淆的基本思路是:先找到目标函数的所有基本块,再通过符号执行遍历目标函数所有可达的基本块,剩下的就是不可达的基本块。把不可达的基本块全部nop掉,就能使IDA的F5反汇编正常分析。
图片描述
首先加载我们需要的参数,比如文件名,目标函数的起始地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument('-f','--file', help='The path of binary file to deobfuscate')
parser.add_argument('-s','--start', help='Start address of target function')
parser.add_argument('-e','--end', help='End address of target function')
args = parser.parse_args()
if args.file == None or args.start == None or args.end == None:
    parser.print_help()
    exit(0)
filename = args.file
start_address = int(args.start, 16)
end_address = int(args.end, 16)

angr加载二进制文件:

1
2
3
import angr
 
proj = angr.Project(filename, load_options={'auto_load_libs': False})

获取目标函数的函数的所有基本块:

1
2
3
4
5
6
target_blocks = set()
cfg = proj.analyses.CFGFast()
cfg = cfg.functions.get(start_address).transition_graph
for node in cfg.nodes():
    if node.addr >= start_address and node.addr <= end_address:
        target_blocks.add(node)

cfg是Control Flow Graph的缩写。注意cfg.nodes()中除了会包含函数本身的基本块之外,还会包含函数里调用的其他函数的基本块,所以这里用一个node.addr >= start_address and node.addr <= end_address把函数中调用的其他函数的基本块筛掉。

 

Hook掉目标函数中调用的所有其他函数:

1
2
3
4
5
function_size = end_address - start_address + 1
target_block = proj.factory.block(start_address,function_size)
    for ins in target_block.capstone.insns:
        if ins.mnemonic == 'call':
            proj.hook(int(ins.op_str, 16), angr.SIM_PROCEDURES["stubs"]["ReturnUnconstrained"](), replace=True)

angr.SIM_PROCEDURES["stubs"]["ReturnUnconstrained"]()是ReturnUnconstrained类的一个实例,在符号执行过程中它会返回一个无约束的符号,简单来说就是一个可以返回任何值的函数。
图片描述
为什么要这样Hook,原因是在符号执行一些静态链接的文件时,angr的符号执行模拟器会陷入到复杂的库函数中,导致跑的时间非常长或者根本跑不出来,这也是我把基本块的分析范围限制在目标函数内的原因。
在我研究过程中发现的另一个脚本:cq674350529/deflat显然就没有处理这个问题。

 

然后是符号执行的过程:

1
2
3
4
5
6
7
8
control_flow = set()
state = proj.factory.blank_state(addr=start_address, remove_options={angr.sim_options.LAZY_SOLVES})
simgr = proj.factory.simulation_manager(state)
control_flow.add(state.addr)
while len(simgr.active) > 0:
    for active in simgr.active:
        control_flow.add(active.addr)
        simgr.step()

从目标函数开始,simgr.step()逐块执行,一直到没有active状态为止(可以认为是运行结束)。
step的过程有点像BFS的过程,每碰到一个跳转就会分裂出两个新的active状态(前提是两个状态都是可达的)。
一边符号执行一边将符号执行能遍历到的所以基本块的地址保存到control_flow中。

 

最后nop掉没有被执行到的基本块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
base_address = proj.loader.main_object.mapped_base
handled_blocks = set()
patched_addrs = []
with open(filename, 'rb') as inp:
    data = bytearray(inp.read())
for block in target_blocks:
    if block.addr in handled_blocks:
        continue
    handled_blocks.add(block.addr)
    if block.addr in control_flow:
        for child in cfg.successors(block):
            if child.addr < start_address or child.addr > end_address:
                continue
            if child.addr not in control_flow:
                handled_blocks.add(child.addr)
                patched_addrs.append(hex(child.addr))
                write_nops(data, child.addr - base_address, child.size)
            else:
                write_nops(data, block.addr - base_address, block.size)

最后将去混淆的结果保存到另一个文件中

1
2
3
4
5
6
name, suffix = split_suffix(filename)
outpath = name + '_recovered' + suffix
with open(outpath,'wb') as out:
    out.write(data)
print(f'Patched {len(patched_addrs)} unreachable blocks: {patched_addrs}')
print(f'Recovered file is saved to: {outpath}')

0x02. 去混淆效果分析

这里我们直接用BUUCTF上的一道题测试:[XMAN2018排位赛]Dragon Quest
可以看到很明显是虚假控制流混淆:
图片描述
图片描述
运行我们的脚本去混淆:
图片描述
查看去混淆后的文件,可以看到去混淆的效果还不错:
图片描述
我们再测试一个更变态的文件(感谢Rimao大佬提供)。
它的流程图是这样的:
图片描述
伪代码有近2000行,混淆方式是Rimao大佬魔改的虚假控制流:
图片描述
运行去混淆脚本:
图片描述
IDA查看效果,发现没去干净:
图片描述
并且运行也出错了:
图片描述
理论上来说只要弄清混淆原理就有办法改进,不过要过年了嘛,就暂时不研究了hh

0x03. 另一种方法:去除不透明谓词

把所有不透明谓词改为0也能使IDA的F5反汇编恢复正常,用idapython脚本就能实现,因为不是文章的重点就简单贴一下代码好了233:

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
# 去除虚假控制流 idapython脚本
import ida_xref
import ida_idaapi
from ida_bytes import get_bytes, patch_bytes
 
# 将 mov 寄存器, 不透明谓词 修改为 mov 寄存器, 0
def do_patch(ea):
    if get_bytes(ea, 1) == b"\x8B": # mov eax-edi, dword
        reg = (ord(get_bytes(ea + 1, 1)) & 0b00111000) >> 3
        patch_bytes(ea, (0xB8 + reg).to_bytes(1,'little') + b'\x00\x00\x00\x00\x90')
    else:
        print('error')
 
# 不透明谓词在.bss段的范围
start = 0x00428298
end = 0x00428384
 
for addr in range(start,end,4):
    ref = ida_xref.get_first_dref_to(addr)
    print(hex(addr).center(20,'-'))
    # 获取所有交叉引用
    while(ref != ida_idaapi.BADADDR):
        do_patch(ref)
        print('patch at ' + hex(ref))
        ref = ida_xref.get_next_dref_to(addr, ref)
    print('-' * 20)

这种方法的优点是脚本写起来简单,也不用考虑静态链接还是动态链接的问题,缺点是面对虚假控制流的变体可能无能为力。

0x04. 一些改进的想法

在上面的脚本中为了防止angr陷到库函数里面,我把符号执行的范围限定在了目标函数内。这样的缺陷是如果有多个函数被混淆了,就要运行很多次脚本。可以加入一个深度参数--depth,距离目标函数的调用深度不超过depth的函数都会被去混淆,这是一个改进方案,不过感觉代码量很大就没实现了呜呜。

 

另一个可以改进的地方是在去掉不可达的基本块之后,还可以顺便把跳转到这个基本块的jnz指令改成jmp指令,可能对以后要研究的去除虚假控制流变体有帮助。

0x05. 之后的打算

我对angr的研究也不是特别深入,因此这个去混淆脚本也许不能适用于所有情况(比如上面那个魔改虚假控制流),不过应对OLLVM的虚假控制流混淆应该没有问题。Rimao师傅还提出了一个“怎么区分虚假控制流还是输入导致的分支”问题,欢迎大家讨论吧233。
过完年之后打算研究基于LLVM的混淆了,angr也会继续学习,届时还会推出一些相关的文章,欢迎大家交流学习!

0x06. 参考


[公告]名企招聘!

最后于 2021-2-11 07:02 被34r7hm4n编辑 ,原因:
收藏
点赞3
打赏
分享
最新回复 (7)
雪    币: 3635
活跃值: 活跃值 (186)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
breaklink 活跃值 2021-2-10 18:57
2
0

好奇这是什么插件还能识别stl模板

忘了可能是debug版

最后于 2021-2-12 10:11 被breaklink编辑 ,原因:
雪    币: 4358
活跃值: 活跃值 (1835)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
34r7hm4n 活跃值 3 2021-2-10 19:13
3
0
breaklink 好奇这是什么插件还能识别stl模板
IDA 7.5
雪    币: 4574
活跃值: 活跃值 (416)
能力值: ( LV3,RANK:25 )
在线值:
发帖
回帖
粉丝
alphc 活跃值 2021-2-11 11:05
4
0
mark
雪    币: 0
活跃值: 活跃值 (255)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
lookzo 活跃值 2021-2-18 13:41
5
0
写到非常通俗易懂,容易采坑的地方都说了,很好的文章,可以加精了
雪    币: 4358
活跃值: 活跃值 (1835)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
34r7hm4n 活跃值 3 2021-2-18 14:38
6
0
lookzo 写到非常通俗易懂,容易采坑的地方都说了,很好的文章,可以加精了
谢谢
雪    币: 0
活跃值: 活跃值 (74)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
YenKoc 活跃值 2021-2-19 11:15
7
0
mark了,感谢师傅分享
雪    币: 421
活跃值: 活跃值 (176)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
eastmaster 活跃值 6天前
8
0
不错!点赞!
游客
登录 | 注册 方可回帖
返回