前言
最近分析了一个 APK 加固样本,大致看了下,确认是某知名搜索引擎出品的加固方案。部分函数带有 DexVMP 保护,整体架构和开源项目 nmmp 有不少相似之处。可以参考以下文章辅助理解:
初步分析
定位检测 so
还是老套路:先 Hook linker 的 call_array,判断加载到哪个 so 时闪退。

看一下 Logcat,发现退出时会打印 XOX: state=545。

JNI_OnLoad 被加密了,第三个 init_array 函数中出现了对未解密函数的调用,由此推断解密的逻辑在前两个 init_array 函数里。


.init_array1
第一个函数明显在做偏移计算,其中 sub_9F090 调用了 syscall mprotect,这是要修改内存中代码/数据的前提条件。


sub_9F130 负责解密 data 段。它用 v2 作为起始地址、qword_D0010 作为长度,按顺序与 0x67, 0x69, 0xBD, 0xE7, 0x11, 0xD1, 0x54, 0x3C 做 XOR 运算。


sub_9B7D4 对指定内存区域做 cache flush,让解密后的代码/数据生效(刷新 D-cache)。Hook 这个函数打印参数一和参数二,会触发两次。第一次的返回值:


0xad080 是 data 段的起始地址,0xb5898 就是结束地址。


init_array2
sub_9F370 插满了不透明谓词,把代码段的解密、修补、权限切换、cache flush 全部打散进状态机里,分析起来比较头疼。


so 脱壳
还原成完整算法确实麻烦。不过既然手里已经有数据段和代码段的起始地址与长度,那就在 sub_9F370 执行完后直接把这两段 dump 下来。
Interceptor.attach(libbaiduprotect.add(0x9F370), {
onLeave(ret) {
var libxx = Process.findModuleByName("libbaiduprotect.so");
var file_path = "/data/data/xxx/data.bin";
console.log(file_path);
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
Memory.protect(ptr(libxx.base.add(0xad080)), 0x8818, 'rwx');
var libso_buffer = ptr(libxx.base.add(0xad080)).readByteArray(0x8818);
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump]:", file_path);
}
}
})
把 dump 下来的数据在 010 Editor 里找到对应偏移并覆盖,以下是修改前后对比。


解密字符串
修复完的 so 不用 sofix 也能直接打开(如果 dump 整个 so 再 sofix,容易出现写内存地址、导入函数识别错误的情况)。JNI_OnLoad 此时已经能正常查看了。

字符串表里突然多了一大堆十六进制字符串。

随便挑一个字符串的引用跟进去,发现都被统一当做参数一传递给了某个解密函数。

解密算法很简单:malloc 申请对应大小的内存,拿 byte_AE4B9 当 key,循环按位异或解密出原始字符串。问题是,字符串和密钥的组合非常多,手动处理不现实,得用 IDAPython 来匹配并批量模拟解密。

