Unicorn实战(一):去掉libcms.so的花指令

最近学习unicorn,看到一位大佬在AndroidNativeEmu上把 X-Gorgen的计算跑通了,听说这个版本的leviathan函数很复杂,准备动手看看复杂在哪里,一眼就看到了JNI_OnLoad里的花指令。抱着学习unicorn的目的,本文记录下如何使用unicorn去掉libcms.so的花指令。

本文的涉及到2个文件,libcms.so请从4.0.0版本的抖音app里解压出来,unflower_cms.py 位于该gist下:https://gist.github.com/LeadroyaL/80a5f6fbb83ee1c102c860aaf2bc594d

一、初步分析

随手下载了4.0.0的抖音,混淆还在,leviathan函数也在,本文就用它来演示。

先看两段汇编,demo1和demo2,都是PUSH起手,连续操作2个和PC相关的MOV,之后进行简单的运算和跳转,最后POP收尾,根据预期,花指令运行时不大依赖上下文,非常适合unicorn模拟执行。
dmeo1

1
2
3
4
5
6
7
8
9
10
11
12
.text:0007862A LDR R0, =loc_5D45C
.text:0007862C MOVW R1, #0xFFF
<font color=red>.text:00078630 PUSH {R0-R3}</font>
<font color=red>.text:00078632 MOV R0, PC</font>
<font color=red>.text:00078634 MOV R1, PC</font>
.text:00078636 SUBS R2, R1, R0
.text:00078638 SUBS R2, R2, #2
.text:0007863A CMP R2, #0
.text:0007863C BGT loc_78662
.text:0007863E B loc_78640
-----
<font color=red>.text:00078686 POP {R0-R3}</font>

demo2

.text:000786B6 PUSH {R0-R3,R7}
.text:000786B8 MOV R0, PC
.text:000786BA MOV R1, PC
.text:000786BC SUBS R2, R1, R0
.text:000786BE SUBS R3, R2, #2
.text:000786C0 CMP R3, #0
.text:000786C2 BGT loc_786E8
.text:000786C4 B loc_786C6


.text:000786CE PUSH {R1}

.text:000786E4 POP {R1}
.text:000786E6 B loc_7870A


.text:0007870A POP {R0-R2}
.text:0007870C POP {R3,R7}

二、整体思路:从PUSH进入,向后执行,尝试找到栈平衡的时刻,按照预期,这个时刻寄存器的内容与开始时是完全相同的。

三、详细思路

1、使用python3和相关组件: unicorn keystone-engine(这个不要用pip装) capstone pyelftools

2、使用pyelftools找到连接时视图,即节表,根据节表找到.text的范围。使用keystone反汇编.text节的内容,找到我们需要的格式,这里有个坑点,keystone反汇编是存在范围限制的,因此每次只反1000字节,防止超出部分被截断,而且往前稍微扫一点,防止寻找的内容被拆到两次扫描中。

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
# 找到.text节

filename = "./libcms.so"
fd = open(filename, 'rb')
elf = ELFFile(fd)
sh_offset = elf.get_section_by_name(".text").header['sh_offset']
sh_size = elf.get_section_by_name(".text").header['sh_size']
fd.seek(sh_offset)
text_data = fd.read(sh_size)

# 找到 [PUSH{...}; MOV RX,PC; MOV RX,PC -> 只找thumb
cs = Cs(CS_ARCH_ARM, CS_MODE_THUMB)

# (address, push_regs)
entries = []
step = 1000
for i in range(0, len(text_data), step):
_i = max(0, i - 10)
g = cs.disasm(text_data[_i:_i + step], 0)
while True:
try:
ins = next(g)
assert isinstance(ins, CsInsn)
# push {rx, rx}
if ins.mnemonic != 'push':
continue
ins2 = next(g)
assert isinstance(ins2, CsInsn)
# mov rx, pc
if not ins2.mnemonic.startswith('mov') or not ins2.op_str.endswith('pc'):
continue
ins3 = next(g)
assert isinstance(ins3, CsInsn)
# mov rx, pc
if not ins3.mnemonic.startswith('mov') or not ins3.op_str.endswith('pc'):
continue
entries.append((_i + sh_offset + ins.address, ins.op_str))
except StopIteration:
break
print(entries)

3、使用pyelftools找到运行时视图,即段表。根据段表将library正确加载到内存中。这里的坑点在于内存要对齐,否则无法进行mmap操作。

2019年9月15日13:56:16更新:以及另一个坑点,p_flags和uc_prot是不一致的,不能直接使用。

1
2
3
4
5
6
7
8
9
10
11
12
typedef enum uc_prot {
UC_PROT_NONE = 0,
UC_PROT_READ = 1,
UC_PROT_WRITE = 2,
UC_PROT_EXEC = 4,
UC_PROT_ALL = 7,
} uc_prot;


