Unicorn实战(三):去掉hikari的字符串加密

hikari是近年来市面上开源的最优秀的一款基于llvm的混淆工具了,也提供字符串加密功能,上一篇文章解密了armariris的字符串,本文用unicorn解密一下hikari的字符串。

本文的涉及到个5文件,位于该gist下:https://gist.github.com/LeadroyaL/41deedb95f5fd29d7ee874c08d27db5b 或者github仓库 https://github.com/LeadroyaL/decrypt_str_hikari 共:一个idapython(py2)脚本,一个unicorn(py3)脚本,一个C文件,一个Patch前的SO,一个Patch后的SO。

一、hikari字符串加密实现原理

我曾经移植过它,代码位于https://github.com/LeadroyaL/llvm-pass-tutorial/blob/dev/Hikari/StringEncryption.cpp,(建议先阅读上篇armariris) 与armariris不同的地方是:hikari是在进入函数时进行字符串解密的,只有运行过的函数才会将解密过的字符串存放在内存中,因此不能使用执行一遍datadiv_decode就完事的策略,需要遍历可疑的函数从而在内存中获得解密后的字符串。

使用下面的伪代码可以帮助理解: 原先的代码

1
2
3
4
5
#include<stdio.h>
int main(){
printf("Hello");
return 0;
}

hikari 处理过的代码

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>
decryptStatu = 0;
int main(){
if(decryptStatu == 0){
xor_decrypt(p_hello);
}
decryptStatu = 1;
printf(p_hello);
return 0;
}

使用llvm提供的对函数的viewCFG功能,会有下图的CFG

由于字符串加密的Pass在注册时,放在比较靠后的位置,所以它一定是出现在整个函数最开始的,范围比较容易界定。

二、制造样本

网上没找到,没办法,自己编译一个出来吧,用脚本生成一些c代码,将随机字符串拼接为新的字符串并且返回,见github里的native_lib.c。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from string import ascii_letters
import random

fmt = """void concatenate_string_%d(char* s){
const char* data = "%s";
strcat(s, data);
}
"""
txt = ""
fmt2 = """concatenate_string_%d(ret);
"""
txt2 = "char ret[1024 + 10 * 100];nmemset(ret, 0, 1024 + 10 * 100);n"
for _ in range(100):
c = fmt % (_, ''.join([ascii_letters[random.randint(0, len(ascii_letters) - 1)] for __ in range(10)]))
txt += c
txt2 += fmt2 % (_)
txt2 += "return env->NewStringUTF(ret);n"
print txt
print txt2

然后加载hikari的字符串加密Pass,可以参考我的github教程

https://github.com/LeadroyaL/llvm-pass-tutorial/blob/dev/),获得一份样本。

如果懒得自己弄,样本是github的libnative-lib.so文件。

三、主要思路

字符串解密在每个函数开头完成的的,因此要对每个函数都解密一遍,就要先找到每个函数,然后只运行最前面的解密代码,之后保存内存,并且运行时不要再次执行解密。

这里我们定义三个BasicBlock,entryBB就是函数入口,可以理解为 if(status==0) 这句话,decryptBB就是执行解密逻辑的代码,可以理解为 xor_decrypt(xxx),originalBB就是原先的业务代码,可以理解为status=1;紧接printf(“Hello”)。

第一个难题是【函数边界】确定各个函数的起始位置,这个很头疼,因为用到的这几款工具都无法直接给出函数的开头地址;

方案一:使用IDA、radare2、angr 给出的推测

方案二:手写一个简单的寻找函数的轮子

最后决定使用IDA提供的函数边界识别。

第二个难题是【ControlFlowGraph】如何精准确认StringDecryptBB 的具体位置、精准确认status 被读写的时刻,精准确认 if 函数的位置。

方案一:添加rwdata的 hook,读取 status 时可以知道大致位置,写回时候也知道大致位置,使用它们作为界限

方案二:总结 pattern,根据各个块的汇编特征来确认

方案三:使用IDA、radare2、angr获取CFG

最终决定使用 IDA 提供的API,菜鸡脱离IDA活不下去,太菜了。。。

写完这三篇文章,突然想一个东西:使用unicorn时一定要忘掉汇编语句这个东西,思路上,把ELF看做一个个Function、BasicBlock、一段段汇编,关注大局而非某条语句,思想上要从逐句执行汇编上解放出来,也是我觉得unicorn比较牛逼的地方。虽然我对unicorn的了解并不深入,但我还是强烈推荐读者也有这样的大局观!

