【Android安全- VMP攻略笔记】此文章归类为:Android安全。
本篇笔记记录的是一次围绕 VMP 样本的完整逆向实战:从入口定位、混淆对抗、执行路径还原,到关键机制理解并在AI的加持下实现虚拟化加固。项目地址:7faK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6D9P5s2A6Q4x3X3c8B7K9h3q4F1k6r3q4F1i4K6u0r3g2X3#2H3f1s2u0G2K9X3g2U0N6l9`.`.
Python版本:3.11
IDA版本:9.2
Frida版本:hluda-server-16.0.10
样本:金罡大佬同款
VMP的入口函数特征确实不太好找,但根据 VMP 开发的常见思路,虚拟机入口函数一般都会被 Wrapper 函数包装调用,这些 Wrapper 函数一般拥有如下的特征,我们可以据此推测一些比较可疑的VMP入口地址,再配合 Frida 具体定位
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 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | # Wrapper 函数特征# 1. 规模:指令数量极少,一般少于 20 条指令# 2. 调调用函数数量限制:仅存在一条跳转指令# 3. 调用函数方式限制:存在一条固定目标跳转指令(BL跳转指令)# 4. 返回方式:以 ret 结束# 5. 尾指令数量:CALL和RET之间的指令数量一般不会太多,一般应小于3# 6. 返回结果:CALL 之后不修改 X0/W0import ida_funcsimport ida_uaimport idautilsimport idaapiimport ida_idp# =============================================================================# 配置# =============================================================================MAX_INSNS = 30 # wrapper 最大指令数(对应特征 1:规模)MIN_WRAPPER_COUNT = 15 # 至少几个 wrapper 指向同一 VM Entry# =============================================================================# 工具函数(规模 / 调用关系 / 输出 检查用)# =============================================================================def iter_func_insns(func): """遍历函数内指令(IDA 9.2:func_item_iterator_t)""" fii = ida_funcs.func_item_iterator_t() if not fii.set(func): return for ea in fii.code_items(): yield eadef is_call_insn(ea): """是否为 call 指令(用于检查“仅一次 call”)""" insn = ida_ua.insn_t() if ida_ua.decode_insn(insn, ea): return ida_idp.is_call_insn(insn) return Falsedef writes_return_reg(ea): """是否存在写入X0/W0寄存器 """ insn = ida_ua.insn_t() if ida_ua.decode_insn(insn, ea) == 0: return False if not ida_idp.has_insn_feature(insn.itype, ida_idp.CF_CHG1): return False op0 = insn.ops[0] if op0.type != ida_ua.o_reg: return False width = ida_ua.get_dtype_size(op0.dtype) reg_name = ida_idp.get_reg_name(op0.reg, width) return reg_name in ("X0", "W0")# 获取函数中所有CALL指令def get_call_list(func): call_list = [] for ea in iter_func_insns(func): if is_call_insn(ea): call_list.append(ea) return call_list# 获取 CALL 指令后面的所有指令(不含 call 本身)def get_call_tail(func): call_list = get_call_list(func) if not call_list: return [] tail_insns = [] for ea in iter_func_insns(func): if ea > call_list[0]: tail_insns.append(ea) return tail_insnsdef analyze_wrapper(func): """若 func 符合 Wrapper 特征,返回其唯一调用的 VM Entry 地址;否则返回 None。""" insns = list(iter_func_insns(func)) # 1. 规模:指令数量极少 if len(insns) == 0 or len(insns) > MAX_INSNS: return None # 2. 调用函数数量限制:仅存在一条跳转指令 call_list = get_call_list(func) if len(call_list) != 1: return None # 3. 调用函数方式限制:存在一条固定目标跳转指令(BL跳转指令) targets = list(idautils.CodeRefsFrom(call_list[0], False)) if len(targets) != 1: return None # 4. 返回方式:以 ret 结束 if not idaapi.is_ret_insn(insns[-1]): return None # 5. 尾指令数量:CALL和RET之间的指令数量应小于5 tail_insns = get_call_tail(func) if len(tail_insns) == 0 or len(tail_insns) > 3: return None # 6. 返回结果:CALL 之后不修改 X0/W0 for ea in tail_insns: if writes_return_reg(ea): return None callee_func = ida_funcs.get_func(targets[0]) if not callee_func: # 目标不在任何函数内(如 thunk)则排除 return None return callee_func.start_eadef find_vm_entry_candidates(): wrapper_map = {} for func_ea in idautils.Functions(): func = ida_funcs.get_func(func_ea) if not func: continue vm_entry = analyze_wrapper(func) if not vm_entry: continue wrapper_map.setdefault(vm_entry, []).append(func.start_ea) print("\n====== VM Entry Candidates ======") for vm_entry, wrappers in wrapper_map.items(): if len(wrappers) >= MIN_WRAPPER_COUNT: print(f"\n[+] VM Entry Candidate: {hex(vm_entry)} ({len(wrappers)} wrappers)") for w in wrappers: print(f" wrapper: {hex(w)}")def main(): find_vm_entry_candidates()main() |
防守方设计了大量的 BL 混淆代码,这里没啥说的,直接上罡佬的脚本,执行后可以去除 BL 混淆,注意要重新加载分析文件,Apply patches to input file --> 退出IDA ---- 重新加载so
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 | import idautilsimport idcimport idaapifrom keystone import * # pip3 install keystone-enginedef get_insn_const(addr): op_val = None if idc.print_insn_mnem(addr) in ['MOV', 'LDR']: op_val = idc.get_operand_value(addr, 1) if op_val > 0x1000: # 可能是间接引用 op_val = idc.get_wide_dword(op_val) else: raise Exception(f"error ops const: {addr}") return op_valdef get_patch_data(addr): addr_list = [] for bl_insn_addr in idautils.XrefsTo(addr): bl_insn_addr = bl_insn_addr.frm # print(f'L1 {hex(bl_insn_addr)}:') for xref_addr_l2 in idautils.XrefsTo(bl_insn_addr): # print(f'\tL2 {hex(xref_addr_l2.frm)}:') index = get_insn_const(xref_addr_l2.frm - 4) const_table_start = bl_insn_addr + 4 offset = idaapi.get_dword(const_table_start + index * 4) link_target = const_table_start + offset addr_list.append({"bl_insn_addr": bl_insn_addr, "patch_addr": xref_addr_l2.frm, "index": index, "offset": offset, "link_target": link_target}) return addr_listdef print_patch_data(patch_data): for item in patch_data: print( f"bl_insn_addr: {item["bl_insn_addr"]:#x}, patch_addr: {item["patch_addr"]:#x}, index: {item["index"]}, offset: {item["offset"]:#x}, link_target: {item["link_target"]:#x}")def patch_insns(patch_data): index = 0 for item in patch_data: ks = Ks(KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN) asm = f'B {item["link_target"]:#x}' print(f'patch addr {item["patch_addr"]:#x}: {asm}') encoding, count = ks.asm(asm, as_bytes=True, addr=item["patch_addr"]) print(encoding) for i in range(4): idc.patch_byte(item["patch_addr"] + i, encoding[i]) index += 1 # if index == 1: # break def start(): modify_x30_func_address = 0x25D00 patch_data = get_patch_data(modify_x30_func_address) print_patch_data(patch_data) patch_insns(patch_data)start() |
防守方设计了干扰 F5 的代码,但不过是迷惑 IDA 的障眼法,我们调整一下重定位表,执行后就可以 F5 了
直接对 sub_138518 函数直接 F5 会提示 13CEF4: invalid basic block
此时我们查看 13CEF4 会发现,IDA 将 13CEF4 识别成代码块了
1 | .text:000000000013CEF4 0E 00 00 00 dword_13CEF4 DCD 0xE ; DATA XREF: .data.rel.ro:off_1F15E0↓o |
1 2 3 4 5 6 7 8 9 10 | .data.rel.ro:1F15E0 A8 96 13 00 00 00 00 00 off_1F15E0 DCQ loc_1396A8 ; DATA XREF: sub_138518+1498↑o.data.rel.ro:1F15E0 ; sub_138518+14C0↑o ....data.rel.ro:1F15E8 00 95 13 00 00 00 00 00 DCQ loc_139500.data.rel.ro:1F15F0 F4 CE 13 00 00 00 00 00 DCQ dword_13CEF4.data.rel.ro:1F15F8 00 94 13 00 00 00 00 00 DCQ loc_139400.data.rel.ro:1F1600 28 96 13 00 00 00 00 00 DCQ loc_139628.data.rel.ro:1F1608 20 B0 13 00 00 00 00 00 DCQ loc_13B020.data.rel.ro:1F1610 D4 A7 13 00 00 00 00 00 DCQ loc_13A7D4.data.rel.ro:1F1618 F4 CF 13 00 00 00 00 00 DCQ loc_13CFF4.data.rel.ro:1F1620 00 97 13 00 00 00 00 00 DCQ dword_139700 |
我们可以将 1F15F0 中的地址指向一个空白的代码段然后将 13CEF4 的值直接写入,随后即可成功执行 F5 反汇编
1 2 3 4 5 6 7 8 9 10 11 12 | def val(value, size): return value.to_bytes(size, byteorder='little', signed=False)def raw_patch(ea, data): import idaapi for i, b in enumerate(data): idaapi.put_byte(ea + i, b) written = ida_bytes.get_bytes(ea, len(data)) print(f"验证写入: {written.hex()}") raw_patch(0x1F15F0, val(0x144218, 4))raw_patch(0x144218, val(0xe, 4)) |
防守方在开发时大量的使用跳转表的结构,编译之后就形成了类似 BR 的混淆代码,虽然我们确实可以找到跳转表,但 IDA 经常无法识别跳转表或者识别跳转表错误或者是以函数指针的形式调用,导致 F5 看不全代码,我们可以分析一下跳转表的逻辑,然后构造 IDA 易于识别的跳转逻辑
在 IDA 中模糊匹配无法正确建立switch case 表的 BR X8/9/10 跳转块
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 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | def search_wildcard_ex(hex_str1, *args): """ search_wildcard_ex( hex_str1, gap1, hex_str2, gap2, hex_str3, ... ) gap 语义: 前一个 pattern 结束地址 与 下一个 pattern 起始地址 之间 允许的最大字节数(不包含 pattern 自身) """ import ida_bytes import ida_ida import ida_idaapi # - # 1. 参数校验 # - if len(args) % 2 != 0: raise ValueError("arguments must be (gap, hex_str) pairs") patterns = [(hex_str1, None)] for i in range(0, len(args), 2): patterns.append((args[i + 1], args[i])) # - # 2. 工具函数 # - def compile_pat(hex_str): cpv = ida_bytes.compiled_binpat_vec_t() base = ida_ida.inf_get_min_ea() err = ida_bytes.parse_binpat_str(cpv, base, hex_str, 16) if err: raise RuntimeError("binpat compile failed: %s" % hex_str) return cpv def pat_len_from_hex(hex_str): # "?? 7A ?? F8" -> 4 return len(hex_str.strip().split()) # - # 3. 编译 pattern # - pats = [] for hex_str, gap in patterns: cpv = compile_pat(hex_str) pat_len = pat_len_from_hex(hex_str) pats.append((hex_str, cpv, pat_len, gap)) start = ida_ida.inf_get_min_ea() end = ida_ida.inf_get_max_ea() # - # 4. anchor 搜索 # - results = [] addr = start while True: addr, _ = ida_bytes.bin_search( addr, end, pats[0][1], ida_bytes.BIN_SEARCH_FORWARD ) if addr == ida_idaapi.BADADDR: break cur_ea = addr cur_len = pats[0][2] matched = True chain = [addr] # 本条匹配链:各 pattern 的起始地址 # - # 5. 逐段验证 # - for i in range(1, len(pats)): _, cpv, pat_len, gap = pats[i] # gap = 前一个结束 与 下一个起始 之间的最大字节数(含下一段起始) # 下一段起始可在 [prev_end, prev_end+gap],故搜索范围需覆盖到 prev_end+gap+pat_len(右开) search_start = cur_ea + cur_len search_end = cur_ea + cur_len + gap + pat_len ea2, _ = ida_bytes.bin_search( search_start, search_end, cpv, ida_bytes.BIN_SEARCH_FORWARD ) if ea2 == ida_idaapi.BADADDR: matched = False break chain.append(ea2) cur_ea = ea2 cur_len = pat_len if matched: results.append(chain) addr += 1 # 允许重叠 if not results: print("[-] no match") else: match_map = {chain[0]: chain[1:] for chain in results} lines = [" {}: [{}]".format(hex(k), ", ".join(hex(x) for x in v)) for k, v in sorted(match_map.items())] print("match_map = {") print(",\n".join(lines)) print("}") return results# .text:000000000013A5B4 08 B9 00 11 ADD W8, W8, #0x2E ; '.'# .text:000000000013A5B8 E8 5A 68 F8 LDR X8, [X23,W8,UXTW#3]# .text:000000000013A5BC F9 03 1F 2A MOV W25, WZR# .text:000000000013A5C0 F3 03 00 32 MOV W19, #1# .text:000000000013A5C4 F5 63 08 A9 STP X21, X24, [SP,#0x120+var_A0]# .text:000000000013A5C8 E8 7A 68 F8 LDR X8, [X23,X8,LSL#3]# .text:000000000013A5CC E0 7B 3B A9 STP X0, X30, [SP,#0x120+var_170]# .text:000000000013A5D0 00 01 1F D6 BR X8 ; apply_bit_mask compare_float_double# # .text:0000000000139DD0 29 B9 00 11 ADD W9, W9, #0x2E ; '.'# .text:0000000000139DD4 E9 5A 69 F8 LDR X9, [X23,W9,UXTW#3]# .text:0000000000139DD8 E9 7A 69 F8 LDR X9, [X23,X9,LSL#3]# .text:0000000000139DDC E0 7B 3B A9 STP X0, X30, [SP,#0x120+var_170]# .text:0000000000139DE0 20 01 1F D6 BR X9# # .text:000000000013A548 4A B9 00 11 ADD W10, W10, #0x2E ; '.'# .text:000000000013A54C EA 5A 6A F8 LDR X10, [X23,W10,UXTW#3]# .text:000000000013A550 EA 7A 6A F8 LDR X10, [X23,X10,LSL#3]# .text:000000000013A554 E0 7B 3B A9 STP X0, X30, [SP,#0x120+var_170]# .text:000000000013A558 40 01 1F D6 BR X10# # .text:000000000013A184 08 B9 00 11 ADD W8, W8, #0x2E ; '.'# .text:000000000013A188 E8 5A 68 F8 LDR X8, [X23,W8,UXTW#3]# .text:000000000013A18C 5A 17 00 11 ADD W26, W26, #5# .text:000000000013A190 F5 03 13 AA MOV X21, X19# .text:000000000013A194 FC 03 18 AA MOV X28, X24# .text:000000000013A198 E8 7A 68 F8 LDR X8, [X23,X8,LSL#3]# .text:000000000013A19C F8 03 09 AA MOV X24, X9# .text:000000000013A1A0 F3 03 1A 2A MOV W19, W26# .text:000000000013A1A4 E0 7B 3B A9 STP X0, X30, [SP,#0x120+var_170]# .text:000000000013A1A8 00 01 1F D6 BR X8search_wildcard_ex( "?? B9 00 11 ?? 5A ?? F8", 12, "?? 7A ?? F8", 12, "?? 01 1F D6")-----match_map = { 0x139dd0: [0x139dd8, 0x139de0], 0x13a184: [0x13a198, 0x13a1a8], 0x13a548: [0x13a550, 0x13a558], 0x13a5b4: [0x13a5c8, 0x13a5d0], 0x13a914: [0x13a928, 0x13a930], 0x13afc8: [0x13afd0, 0x13afd8], 0x13b00c: [0x13b014, 0x13b01c], 0x13b6cc: [0x13b6d4, 0x13b6dc], 0x13bce0: [0x13bce8, 0x13bcf0], 0x13bed4: [0x13bedc, 0x13bee4], 0x13c214: [0x13c21c, 0x13c224], 0x13d024: [0x13d02c, 0x13d034], 0x13d07c: [0x13d084, 0x13d08c]} |
利用 frida 批量 hook 建立 x23 和 x8/9/10 的映射表
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 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | let trace_map = new Map();function _py_str(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';}function dump_as_python_dict(trace_map) { if (!trace_map || typeof trace_map.forEach !== "function") { console.log("{}"); return; } let lines = []; trace_map.forEach(function (node, pc_hex) { let br_reg = (node && node.br_reg) ? node.br_reg : ""; let values = (node && node.values) ? node.values : new Map(); let innerParts = []; values.forEach(function (val, xn_1) { if (Array.isArray(val)) { let listStr = "[" + val.map(function (s) { return _py_str(s); }).join(", ") + "]"; innerParts.push(_py_str(xn_1) + ": " + listStr); } else { innerParts.push(_py_str(xn_1) + ": " + _py_str(val)); } }); let valuesStr = "{" + innerParts.join(", ") + "}"; lines.push(" " + _py_str(pc_hex) + ": {\"br_reg\": \"" + br_reg + "\", \"values\": " + valuesStr + "}"); }); console.log("{\n" + lines.join(",\n") + "\n}");}function hook_libmtguard_by_offset(base) { // [[0x139dd0, 0x139dd8, 0x139de0], [0x13a184, 0x13a198, 0x13a1a8], [0x13a548, 0x13a550, 0x13a558], [0x13a5b4, 0x13a5c8, 0x13a5d0], [0x13a914, 0x13a928, 0x13a930], [0x13afc8, 0x13afd0, 0x13afd8], [0x13b00c, 0x13b014, 0x13b01c], [0x13b6cc, 0x13b6d4, 0x13b6dc], [0x13bce0, 0x13bce8, 0x13bcf0], [0x13bed4, 0x13bedc, 0x13bee4], [0x13c214, 0x13c21c, 0x13c224], [0x13d024, 0x13d02c, 0x13d034], [0x13d07c, 0x13d084, 0x13d08c]] let hook_addr_map = new Map(); hook_addr_map.set(0x139dd0, "x9"); hook_addr_map.set(0x13a184, "x8"); hook_addr_map.set(0x13a548, "x10"); hook_addr_map.set(0x13a5b4, "x8"); hook_addr_map.set(0x13a914, "x8"); hook_addr_map.set(0x13afc8, "x8"); hook_addr_map.set(0x13b00c, "x8"); hook_addr_map.set(0x13b6cc, "x8"); hook_addr_map.set(0x13bce0, "x8"); hook_addr_map.set(0x13bed4, "x8"); hook_addr_map.set(0x13c214, "x8"); hook_addr_map.set(0x13d024, "x8"); hook_addr_map.set(0x13d07c, "x8"); hook_addr_map.forEach(function (br_reg_name, off) { let addr = base.add(off) Interceptor.attach(addr, { onEnter() { let changed = false; let pc_offset = this.context.pc.sub(base); let pc_offset_hex = "0x" + pc_offset.toString(16); console.log("pc_offset_hex =", pc_offset_hex); let reg = this.context[br_reg_name]; let xn_value_1 = reg.toInt32(); let xn_value_1_hex = "0x" + xn_value_1.toString(16); console.log("xn_value_1_hex =", xn_value_1_hex); let xn_value_2 = xn_value_1 + 0x2e; let xn_value_2_hex = "0x" + xn_value_2.toString(16); console.log("xn_value_2_hex =", xn_value_2_hex); let xn_value_3 = this.context.x23.add(xn_value_2 * 8).readU64(); let xn_value_3_hex = "0x" + xn_value_3.toString(16); console.log("xn_value_3_hex =", xn_value_3_hex); let xn_value_4 = this.context.x23.add(xn_value_3 * 8).readU64(); let xn_value_4_hex = "0x" + xn_value_4.toString(16); console.log("xn_value_4_hex =", xn_value_4_hex); let xn_value_5 = ptr(xn_value_4).sub(base); // 有符号差值 let xn_value_5_hex = "0x" + (xn_value_5 >>> 0).toString(16); // 按无符号显示偏移 console.log("xn_value_5_hex =", xn_value_5_hex); if(!trace_map.has(pc_offset_hex)) { trace_map.set(pc_offset_hex, { br_reg: br_reg_name, values: new Map() }); changed = true; } let pc_node = trace_map.get(pc_offset_hex); if(!pc_node.values.has(xn_value_1_hex)) { pc_node.values.set(xn_value_1_hex, [xn_value_3_hex, xn_value_5_hex]); changed = true; } if (!changed) return; dump_as_python_dict(trace_map); console.log("======================"); } }); });}function hook_linker_load() { var dlopen = Module.findExportByName(null, "dlopen"); var android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext"); if (dlopen) { console.log("[+] dlopen @", dlopen); Interceptor.attach(dlopen, { onEnter: function (args) { var name = args[0].readCString(); if (name && name.indexOf(".so") > -1) { // console.log("dlopen --> " + name); } } }); } else { console.log("[-] dlopen not found"); } if (android_dlopen_ext) { console.log("[+] android_dlopen_ext @", android_dlopen_ext); Interceptor.attach(android_dlopen_ext, { onEnter: function (args) { this.soname = args[0].readCString(); // console.log("android_dlopen_ext --> " + this.soname); }, onLeave: function () { if (!this.soname) return; if (this.soname.indexOf("libxxguard.so") === -1) { return; } var base = Module.findBaseAddress("libxxguard.so"); if (!base) { base = Module.findBaseAddress(this.soname); } if (!base) { console.log("[-] libxxguard.so base not found yet"); return; } console.log("[+] libxxguard.so base =", base); console.log("[+] libxxguard.so pid =", Process.id); hook_libmtguard_by_offset(base); } }); } else { console.log("[-] android_dlopen_ext not found"); }}function main() { hook_linker_load();}setImmediate(main); |
批量 patch BR 跳转,执行后此时 IDA 已经可以较好的反汇编生成伪 C 代码
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 93 94 95 96 97 98 99 | def set_asm(addr: int, asm: str) -> int: """ 在给定地址 addr 处写入一条 ARM64 汇编指令 asm(单行字符串)。 返回实际汇编出的指令条数(通常为 1)。 """ # 懒加载依赖,便于拷贝到其他工程中使用 import ida_bytes import ida_ua import ida_kernwin import ida_idaapi from keystone import Ks, KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN # 处理可能出现的 loc_xxxxx:将其替换为对应的绝对地址 0xXXXXXXXX if "loc_" in asm: parts = asm.split() for i, token in enumerate(parts): if token.startswith("loc_"): ea = ida_kernwin.str2ea(token) if ea == ida_idaapi.BADADDR: raise RuntimeError(f"Cannot resolve {token}") parts[i] = f"0x{ea:X}" asm = " ".join(parts) # Keystone 汇编 ks = Ks(KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN) encoding, count = ks.asm(asm, addr) code = bytes(encoding) # 以十六进制显示编码,例如 "70 BD FA 17" hex_encoding = " ".join(f"{b:02X}" for b in encoding) print(f"[+] Encoding: {hex_encoding}") # Patch 到 IDA ida_bytes.patch_bytes(addr, code) # 强制 IDA 重新反汇编 ea = addr end = addr + len(code) while ea < end: ida_ua.create_insn(ea) ea += 4 print(f"[+] Patched {count} ARM64 instructions at {hex(addr)}") return countdef find_empty_space(size: int) -> int: """ 在当前二进制的 .text 段中查找一块大小为 size 且全部为 0x00 的空闲空间, 返回 0x10 对齐的起始地址,找不到则返回 BADADDR。 """ import ida_bytes import ida_idaapi import ida_segment # 只在 .text 段中查找,和注释含义保持一致 seg = ida_segment.get_segm_by_name(".text") if not seg: return ida_idaapi.BADADDR start = seg.start_ea end = seg.end_ea # 从第一个 0x10 对齐的地址开始,每次步进 0x10,直接在该对齐处读 size 字节检查是否全 0 ea = (start + 0xF) & ~0xF while ea + size <= end: data = ida_bytes.get_bytes(ea, size) if data is not None and all(b == 0 for b in data): return ea ea += 0x10 return ida_idaapi.BADADDRmatch_map = { 0x139dd0: [0x139dd8, 0x139de0], ......}trace_map = { "0x139dd0": {"x9": {"0xc": "0x139628", "0xf": "0x13a934", "0x13": "0x13cd74"}}, ......}for pc_offset, node in trace_map.items(): for reg, info in node.items(): print(f"reg:{reg}") br_reg_id = int(reg.replace("x", "")) print(f"br_reg_id:{br_reg_id}") empty_space = find_empty_space((len(info)*2+1)*4) set_asm(int(pc_offset, 16), "NOP") set_asm(int(pc_offset, 16)+4, "NOP") set_asm(match_map[int(pc_offset, 16)][0], "NOP") set_asm(match_map[int(pc_offset, 16)][1], "B " + hex(empty_space)) index = 0 for key, value in info.items(): print(f"reg:{reg} key:{key} addr:{value}") set_asm(empty_space + 4 * index, f"CMP W{br_reg_id}, #{key}") index += 1 set_asm(empty_space + 4 * index, f"B.EQ {value}") index += 1 set_asm(empty_space + 4 * index, "RET") print("================================================") |
没什么说的,直接上追佬的工具,配合葫芦佬的 frida 一键trace
16eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6B7K9i4q4A6N6e0t1H3x3U0u0Q4x3V1k6$3L8g2)9J5k6s2c8J5j5h3y4W2i4K6u0V1M7X3g2D9k6h3q4K6k6b7`.`.
33fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6Z5P5Y4A6Z5k6i4W2S2L8X3N6Q4x3V1k6K6N6s2u0G2L8X3N6d9i4K6u0V1k6Y4u0A6k6r3q4Q4x3X3c8S2L8X3c8J5L8$3W2V1
强推 010Editor16,秒开超大文本,支持智能高亮
我自己拿 AI 写了一个简单的 UI 分析工具,主要是用来快速追溯寄存器的赋值情况(010Editor来回翻太痛苦了)
项目地址:298K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6D9P5s2A6Q4x3X3c8B7K9h3q4F1k6r3q4F1i4K6u0r3g2s2u0S2j5$3g2m8L8X3q4D9P5i4A6W2M7R3`.`.