PF_X=0x1
PF_W=0x2
PF_R=0x4
1
2
3
4
5
6
7
8
9
10
11
12
13
# 加载 so 到内存中
def align(addr, size, align):
fr_addr = addr // align * align
to_addr = (addr + size + align - 1) // align * align
return fr_addr, to_addr - fr_addr

load_base = 0
emu = Uc(UC_ARCH_ARM, UC_MODE_LITTLE_ENDIAN)
load_segments = [x for x in elf.iter_segments() if x.header.p_type == 'PT_LOAD']
for segment in load_segments:
fr_addr, size = align(load_base + segment.header.p_vaddr, segment.header.p_memsz, segment.header.p_align)
emu.mem_map(fr_addr, size, pflags2prot(segment.header.p_flags))
emu.mem_write(load_base + segment.header.p_vaddr, segment.data())

4、使用unicorn进行模拟执行,根据寻找到的特征,找到对应的真实代码的位置。这里判断的原则有两个,一是栈平衡,二是被保护的寄存器被完整地还原。这里也有一个坑点,在PUSH指令执行时,也会满足栈平衡的条件,会导致程序刚执行第一句就退出了,需要额外判断一下。

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
# 依次进入所有的entry,执行到栈平衡时退出
STACK_ADDR = 0x7F000000
STACK_SIZE = 1024 * 1024
start_addr = None


def hook_code(mu: Uc, address, size, user_data):
if mu.reg_read(UC_ARM_REG_PC) != start_addr and mu.reg_read(UC_ARM_REG_SP) == STACK_ADDR + STACK_SIZE:
emu.emu_stop()


emu.mem_map(STACK_ADDR, STACK_SIZE)
emu.hook_add(UC_HOOK_CODE, hook_code)

_to_reg_id = {
"r0": UC_ARM_REG_R0, "r1": UC_ARM_REG_R1, "r2": UC_ARM_REG_R2, "r3": UC_ARM_REG_R3,
"r4": UC_ARM_REG_R4, "r5": UC_ARM_REG_R5, "r6": UC_ARM_REG_R6, "r7": UC_ARM_REG_R7,
"r8": UC_ARM_REG_R8, "r9": UC_ARM_REG_R9, "r10": UC_ARM_REG_R10, "r11": UC_ARM_REG_R11,
"r12": UC_ARM_REG_R12, "r13": UC_ARM_REG_R13, "r14": UC_ARM_REG_R14, "r15": UC_ARM_REG_R15,
"lr": UC_ARM_REG_LR, "pc": UC_ARM_REG_PC, "sp": UC_ARM_REG_SP,
"sb": UC_ARM_REG_SB, "sl": UC_ARM_REG_SL, "fp": UC_ARM_REG_FP, "ip": UC_ARM_REG_IP,
}

ret = []
MAGIC32 = 0x12345678
for push_entry, push_regs in entries:
emu.reg_write(UC_ARM_REG_SP, STACK_ADDR + STACK_SIZE)
print("Emulate arm code start", hex(push_entry))
start_addr = push_entry
for r in push_regs.strip('{}').replace(' ', '').split(','):
emu.reg_write(_to_reg_id[r], MAGIC32)
emu.emu_start(push_entry + 1, 0, 0, 100)
print("Emulation arm code done")
changed = False
for r in push_regs.strip('{}').replace(' ', '').split(','):
if emu.reg_read(_to_reg_id[r]) != MAGIC32:
changed = True
break
stop_addr = emu.reg_read(UC_ARM_REG_PC)
if not changed:
print("Match:", start_addr, stop_addr)
ret.append((start_addr, stop_addr))
else:
print("Cannot handle:", start_addr)

5、使用capstone和ida的API,将PUSH语句改写为跳转语句,刚好会覆盖掉原有的PUSH和MOV语句,跳转的目标是POP后栈平衡的那条语句、而非POP语句,刚好也就是我们的stop_addr。

1
2
3
4
5
6
7
for start, stop in ret:
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
a = ks.asm("B.W $+" + str(stop - start))
PatchByte(start, a[0][0])
PatchByte(start + 1, a[0][1])
PatchByte(start + 2, a[0][2])
PatchByte(start + 3, a[0][3])

完整代码见gist,可直接运行。

四、验证成果

找台root的手机,把libcms.so丢进去,覆盖原先的文件,发现抖音仍然可以正常使用,说明patch的确实没问题!

参考链接:

http://www.unicorn-engine.org/docs/tutorial.html

https://bbs.pediy.com/thread-253868.htm

https://zhuanlan.zhihu.com/p/33087450