四、实现细节

由于没有办法用python脚本把ida的逻辑和unicorn的逻辑串起来,因此执行完毕后,把重要信息保存到json里,之后再传递给unicorn。(主要是因为ida是python2,而我unicorn喜欢用python3)

1、使用idapython拿到各个函数入口

1
2
3
4
5
6
text_seg = idaapi.get_segm_by_name('.text')
text_start = text_seg.startEA
text_end = text_seg.endEA

for ea in Functions(text_start, text_end):
f = get_func(ea)

2、使用idapython拿到函数的CFG(Control Flow Graph),API叫FlowChat,并且根据以下特征找到entryBB、decryptBB、originalBB: 特征一:entryBB一定有两个分支,其中一个是originalBB、另一个是decryptBB,但无法区分出二者。

特征二:decryptBB进入后,无论中间经过多少次跳转,一定不会存在两个后继分支,一定会走到originalBB。可以理解为单向图,decryptBB => AAA => BBB => CCC => originalBB,使用深度优先遍历即可确认两个BB之间的关系,如果错了就说明这个函数没有进行字符串解密。

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
def isFrTo(frBB, toBB):
assert frBB._fc == toBB._fc
_fc = frBB._fc
_ret = False
currentBB = frBB
while not _ret:
if _fc._q.nsucc(currentBB.id) != 1:
break
_ = next(currentBB.succs())
if _.start_ea == toBB.start_ea:
_ret = True
break
currentBB = _
return _ret

for ea in Functions(text_start, text_end):
f = get_func(ea)
fc = FlowChart(f)
entryBB = fc[0]
if fc._q.nsucc(entryBB.id) != 2:
print "Not Decrypt Header. PIN1"
continue
decryptBB, originalBB = None, None
trueBB, falseBB = entryBB.succs()
# check trueBB->falseBB
if isFrTo(trueBB, falseBB):
decryptBB, originalBB = trueBB, falseBB
print "GOT1!", hex(ea)
elif isFrTo(falseBB, trueBB):
decryptBB, originalBB = falseBB, trueBB
print "GOT2!", hex(ea)
else:
print "No Decrypt Header. PIN2"
if decryptBB and originalBB:
'''
https://www.hex-rays.com/products/ida/support/idadoc/1350.shtml
Since architecture version v4 (introduced in ARM7 cores), ARM processors have a new 16-bit instruction set called Thumb (the original 32-bit set is referred to as "ARM"). Since these two sets have different instruction encodings and can be mixed in one segment, we need a way to specify how to disassemble instructions.
For this purpose, IDA uses a virtual segment register named 'T'. If its value is 0, then ARM mode is used. Otherwise, Thumb mode is used. ARM is the default mode. Please note that if you change the value of T register for a range, IDA will destroy all instructions in that range because their disassembly is no longer correct.
'''
isThumb = GetReg(entryBB.start_ea, 't')
result.append((entryBB.start_ea + isThumb, decryptBB.start_ea + isThumb, originalBB.start_ea + isThumb,))

注意这里要区分开arm和thumb,ida里是通过 “T寄存器”来区分的,我们需要转换为正确的PC值。

至此,我们获得了一系列 entryBB、decryptBB、originalBB 的数据,完整代码见github。

3、使用unicorn模拟执行代码 这里主要的难点是何时认为字符串已解密完成,向下运行的太多,对我们的分析是有害的,因为业务代码会造成各种非预期,一定要停下来。

另一个难点是,使用idapython得到的数据,大部分是可靠的,有一小部分可能是不可靠的,例如业务代码真的写了 if(xxx) {xxx} 这样的代码,这种代码对我们的分析也是有害的,需要额外考虑,我们称之为“非decryptHeaders”。

停止的方案一:status和decrypt的内存访问read/write恰好完全成对出现; -> 极大概率不会被非decryptHeaders的地方干扰

停止的方案二:status的位置已知,decrypt执行完毕;-> 但容易被非decryptHeaders的地方干扰,因为对解密block的鉴定是不准确的

停止的方案三:status的位置已知,当他被赋值为1时,停止执行;-> 大概率不会被非decryptHeaders的地方干扰

最后选择了方案三,因为比较好写,如果失败率过高,就需要人工剔筛选一下 decryptHeaders 了。

4、执行停止的方案三,主要使用UC_HOOK_MEM_READ和UC_HOOK_MEM_WRITE 我们将运行分为三段: entryBB里一定只涉及一次内存读,(因为status初始为0,落在了bss里),可以获取status的内存地址,直到decryptBB会停下。

