0. 前言
在分析某app的so时遇到了间接跳转类型的混淆,不去掉的话无法使用ida f5来静态分析,f5之后就长下面这样:

本文记录一下使用python+unicorn模拟执行来去掉混淆的过程。
1. 分析混淆的模式
混淆的汇编代码如下:
可以看到,这个代码块进行了一通运算,然后通过 br x8,跳转到寄存器x8中保存的地址,仔细分析这个x8的来源,可以观察到如下的固定模式:
1 2 3 4 5 6 7 8 9 10 11 12 | CMP X19, #0MOV W9, #0x40 ; '@' ; X9 = 0x40MOV W10, #0x38 ; '8' ; X10 = 0x38ADRP X25, #off_212C70@PAGECSEL X9, X10, X9, EQ ; if X19 == 0 then X9 = X10ADD X25, X25, #off_212C70@PAGEOFF ; X25 = 0x212C70LDR X8, [X25,X9] ; X8 = qword[X25 + X9]MOV W9, #0xFE53 ; X9 = 0xFE53MOV W10, #0x82B4 ; X10 = 0x82B4CSEL X9, X10, X9, EQ ; if X19 == 0 then X9 = X10SUB X8, X8, X9 ; X8 = X8 - X9BR X8 ; 跳转到X8 |
先看 LDR X8, [X25,X9],X25寄存器是一张偏移表的基址, 这条指令从偏移表+X9出取出8字节数据放到了X8中,而X9的值来源于csel指令是0x38或者是0x40,由cmp的结果决定,如果X19等于0,则X9此时等于0x38,负责等于0x40。
再看 SUB X8, X8, X9,从偏移表取出一个8字节数据到X8之后,用X8减X9,结果放到X8,X9的值也是来源于csel指令,0x82B4或者是0xFE53
最后,通过br X8跳转到目标地址
也就是说,根据X9值的不同,最终跳转的地址会有两个,把正常的分支指令混淆成了上面这种模式,手动还原混淆可以把br X8 patch成:
1 2 | beq addr1b addr2 |
2. 去混淆的方式
本来想的是直接用python来匹配这种模式,然后手动计算出两个分支的地址,最后patch,但是后面发现,一个被混淆的函数中只有第一个混淆块会给X25赋值偏移表的地址,其他的块就直接用X25的值了,不会再次赋值,比如下面这个:
这样的话就不能把这些被混淆的块单独的拿出来看,因为缺少计算分支地址的必要条件,必须从函数的第一个被混淆的块出发,获取到偏移表的地址才行。如果还想通过手动的计算出目的地址,那就需要手动去确定函数的边界,这样就太麻烦了。
所以最后还是选择使通过模拟执行的方式,从函数头开始执行,跑通每一个块,在执行到混淆块的时候,计算出分支地址,最后进行patch
这里模拟执行的框架选择unicorn,之前学习过无名侠大佬用unicorn去ollvm混淆的文章,这里借鉴一下思路
3. 写脚本
3.1 加载so
由于代码中有需要访问偏移表,这些偏移表是在so的第二个segment,这个segment的内存便宜和文件便宜不一样,跟windows加载pe一样存在一个拉伸的效果,所以为了模拟执行代码时可以正常访问到偏移表的数据,我们手动将so拉伸成内存视图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | def load_elf(filename): global img_size global out_data segs = [] with open(filename, 'rb') as f: out_data = f.read() for seg in ELFFile(f).iter_segments('PT_LOAD'): print('file_off:%s, va: %s, size: %s' %(hex(seg['p_offset']), hex(seg['p_vaddr']), hex(seg['p_filesz']))) segs.append((seg['p_offset'],seg['p_vaddr'], seg['p_filesz'], seg.data())) img_size = segs[-1][1] + segs[-1][2] byte_arr = bytearray([0] * img_size) for seg in segs: vaddr = seg[1] size = seg[2] data = seg[3] byte_arr[vaddr: vaddr + size] = bytearray(data) return byte_arr |
3.2 初始化unicorn
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | def init_unicorn(file_name): global bin_data global uc #装载一下so到内存 bin_data = bytes(load_elf(file_name)) uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM) uc.mem_map(0x80000000, 8 * 0x1000 * 0x1000) uc.mem_map(0, 8 * 0x1000 * 0x1000) # 写入so数据 uc.mem_write(0, bin_data) #设置sp寄存器 uc.reg_write(UC_ARM64_REG_SP, 0x80000000 + 0x1000 * 0x1000 * 6) #设置指令执行hook,执行每条指令都会走hook_code uc.hook_add(UC_HOOK_CODE, hook_code) #设置非法内存访问hook uc.hook_add(UC_HOOK_MEM_UNMAPPED, hook_mem_access) barr = uc.mem_read(0x144320, 8) print(barr) |
3.3 如何跑通一个函数的所有路径
这里借鉴无名侠大佬的思路,先把入口节点放到队列中,然后不停从队列中取节点,以这个节点为起点模拟执行,直到下一个br reg,或者是ret。
一个节点包括地址和上下文环境(寄存器),在模拟执行之前,需要把寄存器的值设置好,同时在找到分支之后,也需要保存现场的上下文环境。
在找到下一个br reg之后,计算出分支地址,将分支地址放到队列中,继续循环即可。
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 | def deobf(): # 初始化unicorn filename = 'libxxxx.so' patched_filename = 'out.so' start_addr = 0x0103168 init_unicorn(filename) q = queue.Queue() q.put((start_addr, None)) # 入口函数是第一个节点,放到队列中去,队列中是(地址,上下文) traced = {} # 跑过的节点 while not q.empty(): #一直循环,直到队列为空 addr, context = q.get() traced[addr] = 1 # 跑过了 s = run(addr, context) #开始模拟执行,找br reg if s is None: continue if len(s) == 2: #单分支 if s[0] not in traced: q.put(s) #将分支节点放到队列中 else: #双分支 if s[0] not in traced: q.put((s[0], s[2]))#将分支节点放到队列中 if s[1] not in traced: q.put((s[1], s[2]))#将分支节点放到队列中def run(addr, context): global uc global is_success global block_flow #开始模拟执行,函数返回说明在hook_code中执行了emu_stop set_context(uc, context)#设置寄存器环境 uc.emu_start(addr, 0x10000) if is_success == True: is_success = False return block_flow[addr] #返回分支信息和context |
3.4 hook_code
hook_code是在unicorn中注册的指令执行回调,当执行uc.emu_start(addr, 0x10000)之后,就会开始模拟执行指令,同时调用hook_code,在hook_code中有很多重要的逻辑。
保存指令栈
进入hook_code之后,需要保存执行的汇编指令以及上下文环境,供后续判断是否到达混淆块使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def hook_code(uc, address, size, user_data): global ins_stack global is_success if is_success == True: uc.emu_stop() return ins_help = InsHelp() code = uc.mem_read(address, size) ins = list(ins_help.disasm(code, address, False))[0] print("[+] tracing instruction\t0x%x:\t%s\t%s" % (ins.address, ins.mnemonic, ins.op_str)) #记录指令和上下文环境 ins_stack.append((address, get_context(uc))) |
遇到ret或者bl .__stack_chk_fail需要停止
这两个是函数的边界,执行到这里就需要停下,我没有找到如何优雅的判断执行到了bl .__stack_chk_fail,所以就判断bl后面的地址了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #遇到ret直接挺停止if ins.mnemonic.lower() == 'ret': #uc.reg_write(UC_ARM64_REG_PC, 0) print("[+] encountered ret, stop") ins_stack.clear() uc.emu_stop() return#遇到bl .__stack_chk_fail停止if ins.mnemonic.lower() == 'bl' and ins.operands[0].imm == 0x237C0: #uc.reg_write(UC_ARM64_REG_PC, 0) print("[+] encountered bl .__stack_chk_fail, stop") ins_stack.clear() uc.emu_stop() return |
跳过函数调用、非栈或者so本身的内存访问、svc
需要跳过这些指令,并不影响寻路
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 | #跳过bl、非栈、so本身内存访问、svc if ins.mnemonic.lower().startswith('bl') or is_ref_ilegel_emm(uc, ins) or ins.mnemonic.lower().startswith('svc'): print("[+] pass instruction 0x%x\t%s\t%s" % (ins.address, ins.mnemonic, ins.op_str)) uc.reg_write(UC_ARM64_REG_PC, address + size) returndef is_ref_ilegel_emm(mu, ins): if ins.op_str.find('[') != -1: if ins.op_str.find('[sp') == -1: # 不是通过sp访问内存 for op in ins.operands: if op.type == ARM64_OP_MEM: addr = 0 if op.value.mem.base != 0: addr += mu.reg_read(reg_ctou(ins.reg_name(op.value.mem.base))) if op.value.mem.index != 0: addr += mu.reg_read(reg_ctou(ins.reg_name(op.value.mem.index))) if op.value.mem.disp != 0: addr += op.value.mem.disp if 0x0 <= addr <= img_size: # 访问so中的数据,允许 return False elif 0x80000000 <= addr < 0x80000000 + 0x1000 * 0x1000 * 8: #访问栈中的数据,允许 return False else: return True else:# 是通过sp的内存访问,允许 return False else: return False |
特征是否到达混淆块、计算分支地址
先判断是否是br指令,如果是,调用get_double_branch尝试去进一步匹配特征
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | if ins.mnemonic == "br": #判断是否到达间接跳转 is_success = True block_base = ins_stack[0][0] jmp_addr = ins_stack[-1][0] ret = get_double_branch(uc, ins_stack) if ret != None: print('find double branch: %x => %x, %x' % (block_base, ret[0], ret[1])) block_flow[ins_stack[0][0]] = ret patch_double_branch(uc, jmp_addr, ret) else: ret = get_single_branch(uc, ins_stack) if ret == None: print("[+] find dest failed 0x%x\t%s\t%s" % (ins.address, ins.mnemonic, ins.op_str)) is_success = False else: print('find single branch: %x => %x' % (block_base, ret[0])) block_flow[block_base] = ret patch_single_branch(jmp_addr, ret[0]) ins_stack.clear() uc.emu_stop() return |
进入get_double_branch,遍历指令栈,判断是否存在特定的指令,如果有则获取指定寄存器的值,最后计算出两个分支地址,这几个指令存在先后顺序,所以需要几个标志变量来控制。
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 | def get_double_branch(uc, ins_stack): #变量声明略 ins_help = InsHelp() cond = '' for tup in ins_stack[::-1]: addr = tup[0] context = tup[1] ins = list(ins_help.disasm(bin_data[addr: addr+5], addr, False))[0] # BR X8 if ins.mnemonic.lower() == 'br' and flag_br == False: flag_br = True br_reg = ins.operands[0].reg # SUB X8, X8, X9 if flag_br == True and (ins.mnemonic.lower() == 'add' or ins.mnemonic.lower() == 'sub') \ and ins.operands[0].reg == br_reg and flag_sub_add == False: if ins.operands[1].type == 1 and ins.operands[2].type == 1: op_reg1 = ins.operands[1].reg op_reg2 = ins.operands[2].reg flag_sub_add = True # CSEL X9, X10, X9, EQ if flag_sub_add == True and ins.mnemonic.lower() == 'csel' and ins.operands[0].reg == op_reg2 \ and flag_csel1 == False: cond = ins.op_str.split(', ')[-1] regname1 = ins.reg_name(ins.operands[1].reg) regname2 = ins.reg_name(ins.operands[2].reg) # index1 = reg_ctou(regname1) - arm64_const.UC_ARM64_REG_X0 # index2 = reg_ctou(regname2) - arm64_const.UC_ARM64_REG_X0 reg2_value1 = 0 if regname1.lower() == 'xzr' else context[reg_ctou(regname1) - arm64_const.UC_ARM64_REG_X0] reg2_value2 = 0 if regname2.lower() == 'xzr' else context[reg_ctou(regname2) - arm64_const.UC_ARM64_REG_X0] flag_csel1 = True # LDR X8, [X25,X9] if flag_sub_add == True and ins.mnemonic.lower() == 'ldr' and ins.operands[0].reg == op_reg1 \ and flag_ldr == False: pattern = r'\[(.*?)\]' matches = re.findall(pattern, ins.op_str) assert len(matches) == 1, 'not find []: %x\t%s\t%s' % (addr, ins.mnemonic, ins.op_str) op2_str = matches[0] regs = op2_str.split(', ') assert len(regs) == 2, 'ins invalid!: %x\t%s\t%s' % (addr, ins.mnemonic, ins.op_str) table_base = context[reg_ctou(regs[0]) - arm64_const.UC_ARM64_REG_X0] op_reg3 = reg_ctou(regs[1]) flag_ldr = True # CSEL X9, X10, X9, EQ if flag_ldr == True and ins.mnemonic.lower() == 'csel' and reg_ctou(ins.reg_name(ins.operands[0].reg)) == op_reg3 \ and flag_csel2 == False: regname1 = ins.reg_name(ins.operands[1].reg) regname2 = ins.reg_name(ins.operands[2].reg) # index1 = reg_ctou(regname1) - arm64_const.UC_ARM64_REG_X0 # index2 = reg_ctou(regname2) - arm64_const.UC_ARM64_REG_X0 reg3_value1 = 0 if regname1.lower() == 'xzr' else context[reg_ctou(regname1) - arm64_const.UC_ARM64_REG_X0] reg3_value2 = 0 if regname2.lower() == 'xzr' else context[reg_ctou(regname2) - arm64_const.UC_ARM64_REG_X0] flag_csel2 = True if flag_csel1 == True and flag_csel2 == True: # 满足条件时走的分支 barr1 = uc.mem_read(table_base + reg3_value1, 8) #直接从文件中读数据,注意内存偏移和文件偏移的转换 base1 = struct.unpack('q',barr1) offset1 = base1[0] - reg2_value1 # 不满足条件时走的分支 barr2 = uc.mem_read(table_base + reg3_value2, 8) base2 = struct.unpack('q',barr2) offset2 = base2[0] - reg2_value2 return (offset1, offset2, get_context(uc), cond) else: return None |
除了双分支,还有单分支的情况:
这种只有一个固定的分支,所以直接patch成 b 0xxxxxxxxx即可
所以如果上述特征匹配不成功,则认为是单分支
3.5 patch
当遇到混淆块,计算出分支地址之后,就要进行patch了,双分支的patch需要两条指令的空间,但是有时候混淆块的倒数第二个指令是原来的指令,不能被覆盖,那么能用的就只有一条指令的空间。
那就只能找代码段中别的的空闲空间,调b跳转到空闲空间,然后在跳转到两个分支,找一个跳板。
当我在so中搜索nop时,居然发现了一段很长的nop,那用这里不就行了吗,反正去混淆也只是为了静态分析,不需要塞回去让so正常跑。
patch代码:
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 | def patch_double_branch(uc, addr, branch): global out_data nop_addr = find2nop(uc) assert nop_addr is not None, 'no find 2 nop' offset1 = branch[0] offset2 = branch[1] cond = branch[3] ks = keystone.Ks(keystone.KS_ARCH_ARM64, keystone.KS_MODE_LITTLE_ENDIAN) # 1. 把bx reg修改成跳转到nop_addr jmp1_asm = 'b ' + hex(nop_addr) jmp1_bin = ks.asm(jmp1_asm, addr)[0] # 2. bcond addr1 jmp2_asm = 'b' + cond + ' ' + hex(offset1) jmp2_bin = ks.asm(jmp2_asm, nop_addr)[0] #3. b addr2 jmp3_asm = 'b ' + hex(offset2) jmp3_bin = ks.asm(jmp3_asm, nop_addr + 4)[0] #print(jmp3_bin) #patching print('patch code: %x\t%s => %s' % (addr, list(out_data[addr: addr + 4]), jmp1_bin)) out_data = patch_bytes(out_data, bytearray(jmp1_bin), addr, 4) print('patch code: %x\t%s => %s' % (nop_addr, list(out_data[nop_addr: nop_addr + 4]), jmp2_bin)) out_data = patch_bytes(out_data, bytearray(jmp2_bin), nop_addr, 4) print('patch code: %x\t%s => %s' % (nop_addr + 4, list(out_data[nop_addr + 4: nop_addr + 8]), jmp3_bin)) out_data = patch_bytes(out_data, bytearray(jmp3_bin) , nop_addr + 4, 4) |
最后将so数据写回文件
1 2 3 | #保存patch后的sowith open(patched_filename, 'wb') as f: f.write(out_data) |
4. 效果
还存在一些虚假控制流,但是已经不影响分析了

5. 问题
- 如果没有找到那一长串nop,就需要给代码段扩展一下放跳板,不知道咋搞
- 混淆块的特征可能还需要进一步测试修改,只测了几个函数
6. 参考