一、加固原理概述 Android 应用加固(壳)的核心思路:将原始 DEX 文件加密后打包进 APK,类加载时由壳代码动态解密 DEX,并通过自定义 ClassLoader 加载到 ART 虚拟机中。常见加固厂商包括:腾讯乐固、360 加固保、梆梆安全、阿里聚安全等。
加固层次分为:DEX 整体加密 → DEX 方法抽取 → DEX 指令虚拟化(VMP) ,难度依次递增。
加固技术演进路线图 ┌──────────────────────────────────────────────────────────────┐ │ Android 加固技术发展史 │ ├──────────────────────────────────────────────────────────────┤ │ │ │ 第一代:整体 DEX 加密 (2013-2015) │ │ ┌─────────────────────────────────────┐ │ │ │ classes.dex → AES 加密 → 存为 asset │ │ │ │ 壳 Application.attachBaseContext() │ │ │ │ → 解密 asset → 内存加载 DEX │ │ │ │ 防护级别:低(dump 内存即可脱壳) │ │ │ └─────────────────────────────────────┘ │ │ ↓ │ │ 第二代:DEX 分段 + 方法抽取 (2015-2017) │ │ ┌─────────────────────────────────────┐ │ │ │ 将 DEX 中的方法体代码 (code_item) 移除 │ │ │ │ 存为加密数据在 so 或 asset 中 │ │ │ │ 首次调用方法时 → Native 解密 → 回填 │ │ │ │ 防护级别:中(静态 dump 只能得到空方法体)│ │ │ └─────────────────────────────────────┘ │ │ ↓ │ │ 第三代:VMP 代码虚拟化 (2017-至今) │ │ ┌─────────────────────────────────────┐ │ │ │ 将 DEX 字节码转为壳自定义的虚拟机指令 │ │ │ │ 壳自带解释器执行自定义指令集 │ │ │ │ 传统反编译器无法还原原始逻辑 │ │ │ │ 防护级别:高(需要逆向解释器本身) │ │ │ └─────────────────────────────────────┘ │ │ ↓ │ │ 第四代:SO 加固 + 多种手段结合 (2019-至今) │ │ ┌─────────────────────────────────────┐ │ │ │ so 加壳 (UPX-style 压缩+加密) │ │ │ │ Native 层 VMP │ │ │ │ 多层反调试、完整性校验 │ │ │ │ 资源加密、字符串全量混淆 │ │ │ └─────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────┘
加固的加载流程(以腾讯乐固为例) [App 启动] ↓ AndroidManifest.xml 中指定壳 Application: <application android:name="com.wrapper.StubApplication"> ↓ StubApplication.attachBaseContext() ↓ 加载 Native 壳 SO (libtup.so) ↓ 解密原始 DEX(AES/XXTEA 解密算法) ↓ 创建自定义 ClassLoader ↓ 通过反射替换系统的 ClassLoader (LoadedApk.mClassLoader) ↓ 反射调用原始 Application.attachBaseContext() ↓ 继续正常的 Activity 启动流程
二、检测加固类型 apktool d target.apk -o unpacked ls unpacked/lib/armeabi-v7a/
ls -la target/classes.dexgrep -r "StubShell\|StubApp\|Wrapper" unpacked/AndroidManifest.xml grep "StubApplication\|ProxyApplication\|WrapperApplication" \ unpacked/AndroidManifest.xml readelf -S unpacked/lib/armeabi-v7a/libtup.so | grep -E "UPX|\.shell|\.wrapper|\.packed" apkid target.apk
加固检测脚本 """自动检测 APK 加固类型""" import zipfileimport sysimport reSHELL_SIGNATURES = { '腾讯乐固' : ['libtup.so' , 'libshella-*.so' , 'libshellx-*.so' , 'mix.dex' , 'mixz.dex' ], '360加固保' : ['libjiagu.so' , 'libjiagu_a64.so' , 'libjiagu_x86.so' , 'libjiagu_x64.so' ], '梆梆安全' : ['libSecShell.so' , 'libSecShell-x86.so' , 'libDexHelper.so' ], '爱加密' : ['libexec.so' , 'libexecmain.so' , 'ijm_lib/armeabi/libexec.so' ], '阿里聚安全' : ['libmobisec.so' , 'libmobisecyun.so' , 'libsgmain.so' , 'libsgsecuritybody.so' ], '顶象安全' : ['libDexHelper.so' , 'libDexHelper-x86.so' ], '百度加固' : ['libbaiduprotect.so' ], '几维安全' : ['libkwscmm.so' , 'libkwscr.so' , 'libtianyu.so' ], '通付盾' : ['libshell.so' , 'libshell_x86.so' ], '网易易盾' : ['libnesec.so' , 'libnesec_art.so' ], '娜迦加固' : ['libddog.so' , 'libedog.so' ], } def detect_shell (apk_path ): """检测 APK 使用的加固方案""" detected = [] try : with zipfile.ZipFile(apk_path, 'r' ) as zf: file_list = zf.namelist() for vendor, signatures in SHELL_SIGNATURES.items(): for sig in signatures: pattern = re.compile (sig.replace('*' , '.*' ).replace('.' , r'\.' )) for f in file_list: if pattern.search(f): detected.append((vendor, f)) break try : dex_info = zf.getinfo('classes.dex' ) if dex_info.file_size < 50 * 1024 : detected.append(('UNKNOWN_SHELL' , f'classes.dex size: {dex_info.file_size} bytes (suspicious)' )) except KeyError: pass for f in file_list: if 'AndroidManifest.xml' in f: manifest = zf.read(f).decode('utf-8' , errors='ignore' ) suspicious_apps = ['StubApplication' , 'ProxyApplication' , 'WrapperApplication' , 'ShellApplication' ] for sa in suspicious_apps: if sa in manifest: detected.append(('SUSPICIOUS_APPLICATION' , f'Application class: {sa} ' )) except Exception as e: print (f"Error: {e} " ) return [] return detected if __name__ == '__main__' : if len (sys.argv) > 1 : results = detect_shell(sys.argv[1 ]) for vendor, detail in results: print (f"[+] {vendor} : {detail} " ) else : print ("Usage: python detect_shell.py <target.apk>" )
三、内存 DEX 脱壳 加固的 DEX 最终必须加载到内存才能执行。利用此特点,在运行时从内存中 Dump 解密后的 DEX。
脱壳原理详解 ┌──────────────────────────────────────────────────┐ │ 内存 DEX 脱壳原理 │ ├──────────────────────────────────────────────────┤ │ │ │ ART 加载 DEX 的过程: │ │ │ │ DexFile::DexFile() │ │ ↓ │ │ DexFile::OpenFile() 或 DexFile::OpenMemory() │ │ ↓ │ │ 创建 DexFile 对象,解析 DEX Header: │ │ - magic: "dex\n035\0" │ │ - file_size: 完整 DEX 大小 │ │ - data_size: 数据段大小 │ │ ↓ │ │ DexFile 对象存储在内存中,其起始地址 │ │ 向前偏移可找到完整的 DEX 文件数据 │ │ │ │ 脱壳的关键时间点: │ │ 1. DexFile 构造函数完成后(DEX 已解密) │ │ 2. defineClass 将类加载到 ClassTable 之前 │ │ 3. 任何时机:扫描内存中的 DEX magic 头 │ │ "dex\n035\0" 或 "dex\n037\0" 或 "dex\n038\0" │ │ (分别对应 Android 4-7, 8, 9+) │ │ │ └──────────────────────────────────────────────────┘
方法一:Frida Dump var dex_files = {};Java .perform (function ( ) { var DexFile = Java .use ("dalvik.system.DexFile" ); DexFile .$init .overload ('java.lang.String' ).implementation = function (path ) { console .log ("[+] DexFile loaded from: " + path); var result = this .$init(path); dex_files[path] = this ; return result; }; DexFile .$init .overload ('java.nio.ByteBuffer' ).implementation = function (buf ) { console .log ("[+] DexFile loaded from ByteBuffer, size: " + buf.limit ()); try { var bufferClass = Java .use ("java.nio.Buffer" ); var address = bufferClass.address .call (buf); console .log ("[+] ByteBuffer address: " + address); var magic = Memory .readByteArray (ptr (address), 8 ); console .log ("[+] Magic: " + hexdump (magic, { offset : 0 , length : 8 , header : false })); } catch (e) { console .log ("[-] Failed to read ByteBuffer: " + e); } return this .$init(buf); }; }); Java .perform (function ( ) { var BaseDexClassLoader = Java .use ("dalvik.system.BaseDexClassLoader" ); var pathListField = BaseDexClassLoader .class .getDeclaredField ("pathList" ); pathListField.setAccessible (true ); var DexPathList = Java .use ("dalvik.system.DexPathList" ); var dexElementsField = DexPathList .class .getDeclaredField ("dexElements" ); dexElementsField.setAccessible (true ); var Element = Java .use ("dalvik.system.DexPathList$Element" ); var dexFileField = Element .class .getDeclaredField ("dexFile" ); dexFileField.setAccessible (true ); function listDexFiles ( ) { var classLoader = Java .classFactory .loader ; while (classLoader) { if (classLoader.$className === "dalvik.system.BaseDexClassLoader" || classLoader.$className === "dalvik.system.PathClassLoader" || classLoader.$className === "dalvik.system.DexClassLoader" ) { var pathList = pathListField.get (classLoader); var elements = dexElementsField.get (pathList); for (var i = 0 ; i < elements.length ; i++) { var dexFile = dexFileField.get (elements[i]); if (dexFile) { console .log ("[*] Found DexFile: " + dexFile); } } } classLoader = classLoader.getParent (); } } listDexFiles (); });
frida -U -f com.target.app -l frida_dex_dump.js --no-pause
方法二:使用 Objection 脱壳 objection -g com.target.app explore android hooking list classes android heap search instances dalvik.system.DexFile plugin load /usr/share/objection/plugins/Wallbreaker plugin wallbreaker classdump --fullname com.example.MainActivity android heap execute 0x1234
方法三:完整的内存扫描脱壳 import fridaimport sysimport structDEX_SCRIPT = """ var DEX_MAGICS = [ [0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0x00], // dex\\n035 [0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x37, 0x00], // dex\\n037 [0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x38, 0x00], // dex\\n038 [0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x39, 0x00], // dex\\n039 ]; function scanAndDump(filename) { var modules = Process.enumerateRanges({protection: 'r--', coalesce: true}); modules.forEach(function(range) { var size = range.size; if (size < 1024) return; try { var data = Memory.readByteArray(range.base, Math.min(size, 100 * 1024 * 1024)); var arr = new Uint8Array(data); for (var i = 0; i < arr.length - 8; i++) { var matched = false; for (var m = 0; m < DEX_MAGICS.length; m++) { var match = true; for (var j = 0; j < 8; j++) { if (arr[i + j] !== DEX_MAGICS[m][j]) { match = false; break; } } if (match) { matched = true; break; } } if (matched) { // 读取 file_size (offset 32, 4 bytes, little-endian) var fileSize = arr[i + 32] | (arr[i + 33] << 8) | (arr[i + 34] << 16) | (arr[i + 35] << 24); console.log("[+] Found DEX at " + range.base.add(i) + ", size: " + fileSize); var dexData = Memory.readByteArray(range.base.add(i), fileSize); send({ type: 'dex', address: range.base.add(i).toString(), size: fileSize }, dexData); i += fileSize - 1; // 跳过当前 DEX } } } catch(e) { // 不可读区域,跳过 } }); } rpc.exports = { scan: scanAndDump }; """ def on_message (message, data ): if message['type' ] == 'send' : payload = message['payload' ] if payload.get('type' ) == 'dex' : filename = f"dump_{payload['address' ]} _{payload['size' ]} .dex" with open (filename, 'wb' ) as f: f.write(data) print (f"[+] Saved: {filename} " ) def main (): if len (sys.argv) < 2 : print ("Usage: python dexdump_memory.py <package_name>" ) sys.exit(1 ) device = frida.get_usb_device() pid = device.spawn([sys.argv[1 ]]) session = device.attach(pid) script = session.create_script(DEX_SCRIPT) script.on('message' , on_message) script.load() device.resume(pid) script.exports.scan("output" ) input ("Press Enter to stop..." ) session.detach() if __name__ == '__main__' : main()
方法四:针对方法抽取型加固的主动调用脱壳 Java .perform (function ( ) { var Class = Java .use ("java.lang.Class" ); var Method = Java .use ("java.lang.reflect.Method" ); var ActivityThread = Java .use ("android.app.ActivityThread" ); var currentApplication = ActivityThread .currentApplication (); var classLoader = currentApplication.getClassLoader (); var DexFile = Java .use ("dalvik.system.DexFile" ); function activeInvoke (dexFile ) { var entries = dexFile.entries (); while (entries.hasMoreElements ()) { var className = entries.nextElement (); try { var clazz = classLoader.loadClass (className); var methods = clazz.getDeclaredMethods (); for (var i = 0 ; i < methods.length ; i++) { try { methods[i].setAccessible (true ); var paramTypes = methods[i].getParameterTypes (); var args = []; for (var j = 0 ; j < paramTypes.length ; j++) { args.push (getDefaultValue (paramTypes[j])); } if (Modifier .isStatic (methods[i].getModifiers ())) { methods[i].invoke (null , args); } console .log ("[+] Invoked: " + className + "." + methods[i].getName ()); } catch (e) { } } } catch (e) { } } } function getDefaultValue (type ) { if (type == int.class ) return Java .int (0 ); if (type == long.class ) return Java .long (0 ); if (type == boolean.class ) return Java .boolean (false ); if (type == float.class ) return Java .float (0.0 ); if (type == double.class ) return Java .double (0.0 ); if (type == byte.class ) return Java .byte (0 ); if (type == short.class ) return Java .short (0 ); if (type == char.class ) return Java .char ('\0' ); return null ; } });
注意:主动调用脱壳需要处理参数类型匹配、构造函数的对象实例化、类初始化顺序等问题。对于加固应用,类初始化器中可能有反调试检测,需要先用 Frida 绕过。
脱壳后的验证 dexdump -d dump_0x7a1234000_1234567.dex | head -20 jadx-gui dump_0x7a1234000_1234567.dex xxd dump_0x7a1234000_1234567.dex | head -4
四、对抗反调试 加固应用通常会检测调试状态:
adb shell cat /proc/$(adb shell pidof com.target.app)/status | grep TracerPid
反调试检测技术全景 ┌──────────────────────────────────────────────────────┐ │ Android 反调试检测技术分类 │ ├──────────────────────────────────────────────────────┤ │ │ │ 1. ptrace 检测 │ │ - ptrace(PTRACE_TRACEME) → 失败 = 已被调试 │ │ - 定时检查 (fork 子进程循环检测) │ │ │ │ 2. /proc 文件系统检测 │ │ - /proc/self/status → TracerPid != 0 │ │ - /proc/self/stat → 调试标志 │ │ - /proc/self/wchan → "ptrace_stop" │ │ - /proc/self/task/<tid>/stat │ │ │ │ 3. 调试器端口扫描 │ │ - netstat 扫描 23946 (IDA) │ │ - netstat 扫描 27042 (Frida) │ │ - 读取 /proc/net/tcp 检查连接 │ │ │ │ 4. 断点指令检测 │ │ - CRC/MD5 校验代码段 │ │ - 搜索 BRK #0 指令 │ │ - 检查代码是否被 0xCC (x86) 或软件断点修改 │ │ │ │ 5. 环境特征检测 │ │ - /system/app/ 中是否存在 Superuser.apk │ │ - selinux 是否处于 Permissive 模式 │ │ - ro.debuggable 系统属性 = 1 │ │ - /system/bin/su 是否存在 │ │ - /system/xbin/su 是否存在 │ │ │ │ 6. Frida 专项检测 │ │ - 扫描 /proc/self/maps 中的 frida/ 字符串 │ │ - 检查默认端口 27042 │ │ - D-Bus 通信检测(Frida 使用 D-Bus 协议) │ │ - 检查 so 列表中是否有 frida-agent 相关的 │ │ - 遍历线程名查找 "frida" │ │ │ │ 7. IDA 专项检测 │ │ - 检查 android_server 进程 │ │ - 检测 23946 端口 │ │ │ │ 8. 时间检测 │ │ - 测量代码块执行时间 → 异常长的执行时间 = 单步调试 │ │ - clock_gettime(CLOCK_MONOTONIC) 前后对比 │ │ │ └──────────────────────────────────────────────────────┘
对抗 ptrace 反调试的方法:
Interceptor .attach (Module .findExportByName ("libc.so" , "ptrace" ), { onEnter : function (args ) { if (args[0 ].toInt32 () == 0 ) { console .log ("[*] ptrace(PTRACE_TRACEME) called, bypassing..." ); } }, onLeave : function (retval ) { retval.replace (0 ); } });
对抗 TracerPid 检测:直接 patch /proc/self/status 的读取结果,或使用 MagiskHide 隐藏调试状态。
完整反调试绕过 Frida 脚本 var LIBC = "libc.so" ;var ptrace = Module .findExportByName (LIBC , "ptrace" );if (ptrace) { Interceptor .attach (ptrace, { onEnter : function (args ) { if (args[0 ].toInt32 () === 0 ) { this .bypass = true ; } }, onLeave : function (retval ) { if (this .bypass ) { retval.replace (0 ); } } }); } var fopen = Module .findExportByName (LIBC , "fopen" );Interceptor .attach (fopen, { onEnter : function (args ) { var path = Memory .readUtf8String (args[0 ]); if (path && path.indexOf ("/proc/" ) !== -1 && (path.indexOf ("status" ) !== -1 || path.indexOf ("stat" ) !== -1 )) { console .log ("[*] fopen hooked: " + path); this .hooked = true ; } } }); var strstr = Module .findExportByName (LIBC , "strstr" );Interceptor .attach (strstr, { onEnter : function (args ) { try { var haystack = Memory .readUtf8String (args[0 ]); var needle = Memory .readUtf8String (args[1 ]); if (needle && (needle.indexOf ("frida" ) !== -1 || needle.indexOf ("gum-js" ) !== -1 || needle.indexOf ("linjector" ) !== -1 )) { console .log ("[*] strstr anti-frida: " + needle); this .bypass = true ; } } catch (e) {} }, onLeave : function (retval ) { if (this .bypass ) { retval.replace (ptr (0 )); } } }); var pthread_create = Module .findExportByName (LIBC , "pthread_create" );Interceptor .attach (pthread_create, { onEnter : function (args ) { var startRoutine = args[2 ]; var module = Process .findModuleByAddress (startRoutine); if (module ) { console .log ("[*] New thread: " + module .name + " + " + startRoutine.sub (module .base )); } } }); ["exit" , "_exit" , "abort" ].forEach (function (funcName ) { var func = Module .findExportByName (LIBC , funcName); if (func) { Interceptor .attach (func, { onEnter : function (args ) { console .log ("[!] " + funcName + "(" + args[0 ] + ") blocked!" ); while (true ) { Thread .sleep (1 ); } } }); } }); console .log ("[+] Anti-anti-debug hooks installed!" );
五、对抗 APK 签名校验 加固应用常调用 PackageManager.getPackageInfo() 校验 APK 签名:
var pkgMgr = Java .use ("android.content.pm.PackageManager" );pkgMgr.getPackageInfo .implementation = function (pkg, flags ) { console .log ("[*] getPackageInfo called for: " + pkg); return this .getPackageInfo (pkg, flags); };
签名校验的多层防御与绕过 ┌─────────────────────────────────────────────────────┐ │ 签名校验的多层防护结构 │ ├─────────────────────────────────────────────────────┤ │ │ │ Layer 1: Java 层签名校验 │ │ ├─ PackageManager.getPackageInfo(pkg, GET_SIGNATURES)│ │ └─ 绕过:Frida Hook 返回原始签名 │ │ │ │ Layer 2: Native 层签名校验 │ │ ├─ 通过 JNI 调用 PackageManager API │ │ ├─ 直接读取 META-INF/*.RSA 文件手动解析 │ │ ├─ 调用 Java Signature API 验证 │ │ └─ 绕过:Hook JNI GetMethodID/CallObjectMethod │ │ Hook libc open/read 拦截证书文件读取 │ │ IDA 中 Patch 比较指令 │ │ │ │ Layer 3: SO 自身完整性校验 │ │ ├─ CRC32 / MD5 / SHA256 of .text section │ │ ├─ 对比硬编码在 .rodata 中的预期哈希 │ │ └─ 绕过:Hook 最终比较逻辑 (strcmp/memcmp) │ │ 修改 .rodata 中的哈希值为实际值 │ │ │ │ Layer 4: 服务器端签名校验 │ │ ├─ 将签名信息发往服务器验证 │ │ └─ 绕过:Hook 网络请求,替换请求体或响应体 │ │ (难度高,服务器逻辑不可控) │ │ │ └─────────────────────────────────────────────────────┘
六、处理 Multi-DEX 应用 加固后的应用通常包含多个 DEX:
jadx-gui classes.dex classes2.dex classes3.dex java -jar dx.jar --dex --output=merged.dex *.dex
Multi-DEX 加载原理 public static void install (Context context) { File sourceApk = new File (context.getApplicationInfo().sourceDir); File dexDir = new File (context.getDataDir(), "secondary-dexes" ); ClassLoader loader = context.getClassLoader(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { Object dexPathList = getField(loader, "pathList" ); Object[] existingElements = (Object[]) getField(dexPathList, "dexElements" ); Object[] newElements = makeDexElements(dexFiles, optimizedDir); Object[] combined = combineArray(existingElements, newElements); setField(dexPathList, "dexElements" , combined); } }
七、VMP(虚拟机保护)分析初步 VMP 是当前最难以分析的保护方式:
┌───────────────────────────────────────────────┐ │ VMP 保护原理与分析思路 │ ├───────────────────────────────────────────────┤ │ │ │ 原始 DEX 字节码: │ │ const/4 v0, 0x1 │ │ invoke-static {v0}, LClass;->method(I)V │ │ if-eqz v0, :label │ │ │ │ VMP 转换后: │ │ .byte 0xA3 0x01 0x00 │ │ .byte 0xB7 0x12 0x34 │ │ .byte 0xC2 0x05 │ │ (自定义指令集格式) │ │ │ │ 运行时解释器: │ │ while (true) { │ │ opcode = *ip++; │ │ switch (opcode) { │ │ case 0xA3: // MOV_CONST │ │ reg = *ip++; │ │ value = *(int*)ip; ip += 4; │ │ regfile[reg] = value; │ │ break; │ │ case 0xB7: // INVOKE_STATIC │ │ ... │ │ } │ │ } │ │ │ │ 分析策略: │ │ 1. 定位解释器主循环 (while + switch 结构) │ │ 2. 逆向每个 opcode handler 的语义 │ │ 3. 编写专用的反汇编器/反编译器 │ │ 4. 使用 Symbolic Execution 辅助分析 │ │ 5. 使用 Trace 工具记录指令序列 │ │ │ └───────────────────────────────────────────────┘
面试常考问题 Q1:Frida 脱壳的原理是什么?被检测到怎么办?
A:Frida 脱壳利用应用运行时 DEX 已在内存中解密的特点,通过 Hook ClassLoader 或直接遍历内存将 DEX dump 出来。核心原理是:无论加固多复杂,DEX 在执行前必然在内存中处于明文状态。脱壳时机包括:DexFile 构造函数完成后、ClassLoader.loadClass 执行前后、或主动扫描内存中的 DEX magic(dex\n035/037/038)。被检测的对抗措施:(1) 使用 Frida 的 anti-detection 脚本(hook strstr 检测 frida 特征字符串、hook fopen 检测 /proc/self/maps 读取);(2) 使用改名的 Frida Server(如 hluda-server),修改默认端口、进程名和 D-Bus 标识;(3) 编译自定义 Frida gadget 嵌入 APK,使用进程内注入而非远程调试协议;(4) 使用 Magisk 的 Zygisk 模块加载 Frida gadget,躲过常规检测。
Q2:什么是 VMP(虚拟化保护)?如何分析?
A:VMP 将 DEX 字节码转换为自定义的虚拟机指令,运行时由壳自带的解释器执行。传统反编译工具无法还原。分析需要逆向壳本身的虚拟机解释器,理解其指令编码格式,编写专用的反汇编器。具体步骤:(1) 在 IDA 中定位解释器的主循环——通常是大 while(true) + 巨型 switch 结构;(2) 逆向每个 case 对应的指令 handler,记录语义(如 0xA3 = MOV_REG_CONST、0xB7 = INVOKE_STATIC);(3) 构建指令集手册;(4) 编写 Python 脚本读取加密的 DEX section,按照指令集手册反汇编;(5) 对反汇编后的代码进行控制流分析和数据流分析,尝试还原出高级逻辑。辅助手段:使用 Frida Stalker 记录所有执行的指令 trace,分析热点路径;使用符号执行引擎(如 angr)对解释器进行符号执行,推导某段 VMP 代码的实际语义。
Q3:加固应用如何做 so 层分析?
A:先用工具(如 Frida 的 dlopen Hook)确认 so 文件被加载到哪个内存地址,然后使用 IDA Pro 的 attach 功能进行动态调试。对于加密的 so,可以通过 /proc/pid/maps 查看内存布局后,用 dd 命令从内存中 dump 解密后的 so。具体流程:(1) 找到 so 加载时机——使用 Frida Hook android_dlopen_ext 或 dlopen;(2) 记录 so 在内存中的基址;(3) 从 /proc/<pid>/maps 中读取 so 对应的内存段范围(含代码段、数据段等);(4) 使用 Frida 的 Memory.readByteArray 或 adb shell dd if=/proc/<pid>/mem 从对应范围 dump 解密后的 so;(5) 将 dump 出的 so 加载到 IDA/Ghidra 中进行静态分析。对于做了代码自修改(SMC)的 so,可能在运行过程中动态修改代码段,需要在关键函数执行前后分别 dump 对比。
Q4:方法抽取型加固(第二代)的原理和脱壳方法?
A:方法抽取型加固的核心是将 DEX 中每个方法的实际字节码(code_item)从 DEX 文件中剥离,替换为空方法体或指向错误地址的 stub。原始字节码加密存储在 so 的 .rodata 段或 APK 的 assets 中。当方法首次被调用(ART 需要执行 code_item 中的指令)时,触发壳的回填机制:Native 层的 Hook 拦截 ART 的方法执行入口 → 从加密存储中读取该方法的原始 code_item → 解密后将 code_item 写回内存中的 DEX 结构 → 方法正常执行。脱壳方法:(1) 主动调用——遍历所有类和方法,触发所有方法的回填,然后 dump 内存中完整的 DEX;(2) Hook ART 内部函数——Hook ArtMethod::Invoke() 或 ClassLinker::GetMethod(),在回填完成后 dump;(3) 逆向壳的回填算法——提取加密的 code_item 和数据,编写脚本离线解密后拼接回 DEX 文件。
Q5:如何检测一个 Android 应用是否被加固?加固后又如何判断加固厂商?
A:检测方法:(1) 解包 APK,检查 classes.dex 的大小——如果仅为几十 KB(正常应用通常 1-10MB+),很可能被加固;(2) 检查 lib/ 目录下是否有特征 so 文件——腾讯乐固的 libtup.so、360 的 libjiagu.so、梆梆的 libSecShell.so 等;(3) 检查 AndroidManifest.xml 中的 Application 类名——壳常替换为 StubApplication、WrapperApplication 等;(4) 使用 APKiD 等专用工具——apkid target.apk 可通过规则匹配自动识别包括加固在内的多种保护方案;(5) 尝试 JADX 打开 APK——如果只能看到少量类(如只有 Application 和几个 stub 类),而不是应用完整的代码结构,说明被加固;(6) 运行时使用 Frida 查看 ClassLoader 的实际 DEX 来源路径——加固应用通常从非标准路径加载。判断厂商主要通过特征 so 文件名、Application 类名、assets 中的配置文件(如 armeabi/dataop、ijiami.dat 等),以及加固工具的特定行为(如乐固的 mix.dex、梆梆的 classes.dgc)。