decryptBB不关心,直接运行即可,直到originalBB会停下。

original运行后,当status被写为1时,停下来。

这里仍然有Thumb的坑点,入口PC要写加一的值,停止PC要判断去掉一的值,需要多测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def hook_bss_access(uc: Uc, access, address, size, value, data):
# check rwdata range
if bss_section_start <= address < bss_section_end:
if stage == 0:
# entryBB
if access == UC_MEM_READ and size == 4 and struct.unpack("<I", uc.mem_read(address, size))[0] == 0:
decryptStatus.add(address)
print("READ: address:0x{0:016x} is decryptStatus".format(address))
elif stage == 1:
# decryptBB
pass
elif stage == 2:
# originalBB
if access == UC_MEM_WRITE and size == 4 and value == 1:
print("WRITE: address:0x{0:016x} is decryptStatus".format(address))
if address in decryptStatus:
decryptStatus.remove(address)
uc.emu_stop()
emu.hook_add(UC_HOOK_MEM_READ UC_HOOK_MEM_WRITE, hook_bss_access)

按照预期,执行三段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
for entryBB, decryptBB, originalBB, in j_result:
emu.reg_write(UC_ARM_REG_SP, STACK_ADDR + STACK_SIZE)
print("Emulating entryBB")
stage = 0
decryptStatus = set()
data_backup = []
emu.emu_start(entryBB, decryptBB & 0xFFFFFFFE)
print("Emulating decryptBB")
stage = 1
emu.emu_start(decryptBB, originalBB & 0xFFFFFFFE)
print("Emulating originalBB")
stage = 2
emu.emu_start(originalBB, 0)

5、异常case的处理 为了避免“非decryptHeaders”被运行,我做了两种简单的校验,防止异常case出现。

校验一:在entryBB,读bss时,将它的地址放到set里;在originalBB,写bss时,只要涉及到bss写入就停下,并且从set中移除地址。在预期内的话,结束时set时空的。

校验二:在entryBB,读内容一定读到的是4字节的0;originalBB时,写内存一定写到的是4字节的1。

如果出现了非预期,就需要将rwdata恢复,防止影响,因此需要添加一个用于backup的内存监控钩子,如下代码:

1
2
3
4
5
6
7
8
9
10
11
def hook_rwdata_backup(uc: Uc, access, address, size, value, data):
if data_section_start <= address < data_section_end and access == UC_MEM_WRITE:
data_backup.append((address, uc.mem_read(address, size)))
# 添加内存恢复hook
emu.hook_add(UC_HOOK_MEM_WRITE, hook_rwdata_backup)

########
print("Current emulating seems incorrect, so restore patched data.")
# 逆序恢复,防止由于读写顺序错乱引起的恢复失败
for _a, data in data_backup[::-1]:
emu.mem_write(_a, bytes(data))

6、Patch掉data,Patch掉解密逻辑 data的话直接把rwdata的内容完全覆盖过去,字符串就会出现在那些未知。而Patch解密逻辑有点绕,因为status落在bss上的,所以初始化总是0,无法初始化为1,所以patch方式是:在decrypt的第一句,直接B到originalBB上。

1
2
3
4
5
6
7
8
9
10
11
12
13
print("Decrypt correctly!")
# Patch decryptBB jump to original BB
if decryptBB & 1 == 1:
ks = ks_thumb
else:
ks = ks_arm
fd.seek(decryptBB & 0xFFFFFFFE)
if originalBB - decryptBB >= 0:
bs = ks.asm("B.W $+" + str(originalBB - decryptBB))[0]
else:
bs = ks.asm("B.W $" + str(originalBB - decryptBB))[0]
for _ in bs:
fd.write(struct.pack("B", _))

五、验证成果

丢到手机里运行,发现patch前后功能没有变化,使用strings和ida都可以看到我们的字符串,完工!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
python3 decrypt_hikari.py
...............................
Emulating entryBB
address:0x000000000000c5f8 is decryptStatus
Emulating decryptBB
Emulating originalBB
address:0x000000000000c5f8 is decryptStatus
Decrypt correctly!
Emulating entryBB
address:0x000000000000c5fc is decryptStatus
Emulating decryptBB
Emulating originalBB
address:0x000000000000c5fc is decryptStatus
Decrypt correctly!
Patch data
done!

来一张对比图收工!