XMAN2018 排位赛 Dragon Quest

Last updated on January 30, 2024 am

记录一道十分nice的题目

我觉得这道题出得十分得好,它采取了较为简单的控制流混淆,可以使用angr恢复出原本的逻辑,也可以用idapython直接patch去混淆,当然也可以直接使用angr跑出flag,但这样就失去了这题本身的意义了。

混淆

这题采取的是控制流混淆,也就是增加一些无关紧要的变量,以这些变量之间的稀奇古怪的运算作为控制条件,再在各个块中跳来跳去。

以这题中一个关键函数start_quest 来看,可以发现里面有很多y26 >= 10 && (x25-1) * (x25 & 1) !=0等诸如此类的控制条件,并且反复出现相同的语句去控制。

1.png

再看看它的CFG

3.png

给人的感觉就是冗长、繁琐。然而相比于它的大哥控制流平坦化,这还是比较友好的。

去混淆

思路一 IDApython

我们知道了这道题是以一些无关变量来操控控制流,以达到混淆目的。很自然的一个想法就是,如果这些变量的值都不会变,那么控制语句的结果不就是唯一确定的吗。于是乎,我们想到去查看这些变量的定义位置,会惊奇的发现,它们全部都在bss段定义!

4.png

我们再交叉引用几个变量,又会惊奇地发现,它们的值全都没有发生改变。

也就是说,那些控制语句的结果就是唯一的!可是这样子的话,那么IDA就应该能分析出来并优化掉那些不可达语句。然而事实很明朗,并没有。原因在于,在IDA眼中,这些仍然是变量,只不过是不会变的变量。

那么这就有了第一个patch思路,也就是我们手动帮助IDA识别,即把所有的这些变量全部patch成0。比如说原本是mov eax,y26,那么我们就patch成mov eax,0。可能会想到,那这么多的变量,我得patch到啥时候呀?这个情况下,我们就应该想到可以借助IDApython来帮我们完成,毕竟 重复性 的工作是计算机最擅长的。

直接上脚本,脚本中会有详细注释

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
38
39
40
41
42
43
44
45
46
47
import ida_xref

# 变量位于bss段的起始地址以及终止地址
ea = 0x610318
end = 0x6105A8
regs = set()

# mov 寄存器,0 对应的机器码,可以在IDA中利用Assemble一句一句试出来
pad = b'\x90\x90'
patch = {'eax':b'\xB8\x00\x00\x00\x00' + pad,
'ebx':b'\xBB\x00\x00\x00\x00' + pad,
'ecx':b'\xB9\x00\x00\x00\x00' + pad,
'edx':b'\xBA\x00\x00\x00\x00' + pad,
'esi':b'\xBE\x00\x00\x00\x00' + pad,
'edi':b'\xBF\x00\x00\x00\x00' + pad,
'r8d':b'\x41\xB8\x00\x00\x00\x00' + pad,
'r9d':b'\x41\xB9\x00\x00\x00\x00' + pad,
'r10d':b'\x41\xBA\x00\x00\x00\x00' + pad,
'r11d':b'\x41\xBB\x00\x00\x00\x00' + pad,
'r12d':b'\x41\xBC\x00\x00\x00\x00' + pad,
'r13d':b'\x41\xBD\x00\x00\x00\x00' + pad,
'r14d':b'\x41\xBE\x00\x00\x00\x00' + pad,
'r15d':b'\x41\xBF\x00\x00\x00\x00' + pad}
'''
如何确定只有这些寄存器?
当然也是使用IDApython 可以先在下面的reg =idc.print_operand(ref,0)后追加
regs.add(reg)
最后再print(regs)就可以得到了

为什么要加一个pad?
可以自己手动patch一两个,看看patch后和patch前长度有啥区别就知道了

确实 按道理来说 这些应该也有规律的 可以使用脚本来表示的,而非字典
但是 我不会:(
'''

# 变量存储地址每次递增4
for addr in range(ea,end,4):
ref = ida_xref.get_first_dref_to(addr)# 得到是引用此变量的首语句地址
while ref != idaapi.BADADDR:# 与最后一句一起配合使用,即当前变量仍有被索引
if 'mov' in GetDisasm(ref):# 转为反汇编,确认是不是mov操作
reg =idc.print_operand(ref,0)# 得到第一个操作数,如果把0改为1,就是得到第二个操作数。这里是获得操作的寄存器
print("before:",GetDisasm(ref))# patch前语句
idaapi.patch_bytes(ref,patch[reg])# 从此语句开始patch,patch值为以上字典所示
print('after:',GetDisasm(ref))# patch后语句
ref = ida_xref.get_next_dref_to(addr,ref)# 得到引用此变量的下一条语句地址 若没有 则返回 -1