粗略扫一遍,发现所有解密算法都一样,唯独 key 不同,对应的汇编指令也完全一致。那就可以取下面这组特征指令,在 .text 段从头到尾做模式匹配。
AND W16, W9, #0xFF
LDURB W17, [X8,#-1]
CMP W16, #0x3A ; ':'
CSEL W16, WZR, W10, CC
CMP W17, #0x3A ; ':'
CSEL W18, W12, W11, CC
CMP W14, #8
CSEL W14, WZR, W14, EQ
整体逻辑是:
- 匹配包含算法特征的函数
- 在函数内匹配 key 地址
- 查找当前函数的交叉引用
- 匹配待解密字符串的地址
这样就能批量解密出大部分字符串。
import idaapi
from idaapi import *
import idautils
import idc
from idc import *
from capstone import *
from keystone import *
import json
TARGET_SEQUENCE = [
"AND",
"LDURB",
"CMP",
"CSEL",
"CMP",
"CSEL",
"CMP",
"CSEL",
]
def decrypt(hex_str: str, key):
out = bytearray()
for i in range(len(hex_str) // 2):
b = int(hex_str[i * 2:i * 2 + 2], 16)
key_byte = ida_bytes.get_byte(key + (i % 8))
out.append(b ^ key_byte)
return out.split(b'\x00')[0].decode(errors="ignore")
def find_x13_define(ea):
cur = ea
while cur != idaapi.BADADDR:
mnem = idc.print_insn_mnem(cur)
if mnem in ["ADR", "ADRL", "ADRP"]:
if idc.print_operand(cur, 0) == "X13":
return idc.get_operand_value(cur, 1)
cur = idc.prev_head(cur)
return None
def get_x0_string(addr):
"""
从某条指令向上回溯 ADRL X0
"""
cur = addr
for _ in range(20): # 防止死循环
cur = idc.prev_head(cur)
if cur == idc.BADADDR:
break
mnem = idc.print_insn_mnem(cur)
# ARM64 常见:ADRP + ADD / ADRL
if mnem in ["ADRP", "ADRL", "ADR"]:
op0 = idc.print_operand(cur, 0)
# 确保是 X0
if "X0" in op0:
op1 = idc.print_operand(cur, 1)
# 直接取字符串引用
str_ea = idc.get_operand_value(cur, 1)
if str_ea != idc.BADADDR:
return idc.get_strlit_contents(str_ea, -1, idc.STRTYPE_C)
return None
def find_fun_cross_list(key_addr, ea):
for xref in idautils.XrefsTo(ea):
call_ea = xref.frm
print("\nCALL at:", hex(call_ea))
s = get_x0_string(call_ea)
if s:
try:
print("X0 string: %s key地址:0x%x" % (s.decode(), key_addr))
dec = decrypt(
s.decode(),
key_addr
)
print(dec)
except:
print("X0 raw:", s)
else:
print("no X0 string found")
def find_target_sequence(start_addr, end_addr):
current_addr = start_addr
sequence_index = 0
locations = []
while current_addr < end_addr:
if not idc.is_code(idc.get_full_flags(current_addr)):
current_addr += 4
continue
mnem = idc.print_insn_mnem(current_addr)
if mnem == TARGET_SEQUENCE[sequence_index]:
if sequence_index == 0:
start_hit = current_addr
sequence_index += 1
if sequence_index == len(TARGET_SEQUENCE):
locations.append(start_hit)
print("[MATCH]", hex(idaapi.get_func(start_hit).start_ea))
key_addr = find_x13_define(current_addr)
find_fun_cross_list(key_addr, idaapi.get_func(start_hit).start_ea)
sequence_index = 0
else:
sequence_index = 0
current_addr += 4
return locations
seg = idaapi.get_segm_by_name(".text")
start_address = seg.start_ea
end_address = seg.end_ea
results = find_target_sequence(start_address, end_address)
print("total:", len(results))

Frida 检测
开头提到的 Logcat 打印 XOX: state=545,在字符串表里搜 state=,随便跟到一个引用位置,逻辑清晰得很,甚至直接打印了对应数字。那直接搜常数 545 就行。

有三处打印了 545,都在同一个函数里。

sub_30310 里面全是检测 Frida 的特征。用 Interceptor.replace 直接替换这个函数后,应用不闪退了。


去不透明谓词混淆
分析 JNI_OnLoad 时,发现大量不透明谓词混淆。根据混淆设定,这些判断条件永远不执行——因为 dword_D7ED0 所在段可读可写,IDA 无法确定它的值,所以伪代码把这些垃圾分支保留了下来,让分析者自己判断。解决思路很直接:要么把 dword_D7ED0 所在段改成只读不可写,要么手动 patch 成 0。

import idaapi
import ida_name
import ida_bytes
import ida_segment
import ida_auto
import idautils
import idc
opaque_vars = [
"dword_D7ECC",
"dword_D7EC8",
"dword_D7EDC",
"dword_D7ED8",
]
touched_segments = set()
for name in opaque_vars:
ea = ida_name.get_name_ea(idaapi.BADADDR, name)
if ea == idc.BADADDR:
print("[!] not found:", name)
continue
old = ida_bytes.get_dword(ea)
ida_bytes.patch_dword(ea, 0)
print("[+] %s %s: 0x%x -> 0" % (name, hex(ea), old))
seg = ida_segment.getseg(ea)
if seg:
touched_segments.add(seg.start_ea)
for seg_ea in touched_segments:
seg = ida_segment.getseg(seg_ea)
if seg:
print("[+] set read-only:", ida_segment.get_segm_name(seg), hex(seg.start_ea), hex(seg.end_ea))
seg.perm = ida_segment.SEGPERM_READ
ida_segment.update_segm(seg)
for f in idautils.Functions():
end = idc.get_func_attr(f, idc.FUNCATTR_END)
ida_auto.plan_and_wait(f, end)
print("[+] done, press F5 again")
Patch 完后让 IDA 按 F5 重新分析,JNI_Onload 立刻变得清清爽爽。

Dump Dex
dump 大法
用yang 佬的 dump dex 脚本可以直接把所有 dex 拉出来(会夹带一些奇怪的东西)。
AI 还原 Dex 解密算法
详细分析过程就不展开了,太长了,感觉单独写一篇都足够。这里直接列出文件结构:
| 文件 |
用途 |
| baiduprotect.md |
存放 Dex 数量、解密 Key 等 |
| baiduprotect1.d.jar |
带 d.jar 的是 Dex VMP |
| baiduprotect1.jar |
普通 DEX |
这些文件开头都有共同的特征字节 14 94 B5 35。

它并没有调用 Assets 打开文件,而是直接打开 /data/app/xxx/base.apk,然后按 ZIP 格式提取需要的条目。

提取出来的文件需要解密:把头部 0x100 字节(d.jar 是 0x200)还原成正确的 zlib 文件头。具体流程如下:
- 用 HKDF-SHA256 派生 key/counter
- 用 AES-128 加密 counter 生成 keystream
- data XOR keystream
- zlib 解压

已知 key 是 0x10 个字节,固定存放在 baiduprotect.md。直接 dump g_payload_index_header,然后让 AI 生成对应的解密算法。

python .\decrypt_djar.py .\baiduprotect1.jar test.dex --ikm-hex 289313ddec23416b48695a7ee2c8b02d --crypt-len 0x100
GPT 一拳下去,裤子都给打掉,太强了。(解密代码见文末)

DexVMP onCreate 函数
onCreate 原本的函数体变成了 AB.v 调用。不同函数,参数一的数值不同,估计就是用这个来区分不同的 VMP method。


AB.v 函数动态注册,指向 0x4e020。
[RegisterNatives] java_class: com.sagittarius.v6.AB name: v sig: (ILjava/lang/Object;[Ljava/lang/Object;)V fnPtr: 0x78a6d27020 module_name: libbaiduprotect.so module_base: 0x78a6cd9000 offset: 0x4e020
Trace 一份函数调用日志
为了方便后续分析,先 trace 一份日志。推荐几个大佬写的 trace 项目:
https://github.com/zgy0x01/QTrace
https://github.com/lidongyooo/GumTrace (本文用的这个)
trace 过程可能会报错中断,需要修改 transform_callback,跳过报错指令,不然 trace 不下去。
trace 完才 13M,估计是原本函数代码也没多少。

0x4e020 的逻辑也很简单,就是个直接调用。这里因为堆栈原因,IDA 的参数识别有误——后面不应该是 0, 0, 0, 0,而应该是 i2 的低 16 位、obj 和 objArr 等。

g_vmp_context_by_id[256] 是已经初始化好的上下,数据来源是 baiduprotect1.d.jar。处理方式和普通 dex 一样:提取、解密头部 0x200 字节、zlib 解压,最后由 vmp_load_context_from_dex_and_register_bridge 进行解析。

进来就看到解析 Dex 头部,解析出一堆 Dex 字段偏移地址。

大概还原下返回的结构体,主要是 method_ids、class_defs、field_ids、string_ids,因为 ins 转换成 smali 需要这些信息。
typedef struct DexView
{
OdexHeader *odex_header; // +0x00, only set for dey/odex input
DexHeader *dex_header; // +0x08, actual dex header
void *type_ids; // +0x10 = dex + type_ids_off
void *proto_ids; // +0x18 = dex + proto_ids_off
void *method_ids; // +0x20 = dex + method_ids_off
void *class_defs; // +0x28 = dex + class_defs_off
void *field_ids; // +0x30 = dex + field_ids_off
void *data_section; // +0x38 = dex + data_off
void *string_ids; // +0x40 = dex + string_ids_off
void *odex_pklc_chunk; // +0x48, payload of tag "PKLC"
void *odex_pamr_chunk; // +0x50, payload of tag "PAMR"
DexHeader *raw_dex_base; // +0x58, same as dex_header after odex unwrap
void *reserved_60; // +0x60, allocated size is 0x68
} DexView;
while 循环将初始化好的 context 填进 vmp_context 数组里。

vmp_execute_method
回到 vmp_execute_method。参数一命名为 vmp_ctx,参数二是 method_id,其他都是 Java 层传进来的参数。一进来先把 JNI 参数压到栈里,最终的 handler 会用到它们。

Tips:DexVMP 最后都逃不掉 JNI 反射调用(除了 if 等逻辑运算,所以那些 dexvmp dump 系统用的 jni trace 也只能还原一部分 call 调用)。
vmp_interpreter_enter
vmp_interpreter_enter 是最终分发 opcode handler 的函数。这是强制让 IDA 识别为 Switch 的 Graph 图。

大概还原下 vmp_interpreter_enter 参数 method_desc 的结构体:
__int64 __fastcall vmp_interpreter_enter(
void *vmp_ctx,
JNIEnv *env,
void *method_desc,
unsigned __int64 *regs,
unsigned __int8 *reg_type_map)
typedef struct VmpMethodDesc {
uint32_t method_key; // +0x00, 例如 0xAB000074
uint16_t unknown_04; // +0x04, 不是 code size
uint16_t pad_06; // +0x06
uint32_t access_flags; // +0x08
uint16_t unknown_0c; // +0x0C
uint16_t first_arg_reg; // +0x0E
uint16_t ins_size; // +0x10, 入参数量
uint16_t registers_size; // +0x12, .registers
uint16_t signature_idx; // +0x14
uint16_t insns_size; // +0x16, 指令数量,单位 code unit
uint16_t *insns; // +0x18, VMP 指令起始地址
uint16_t handler_info_20; // +0x20
uint16_t handler_size; // +0x22
uint32_t pad_24; // +0x24
void *handler_data; // +0x28
} VmpMethodDesc; // size 0x30
从 g_vmp_base_handler_table 取 handler,间接跳转过去。

g_vmp_base_handler_table
g_vmp_base_handler_table 默认并不是标准顺序,第一次调用时会根据 vmp_ctx 进行重排。


用 Frida hook vmp_build_runtime_handler_table,循环打印 g_vmp_base_handler_table 256 次,拿指针减去 libso 基地址,然后 patch 回 IDA。
Interceptor.attach(libbaiduprotect.add(vmp_build_runtime_handler_table), {
onLeave(retval) {
for (let op = 0; op < 256; op++) {
const handler = libbaiduprotect.add(g_vmp_base_handler_table).add(op * Process.pointerSize).readPointer();
console.log(
'op=0x' + ('00' + op.toString(16)).slice(-2) +
' -> ' + ptrOff(libbaiduprotect, handler) +
' abs=' + handler
);
}
},
});
import ida_auto
import ida_bytes
import ida_idaapi
import ida_kernwin
import ida_name
import ida_nalt
TABLE_NAME = "g_vmp_base_handler_table"
TABLE_RVA = 0xD1418
# Set True first if you only want to preview the target table and values.
DRY_RUN = False
# Opcode order:
# VMP_REORDERED_HANDLER_OFFSETS[opcode] == handler offset to write into table.
VMP_REORDERED_HANDLER_OFFSETS = [
# 填入 256 个 handler 偏移
]
def msg(s):
ida_kernwin.msg("[patch_vmp_handler_table] %s\n" % s)
def resolve_table_ea():
ea = ida_name.get_name_ea(ida_idaapi.BADADDR, TABLE_NAME)
if ea != ida_idaapi.BADADDR:
return ea
candidates = [TABLE_RVA, ida_nalt.get_imagebase() + TABLE_RVA]
for cand in candidates:
if ida_bytes.is_loaded(cand):
return cand
return ida_idaapi.BADADDR
def patch_table(table_ea, offsets):
if len(offsets) != 256:
raise ValueError("expected 256 handler offsets, got %d" % len(offsets))
msg("table_ea=0x%X dry_run=%s" % (table_ea, DRY_RUN))
msg("first: op=0x00 -> 0x%X, last: op=0xFF -> 0x%X" % (offsets[0], offsets[-1]))
for opcode, handler_off in enumerate(offsets):
item_ea = table_ea + opcode * 8
if not DRY_RUN:
ida_bytes.patch_qword(item_ea, handler_off)
ida_bytes.set_cmt(item_ea, "op=0x%02X -> handler=0x%X" % (opcode, handler_off), 0)
if not DRY_RUN:
ida_name.set_name(table_ea, TABLE_NAME, ida_name.SN_CHECK | ida_name.SN_NOWARN)
ida_auto.auto_wait()
ida_kernwin.refresh_idaview_anyway()
msg("done: patched %d qwords" % len(offsets))
def main():
table_ea = resolve_table_ea()
if table_ea == ida_idaapi.BADADDR:
raise RuntimeError("cannot resolve %s / 0x%X" % (TABLE_NAME, TABLE_RVA))
patch_table(table_ea, VMP_REORDERED_HANDLER_OFFSETS)
if __name__ == "__main__":
main()
重排前后的对比效果显著。


看第一条 opcode
当 handler 表重排后,看第一次分发的指令对应的 handler。第一次在 0x59FF0。

0x59FF0 在日志里只出现一次,它不是循环解析再调用,而更像是控制 PC 或者 goto 的形式。第一次实际调用的其实是 0x60bcc,0x60bcc 在 handler 表里排第 151 位(0x97)。

0x59f9c:从 x26 寄存器取两个字节到 w22,即 0x2097。
0x59fa0:add x8, x16, w22, uxtb #3;w22 取低 8 位,即 0x97,正好对应了上面的 0x60bcc。

0x789bb1ef02 的取值来自 [x19, #0x18],这正好对上了 uint16_t *insns; // +0x18, VMP 指令起始地址。

用 Frida hook vmp_interpreter_enter 打印出当前函数的所有 ins。

97 20 对上了,符合 trace 日志。注意,目前看到的还是 DEX CodeItem 里的 Dalvik 字节码(可能经过混淆或加密)。

vmp_resolve_method_ref_for_invoke
一进来就看到各种 JNI GetMethodID,这时候基本可以大胆推测这是在模拟 invoke-***。

dex_get_method_ref_info(a1, method_idx, out_class_idx, out_class_desc, out_name, out_sig, out_return_type) 结合 AI 给的注释看,Frida hook 打印一下,基本确定就是 invoke-***,而且只改了 opcode。method_idx 1486 也正好对得上 dex 里的 method[1486]。


Dalvik 字节码
先看一下正常 Dalvik 字节码格式跟 smali 的关系:
正常 Dalvik 字节码
6F 20 04 00 32 00 14 00 1C 00 0B 7F 6E 20 09 00
02 00 1A 00 15 00 1A 01 24 00 71 20 00 00 10 00
1A 00 29 00 12 01 71 30 01 00 02 01 0C 00 6E 10
02 00 00 00 0E 00
Smali 代码
.registers 4
.param p1, "savedInstanceState" # Landroid/os/Bundle;
invoke-super {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V
const v0, 0x7f0b001c
invoke-virtual {p0, v0}, Lcom/demo/baidujiagu/MainActivity;->setContentView(I)V
const-string v0, "MainActivity"
const-string v1, "onCreate: "
invoke-static {v0, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
const-string v0, "test"
const/4 v1, 0x0
invoke-static {p0, v0, v1}, Landroid/widget/Toast;->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;
move-result-object v0
invoke-virtual {v0}, Landroid/widget/Toast;->show()V
return-void
各字节解析
6f 20 04 00 32 00
unit0 = 0x206f
opcode = 0x6f = invoke-super
A = 2 参数个数
G = 0
unit1 = 0x0004 = method_idx 4
method@4 = Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V
unit2 = 0x0032
C = 2, D = 3
参数 = {v2, v3} = {p0, p1}
Dalvik 字节码格式参考:https://source.android.com/docs/core/runtime/dalvik-bytecode?hl=zh-cn#instructions
经过上面的标准对比,invoke 范围在 0x6e - 0x72。目测 VMP 第一条指令还原成:
invoke-super {p0, p1}, Landroidx/fragment/app/FragmentActivity;->onCreate(Landroid/os/Bundle;)V

0x97 handler 最后 goto loc_628,loc_628 集中处理所有 invoke 类调用。


还原 opcode
vmp_interpreter_enter 整体可读性很强,没有特别复杂的 OLLVM,只是打乱了 opcode。
但是!戏剧性的一幕来了——代码里默认的 table 其实就是标准顺序。invoke-super 标准对应的是 0x6f,打乱后 0x97 对应的也是 loc_60BCC。只需要给 AI 提供两份前后 table,做一次对比,就能还原出可以正常解析的顺序。


最终效果
invoke-super {v1, v2}, Landroidx/fragment/app/Fragment;->onCreate(Landroid/os/Bundle;)V
new-instance v2, Lcom/baidu/rp/lib/base/c;
invoke-virtual {v1}, Landroidx/fragment/app/Fragment;->getActivity()Landroidx/fragment/app/FragmentActivity;
move-result-obj ect v1
const/4 v0, 0x0 # object-null
invoke-direct {v2, v0}, Lcom/baidu/rp/lib/base/c;-><init>(Landroid/content/Context;)V
iput-obj ect v2, v1, Lcom/baidu/rp/lib/base/BaseFragment;->baseContextWrapper:Lcom/baidu/rp/lib/base/c;
return-void
// Java-like hints
void method(/* recovered */) {
// cu_0000: 2097 05b1 0021
super.onCreate(v2);
// cu_0005: 109b 059b 0001
v1.getActivity();
// cu_0008: 008d
v0 = null;
// cu_0009: 2052 3a92 0002
v2 = new com.baidu.rp.lib.base.c(v0);
// cu_000c: 12e0 1ad8
v1.baseContextWrapper = v2;
// cu_000e: 0011
return;
}
总结
分析和写文章花了两天多时间。这个样本之所以定为“入门级”,是因为它没有太强的混淆和反调式,甚至 opcode 指令相关都没做加密,和开源项目 nmmp 很像。完全脱壳还原 dex 也并没有太大难度。完整样本就不提供了,不过应该也很好找。