我们可以简单的考虑一个问题,VMP 的原理是将编码的数据转换为自己能理解的 vmstate 然后再进行执行,并且每个被VMP保护的函数都对应一个 vmstate,那么vmp会每次执行的时候都重新解码一次构造一个新的 vmstate 么?换句话说如果我们自己来设计一个虚拟机我们会怎么做?显然是利用红黑树来管理所有的 vmstate,并且本次样本也是这么干的,此处可以说英雄所见略同。
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 | struct VmState{ FunctionType *function_list; __int64 register_count; __int64 type_count; void *register_list; void *type_list; void *inst_list; void *param_list;};struct VmStateTreeNode{ char _color_pad[8]; struct VmStateTreeNode *parent; struct VmStateTreeNode *left; struct VmStateTreeNode *right; unsigned __int64 key; VmState *value;};struct VmStateTree{ VmStateTreeNode *root; union { struct { VmStateTreeNode *sentinel_left; VmStateTreeNode *sentinel_parent; VmStateTreeNode *sentinel_right; void *sentinel_key; VmState *sentinel_value; }; char sentinel_area[40]; }; pthread_mutex_t mutex;}; |
我们从设计一个虚拟机来考虑一个问题,如果将虚拟机所有的代码都放在一个BYTECODE数据块中是否可行,理论上来讲一定是可行的,但这会导致我们几乎需要实现完整的linker,因为虚拟机中解释执行到 BL 这类指令时虚拟机需要自己计算重定位,这除了增加我们的开发难度以外几乎没有任何正向收益。所以简单的办法是除了BYTECODE数据块以外,我们还需要重构一份重定位表,让系统的 Linker 帮我们计算,而我们只需要在 ByteCode 里直接取 ReTable[id] 就行了。
解析每组 BYTECODE 时都采用了如下的结构体进行解析
1 2 3 4 5 6 7 8 | struct ByteCodeReader{ void *buffer_ptr; __int64 buffer_size; __int64 cached_bits; unsigned int bit_count; __int64 read_pos;}; |
解码按 6 bit 为一单元消费,其中最高位为继续标志位,当前 cached_bits 不足 6 bit 时触发补读,补读会使 byteCodeReader 从 buffer 再读 8 字节(不足 8 字节则读到末尾)做 bit 拼接,再继续;若仍不足则解码失败。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | type_tag = bit_stream_read((__int64)byteCodeReader, 6u); //1.读取6位type_tag_1 = type_tag; //2.初始化结果if ( (type_tag & 0x20) != 0 ) //3.检查bit5(继续标志位){ type_tag_1 = type_tag & 0x1F; //4.提取低5位数据/5。位移量初始化为5 v88 = 5; //5.位移量初始化为5 do { v89 = bit_stream_read((__int64)byteCodeReader, 6u); //6. 读下一个6位 type_tag_1 |= (v89 & 0x1F) << v88; //7.拼接低5位数据 v88 += 5; } while ( (v89 & 0x20) != 0 ); //9.检查继续标志} //10.type_tag_1 现在包含完整的VLE解码值 |
反汇编中出现的大量类似如下的代码,本质应该还是标准API va_arg、va_copy 在编译器优化后的展开形式,这里提供一份分析用的代码,可以自行编译调试一下,还是挺有意思的。
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 | struct __va_list_tag { void *__stack; void *__gr_top; void *__vr_top; int __gr_offs; int __vr_offs;};void test_va(int a, int b, int c, int d,int e, int f, int g, int h,...) { va_list ap; va_start(ap, h); struct __va_list_tag *va = (struct __va_list_tag *)≈ __va_list_tag* caller_context = new __va_list_tag(); memcpy(caller_context, &ap, sizeof(__va_list_tag)); for(int i = 0; i < 3; i++){ double* stack = nullptr; int vr_offs = caller_context->__vr_offs; if ( (int)vr_offs < 0 && (caller_context->__vr_offs = vr_offs + 16, (int)vr_offs + 16 <= 0)) { stack = (double *)((char *)caller_context->__vr_top + vr_offs); } else { stack = (double *)caller_context->__stack; caller_context->__stack = (char *)caller_context->__stack + 8; } double v213 = *stack; LOGD("v213:%f", v213); uint64_t stack_args_value = 0; int* stack_args_1 = nullptr; int __gr_offs = caller_context->__gr_offs; if ( (int)__gr_offs < 0 && (caller_context->__gr_offs = __gr_offs + 8, (int)__gr_offs + 8 <= 0) ) { stack_args_value = *(int *)((char *)caller_context->__gr_top + __gr_offs); } else { stack_args_1 = (int *)caller_context->__stack; caller_context->__stack = (char *)caller_context->__stack + 8; stack_args_value = *stack_args_1; } LOGD("stack_args_value:%lu", stack_args_value); } return;}test_va(1,2,3,4,5,6,7,8,1.25, 2.5);test_va(1,2,3,4,5,6,7,8,100, 1.3, 300); |
通过 .data.rel.ro 段中的 vtable 与 RTTI 符号,可以还原出大量关键信息
1 2 3 4 5 6 7 8 9 10 | .data.rel.ro:00000000001F1340 ; `vtable for'jg_vmp::Type.data.rel.ro:00000000001F1340 00 00 00 00 _ZTVN6jg_vmp4TypeE DCQ 0 ; DATA XREF: decode_type+DF0↑o.data.rel.ro:00000000001F1340 00 00 00 00 ; decode_type+EDC↑o ....data.rel.ro:00000000001F1340 ; offset to this.data.rel.ro:00000000001F1348 78 13 1F 00… DCQ _ZTIN6jg_vmp4TypeE ; `typeinfo for'jg_vmp::Type.data.rel.ro:00000000001F1350 2C ED 13 00…off_1F1350 DCQ nullsub_5.data.rel.ro:00000000001F1358 64 DB 13 00… DCQ j_j_.free_3.data.rel.ro:00000000001F1360 68 DB 13 00… DCQ sub_13DB68.data.rel.ro:00000000001F1368 74 DB 13 00… DCQ sub_13DB74.data.rel.ro:00000000001F1370 38 E0 13 00… DCQ sub_13E038 |
jg_vmp::Type(基类)IntegerType / StructType / PointerType / FunctionType / ArrayType / VectorType(派生类)继承关系可准确还原,RTTI 使用 __si_class_type_info,表明所有类型均统一继承自 jg_vmp::Type 可完整重建类型继承树
虚函数接口与调用约定泄露,vtable 中保存了虚函数数量和顺序以及派生类对基类虚函数的覆盖关系
VM 类型解析与执行逻辑被轻易推断,vtable / RTTI 被 decode_type 与 vm_interpreter 大量使用
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 | enum TypeKind : unsigned __int32{ VoidType = 0x0, FloatType = 0x2, DoubleType = 0x3, IntegerType = 0xB, FunctionType = 0xC, StructType = 0xD, ArrayType = 0xE, PointerType = 0xF, VectorType = 0x10,};struct /*VFT*/ Type_vtbl{ void (*_destructor)(); void (*_operator_delete)(); void (*_clone)(); void (*_get_type_name)(); TypeKind (*_get_type_size)();};struct Type{ Type_vtbl *vtable; TypeKind kind; char field1; char field2; char field3; char field4;}; |
前面做逆向时,我们已经把样本里的关键机制摸得比较清楚了:状态缓存、字节码组织、调用桥接、参数传递、寄存器槽管理。所以我们自己实现一个虚拟化加固,但路线选择上,我没有走 LLVM IR,而是选择了汇编语义翻译。虽然实现起来更困难,但更易于理解VMP核心机制。
项目地址:450K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6D9P5s2A6Q4x3X3c8B7K9h3q4F1k6r3q4F1i4K6u0r3g2X3#2H3f1s2u0G2K9X3g2U0N6l9`.`.
离线阶段的核心目标是: 将原始函数逻辑转化为虚拟执行载荷,并重构目标库的调用路径,实现执行权转移。
对选定的目标函数进行指令抽取与翻译处理:
每个函数最终形成:
1 | [函数标识] + [虚拟指令流] + [元数据] |
构成独立的“函数执行单元”。
将所有函数执行单元汇总,组织为统一的扩展容器结构:
容器结构写入扩展载荷库尾部区域,形成独立逻辑区块。
将构建完成的扩展载荷整体嵌入运行时宿主库(VM Engine)尾部:
最终形成:
1 | [VM Engine 本体] + [扩展载荷容器] |
实现单库集成模型。
对目标库进行符号层重构:
调用路径被收口为:
1 | 原始导出函数 → 接管分发层 → 虚拟执行 |
运行时阶段的核心目标是:在保持外部调用行为不变的前提下,完成载荷装载、链接恢复与虚拟执行调度。
对扩展容器进行解析:
当导出函数被调用时:
虚拟机进入执行循环:
这份笔记既是样本分析记录,也是工程实践说明。希望它能为同样在做 VMP 逆向或虚拟化保护研究的同学提供些许帮助
https://bbs.kanxue.com/thread-286441.htm
d04K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5H3P5r3q4U0j5h3u0Q4x3X3g2G2M7X3N6Q4x3V1k6T1K9h3c8S2M7$3y4A6i4K6u0r3N6X3#2H3M7X3!0@1k6h3y4@1i4K6u0V1x3#2)9J5k6e0g2Q4x3X3f1I4
6deK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6e0L8%4W2n7k6h3q4F1e0h3W2D9K9%4S2Q4x3V1k6K6L8@1I4G2j5h3c8W2M7R3`.`.
更多【Android安全- VMP攻略笔记】相关视频教程:www.yxfzedu.com