patch完成之后,CFG还是那个CFG,但是反编译已经成功去混淆了

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
__int64 __fastcall start_quest(std::string *a1)
{
_BYTE v2[4]; // [rsp+0h] [rbp-90h] BYREF
int v3; // [rsp+48h] [rbp-48h]
std::string *v5; // [rsp+50h] [rbp-40h]
int *v6; // [rsp+58h] [rbp-38h]
_BYTE *v7; // [rsp+60h] [rbp-30h]
_BYTE *v8; // [rsp+68h] [rbp-28h]
std::string *v9; // [rsp+70h] [rbp-20h]

v9 = a1;
v8 = &v2[-16];
v7 = &v2[-16];
v6 = &v2[-16];
v5 = &v2[-16];
std::vector<int>::push_back(&hero, &secret_100);
std::vector<int>::push_back(&hero, &secret_214);
std::vector<int>::push_back(&hero, &secret_266);
std::vector<int>::push_back(&hero, &secret_369);
std::vector<int>::push_back(&hero, &secret_417);
std::vector<int>::push_back(&hero, &secret_527);
std::vector<int>::push_back(&hero, &secret_622);
std::vector<int>::push_back(&hero, &secret_733);
std::vector<int>::push_back(&hero, &secret_847);
std::vector<int>::push_back(&hero, &secret_942);
std::vector<int>::push_back(&hero, &secret_1054);
std::vector<int>::push_back(&hero, &secret_1106);
std::vector<int>::push_back(&hero, &secret_1222);
std::vector<int>::push_back(&hero, &secret_1336);
std::vector<int>::push_back(&hero, &secret_1441);
std::vector<int>::push_back(&hero, &secret_1540);
std::vector<int>::push_back(&hero, &secret_1589);
std::vector<int>::push_back(&hero, &secret_1686);
std::vector<int>::push_back(&hero, &secret_1796);
std::vector<int>::push_back(&hero, &secret_1891);
std::vector<int>::push_back(&hero, &secret_1996);
std::vector<int>::push_back(&hero, &secret_2112);
std::vector<int>::push_back(&hero, &secret_2165);
std::vector<int>::push_back(&hero, &secret_2260);
std::vector<int>::push_back(&hero, &secret_2336);
std::vector<int>::push_back(&hero, &secret_2412);
std::vector<int>::push_back(&hero, &secret_2498);
std::vector<int>::push_back(&hero, &secret_2575);
if ( std::string::length(v9) - 1LL != legend >> 2 )
{
*v6 = legend >> 2;
}
else
{
std::string::string(v5, v9);
v3 = sanitize_input(v5);
*v6 = v3;
std::string::~string(v5);
}
return *v6;
}

会发现,代码逻辑已经十分清晰了。

思路二 Angr符号执行

前面提即,那些控制变量的条件是确定的,也就是哪些走、哪些地方不走是确定,如果我们可以直接把不走的地方给nop掉,不也可达到去混淆的目的吗?

那问题在于,我们如何确定哪些块该走,哪些块不该走呢? IDApython或许也可以一试,毕竟这也是重复性的活,不过这时候规律性就没那么明显了,脚本就不是很好写了。但是我们还有Angr这一个帮手呀,它不就是一步一步帮我们探索嘛,那只要把它没有走到的块给nop掉不就好了嘛

直接看脚本吧,脚本附有详细注释

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import angr
import argparse
import os

# 用于记录所有的块
Blocks = set()

# 得到CFG,但注意此处的CFG是函数调用CFG,也即一个Call会是一个block的终止,与IDA的CFG有所不同,但无伤大雅
def GetCFG(func_addr):
print("[*] Getting CFG")
cfg = proj.analyses.CFGFast(normalize = True,force_complete_scan = False)# 使用CFGFast得到静态CFG
function_cfg = cfg.functions.get(func_addr).transition_graph# 得到函数调用CFG
print("[+] Getting CFG Done")
return function_cfg


# 得到所有的块
def GetBlock(cfg):
print("[*] Getting Block ")
for node in cfg.nodes:
Blocks.add(node.addr)
print("[+] Getting Block Done")


# Hook掉所有call的函数,让他们返回未约束的值,以免陷入复杂的库函数中
def Hook(block):
for insn in block.capstone.insns: #该块的所有汇编语句
if insn.mnemonic == 'call':# 汇编操作指令为 call 的
src_Addr = int(insn.op_str,16)# 得到操作数,即call的地址
proj.hook(src_Addr,angr.SIM_PROCEDURES["stubs"]["ReturnUnconstrained"](), replace=True)


# patch 无法执行的Block
def Patch(block):
start = block.addr - proj.loader.main_object.mapped_base
size = block.size
oridata[start : start + size] = b'\x90' * size


# 写入patch后的文件
def Write(oridata):
print("[*] Wtring File")
fname, suffix = os.path.splitext(file)
newname = fname +'_patched' + suffix
with open(newname,'wb') as newfile:
newfile.write(oridata)
print("[+] Wtring Done")

# 符号执行找到不可达的Block
def deobfu_func(func_addr):
state = proj.factory.blank_state(addr = func_addr)
simgr = proj.factory.simgr(state)
# 采用step方式一步一步走,一直到终止
while len(simgr.active):
for active in simgr.active:
#把可以到达的block舍弃掉
Blocks.discard(active.addr)
#把当前active转为block,并Hook掉里面的call函数
block = proj.factory.block(active.addr)
Hook(block)
simgr.step()

print("[*] Patching")
#此时仍在Blocks里面的,即为不可执行块,patch掉
for block_addr in Blocks:
Patch(proj.factory.block(block_addr))
print("[+] Patching Done")


if __name__ == "__main__":
# 准备一些必要参数 文件地址 以及 去混淆的起始地址
parser = argparse.ArgumentParser()
parser.add_argument('-f', '--file', required=True, help='File to deobfuscate')
parser.add_argument('-s', '--start', type=lambda x : int(x, 0), help='Starting address of target function')
args = parser.parse_args()

file = args.file
start = args.start

#装载二进制文件
proj = angr.Project(file,auto_load_libs=False)

#获得原来的二进制数据 为patch做准备
with open(file,'rb') as orifile:
oridata = bytearray(orifile.read())

func_cfg = GetCFG(start)
GetBlock(func_cfg)
deobfu_func(start)

Write(oridata)

值得注意的是,每符号执行一次,只能清除一个函数的混淆,如果有多个函数需要清楚混淆,就要重复执行。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
__int64 __fastcall start_quest(std::string *a1)
{
_BYTE v2[4]; // [rsp+0h] [rbp-90h] BYREF
int v3; // [rsp+48h] [rbp-48h]
std::string *v5; // [rsp+50h] [rbp-40h]
int *v6; // [rsp+58h] [rbp-38h]
_BYTE *v7; // [rsp+60h] [rbp-30h]
_BYTE *v8; // [rsp+68h] [rbp-28h]
std::string *v9; // [rsp+70h] [rbp-20h]

v9 = a1;
v8 = &v2[-16];
v7 = &v2[-16];
v6 = &v2[-16];
v5 = &v2[-16];
std::vector<int>::push_back(&hero, &secret_100);
std::vector<int>::push_back(&hero, &secret_214);
std::vector<int>::push_back(&hero, &secret_266);
std::vector<int>::push_back(&hero, &secret_369);
std::vector<int>::push_back(&hero, &secret_417);
std::vector<int>::push_back(&hero, &secret_527);
std::vector<int>::push_back(&hero, &secret_622);
std::vector<int>::push_back(&hero, &secret_733);
std::vector<int>::push_back(&hero, &secret_847);
std::vector<int>::push_back(&hero, &secret_942);
std::vector<int>::push_back(&hero, &secret_1054);
std::vector<int>::push_back(&hero, &secret_1106);
std::vector<int>::push_back(&hero, &secret_1222);
std::vector<int>::push_back(&hero, &secret_1336);
std::vector<int>::push_back(&hero, &secret_1441);
std::vector<int>::push_back(&hero, &secret_1540);
std::vector<int>::push_back(&hero, &secret_1589);
std::vector<int>::push_back(&hero, &secret_1686);
std::vector<int>::push_back(&hero, &secret_1796);
std::vector<int>::push_back(&hero, &secret_1891);
std::vector<int>::push_back(&hero, &secret_1996);
std::vector<int>::push_back(&hero, &secret_2112);
std::vector<int>::push_back(&hero, &secret_2165);
std::vector<int>::push_back(&hero, &secret_2260);
std::vector<int>::push_back(&hero, &secret_2336);
std::vector<int>::push_back(&hero, &secret_2412);
std::vector<int>::push_back(&hero, &secret_2498);
std::vector<int>::push_back(&hero, &secret_2575);
if ( std::string::length(v9) - 1LL != legend >> 2 )
{
*v6 = legend >> 2;
}
else
{
std::string::string(v5, v9);
v3 = sanitize_input(v5);
*v6 = v3;
std::string::~string(v5);
}
return *v6;
}

这是运行完,清除start_quest混淆后的效果,可以发现效果不错,而且与之前IDApython的结果不能说差不多吧,只能说一模一样。因为这二者的思路本质上都是一样的,殊途同归了属于是

End

清除混淆后的步骤就比较简单了,虽然它还有一个变量名混淆,就是一个变量赋值来赋值去的,不过这个直接使用IDA的变量重命名就能轻松搞定了。

对比一下可以发现,IDApython的脚本简洁明了,运行速度也快于Angr,但它的缺点就是在面对复杂情况下的混淆可能就有点乏力,比如这几个变量不再初始为0,而是随机的初始值,可能就得费一番功夫去修改脚本了。而Angr的优势在于它的准确性,因为它就是实打实的去跑,去探索究竟哪一个块是可达的,哪一个块是不可达的,即使面对复杂的控制流混淆,这一本质是仍是通用的。

二者各有优劣,但都很精彩。

(当然这题也可以直接符号执行出flag,但是这显然就学不到什么了)


XMAN2018 排位赛 Dragon Quest
http://example.com/2023/06/02/wyvern/
Author
yring
Posted on
June 2, 2023
Licensed under